Compare commits

...
Author SHA1 Message Date
bb3beaa353 v1.9.1 2026-09-23 06:11:14 +00:00
27ce69c2d2 v1.8.1 2026-09-23 06:09:05 +00:00
1460cb231f v1.8.0 2026-09-11 10:45:13 +00:00
c7a8d9efcc v1.7.0 2026-08-24 06:42:23 +00:00
4f8ab239b0 v1.6.1 2026-08-18 10:49:06 +00:00
5589c54b9b v1.6.0 2026-08-18 10:48:13 +00:00
b2849880ec v1.5.0 2026-08-18 10:47:06 +00:00
8e6a52d8ce v1.4.1 2026-08-17 18:09:44 +00:00
21df13f02a v1.4.0 2026-08-17 16:07:31 +00:00
a2d1320030 v1.3.1 2026-08-17 16:06:40 +00:00
e3bfd7b6f2 v1.3.0 2026-08-17 16:05:38 +00:00
30451228f7 v1.2.1 2026-08-17 16:04:38 +00:00
0e617fa428 v1.2.0 2026-08-17 16:03:39 +00:00
e80820f416 v1.1.0 2026-08-17 16:03:10 +00:00
e7a4a45a74 v1.0.0 2026-08-17 16:01:48 +00:00
3a653a7bf3 v0.6.0 2026-08-17 16:00:15 +00:00
19 changed files with 3000 additions and 474 deletions

View file

@ -33,8 +33,11 @@ class Robotstxt_Manager_Admin {
* @return void
*/
public function register( Robotstxt_Manager_Loader $loader ): void {
$loader->add_action( 'admin_menu', $this, 'add_menu' );
$menu_hook = is_multisite() ? 'network_admin_menu' : 'admin_menu';
$loader->add_action( $menu_hook, $this, 'add_menu' );
$loader->add_action( 'admin_post_robotstxt_manager_refresh_catalog', $this, 'handle_refresh' );
$loader->add_action( 'admin_notices', $this, 'security_update_notices' );
$loader->add_action( 'network_admin_notices', $this, 'security_update_notices' );
}
/**
@ -43,10 +46,11 @@ class Robotstxt_Manager_Admin {
* @return void
*/
public function add_menu(): void {
$cap = is_multisite() ? 'manage_network_options' : 'manage_options';
add_menu_page(
esc_html__( 'Manager (by ROBOTSTXT) — Plugins', 'robotstxt-manager' ),
esc_html__( 'ROBOTSTXT', 'robotstxt-manager' ),
'manage_options',
$cap,
self::PAGE_SLUG,
array( $this, 'render_page' ),
'dashicons-screenoptions',
@ -60,35 +64,208 @@ class Robotstxt_Manager_Admin {
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-manager' ) );
}
$client = Robotstxt_Manager_Core_Client::from_options();
$catalog = $client->get_catalog();
$local = $this->resolve_local_state( $catalog );
$client = Robotstxt_Manager_Core_Client::from_options();
$catalog = $client->get_catalog();
$local = $this->resolve_local_state( $catalog );
$subscriptions = $client->get_subscriptions();
// Admin notices for subscriptions needing attention.
$manager_notices = $this->build_subscription_notices( $catalog, $subscriptions );
$manager_subscriptions = $subscriptions;
$manager_has_api_key = $client->has_api_key();
$manager_store_url = $client->get_store_url();
// Security patches declared for exactly the versions this site runs.
$manager_security_updates = $this->get_security_updates( $catalog, $local );
require ROBOTSTXT_MANAGER_DIR . 'admin/views/page-catalog.php';
}
/**
* Returns the security patches that apply to the exact versions this
* site runs (Core 1.16.0+ `security_patches` catalog data).
*
* @param list<array<string,mixed>> $catalog Catalog entries.
* @param array<string, array{installed:bool, active:bool, version:string}> $local Local state by slug.
*
* @return list<array{slug:string, name:string, installed:string, patch:string}>
*/
public function get_security_updates( array $catalog, array $local ): array {
$updates = array();
foreach ( $catalog as $entry ) {
$raw_slug = $entry['slug'] ?? '';
$slug = is_string( $raw_slug ) ? $raw_slug : '';
if ( '' === $slug ) {
continue;
}
$state = $local[ $slug ] ?? null;
if ( ! is_array( $state ) || empty( $state['installed'] ) ) {
continue;
}
$raw_version = $state['version'] ?? '';
$version = is_string( $raw_version ) ? $raw_version : '';
$patch = Robotstxt_Manager_Updater::security_patch_for( $entry, $version );
if ( '' === $patch ) {
continue;
}
$raw_name = $entry['name'] ?? '';
$clean_name = is_string( $raw_name ) && '' !== $raw_name ? $raw_name : $slug;
$updates[] = array(
'slug' => $slug,
'name' => $clean_name,
'installed' => $version,
'patch' => $patch,
);
}
return $updates;
}
/**
* Renders the security-update notices on the Plugins screen (not on the
* Manager catalog page, which shows its own block).
*
* @return void
*/
public function security_update_notices(): void {
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
$base = ( $screen instanceof WP_Screen ) ? (string) $screen->base : '';
if ( ! in_array( $base, array( 'plugins', 'plugins-network' ), true ) ) {
return;
}
$client = Robotstxt_Manager_Core_Client::from_options();
if ( ! $client->is_configured() ) {
return;
}
$catalog = $client->get_catalog();
$security = $this->get_security_updates( $catalog, $this->resolve_local_state( $catalog ) );
foreach ( $security as $update ) {
echo '<div class="notice notice-error"><p>';
echo wp_kses_post(
sprintf(
/* translators: 1: plugin name, 2: installed version, 3: patch version, 4: update URL. */
__( '<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href="%4$s">Update now</a> — this is a security patch for the version this site runs, not a feature update.', 'robotstxt-manager' ),
esc_html( $update['name'] ),
esc_html( $update['installed'] ),
esc_html( $update['patch'] ),
esc_url( self::action_url( 'update', $update['slug'] ) )
)
);
echo '</p></div>';
}
}
/**
* Builds admin notices for subscriptions that need attention.
*
* - payment_failed: persistent warning per plugin.
* - expiring within 14 days: per-plugin warning.
* - expired while the plugin is still installed+active: per-plugin warning.
*
* @param list<array<string, mixed>> $catalog Catalog entries.
* @param array<string, array<string, mixed>> $subscriptions Rows keyed by slug.
*
* @return list<array{type:string, message:string}>
*/
private function build_subscription_notices( array $catalog, array $subscriptions ): array {
$notices = array();
$local = $this->resolve_local_state( $catalog );
foreach ( $subscriptions as $slug => $sub ) {
$raw_status = $sub['status'] ?? '';
$raw_expires_at = $sub['expires_at'] ?? '';
$status = is_string( $raw_status ) ? $raw_status : '';
$expires_at = is_string( $raw_expires_at ) ? $raw_expires_at : '';
if ( 'payment_failed' === $status ) {
$notices[] = array(
'type' => 'warning',
/* translators: %s: plugin slug. */
'message' => sprintf( __( 'The payment for %s failed. Update your payment method from your ROBOTSTXT account page to keep access.', 'robotstxt-manager' ), $slug ),
);
continue;
}
if ( 'expired' === $status ) {
$state = $local[ $slug ] ?? array();
if ( ! empty( $state['active'] ) ) {
$notices[] = array(
'type' => 'warning',
/* translators: %s: plugin slug. */
'message' => sprintf( __( 'The subscription for %s has expired, but the plugin is still active on this site. Renew from your ROBOTSTXT account page to keep receiving updates.', 'robotstxt-manager' ), $slug ),
);
}
continue;
}
if ( 'active' === $status && '' !== $expires_at ) {
$days = (int) floor( ( (int) strtotime( $expires_at ) - time() ) / DAY_IN_SECONDS );
if ( $days >= 0 && $days <= 14 ) {
$notices[] = array(
'type' => 'warning',
/* translators: 1: plugin slug, 2: days remaining. */
'message' => sprintf( _n( 'The subscription for %1$s expires in %2$d day.', 'The subscription for %1$s expires in %2$d days.', $days, 'robotstxt-manager' ), $slug, $days ),
);
}
}
}
return $notices;
}
/**
* Handles the "Refresh catalog" admin-post action.
*
* Clears the cached catalog response, purges WordPress's update_plugins
* transient so native update badges re-evaluate immediately, and
* redirects back to the page.
* redirects back to the page. Rate-limited to 6 refreshes per minute
* per user so a stuck browser cannot hammer the store.
*
* @return void
*/
public function handle_refresh(): void {
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-manager' ) );
}
check_admin_referer( 'robotstxt_manager_refresh_catalog' );
$bucket = 'robotstxt_manager_refresh_' . get_current_user_id();
$hits_raw = get_site_transient( $bucket );
$hits = is_numeric( $hits_raw ) ? (int) $hits_raw : 0;
if ( $hits >= 6 ) {
$this->redirect_refresh_error();
}
set_site_transient( $bucket, $hits + 1, MINUTE_IN_SECONDS );
$client = Robotstxt_Manager_Core_Client::from_options();
$client->clear_catalog_cache();
$client->clear_subscriptions_cache();
delete_site_transient( 'update_plugins' );
$redirect = add_query_arg(
@ -96,13 +273,34 @@ class Robotstxt_Manager_Admin {
'page' => self::PAGE_SLUG,
'refreshed' => '1',
),
admin_url( 'admin.php' )
( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Redirects back to the catalog page with a rate-limit error notice.
*
* @return void
*/
private function redirect_refresh_error(): void {
wp_safe_redirect(
add_query_arg(
array(
'page' => self::PAGE_SLUG,
'robotstxt_manager_result' => 'error',
'robotstxt_manager_message' => rawurlencode(
__( 'Too many refreshes. Please wait a minute before refreshing again.', 'robotstxt-manager' )
),
),
( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
)
);
exit;
}
/**
* Builds a nonce-protected admin-post action URL for a plugin row.
*

View file

@ -54,19 +54,297 @@ class Robotstxt_Manager_Installer {
$name = $this->entry_name( $entry, $slug );
$this->ensure_plugin_functions();
// Install missing dependencies first (ecosystem catalog plugins via
// the store, WordPress.org plugins via their repository ZIPs).
$deps_installed = $this->install_dependencies( $slug );
if ( is_wp_error( $deps_installed ) ) {
$this->redirect_error( $deps_installed->get_error_message() );
}
$result = $this->download_and_install( $slug );
if ( is_wp_error( $result ) ) {
$this->redirect_error( $result->get_error_message() );
}
$this->redirect_success(
sprintf(
/* translators: %s: plugin name. */
__( '%s installed. Activate it from the list below.', 'robotstxt-manager' ),
$name
$message = sprintf(
/* translators: %s: plugin name. */
__( '%s installed. Activate it from the list below.', 'robotstxt-manager' ),
$name
);
if ( is_string( $deps_installed ) && '' !== $deps_installed ) {
$message .= ' ' . $deps_installed;
}
$this->redirect_success( $message );
}
/**
* Installs the plugin's missing dependencies, dependencies first.
*
* Each dependency slug is resolved either against the store catalog
* (installed through the store's download endpoint, like any catalog
* plugin) or, when not in the catalog, against WordPress.org (installed
* from the repository's plugin ZIP).
*
* @param string $slug Plugin slug being installed.
*
* @return string|WP_Error Installed-dependency names for the notice, '' when none were needed.
*/
private function install_dependencies( string $slug ) {
$deps = $this->dependencies_for( $slug );
if ( array() === $deps ) {
return '';
}
$installed_names = array();
foreach ( $deps as $dep_slug ) {
if ( '' !== $this->find_plugin_file( $dep_slug ) ) {
continue; // Already installed.
}
$result = $this->install_one_dependency( $dep_slug );
if ( is_wp_error( $result ) ) {
/* translators: %s: dependency slug. */
$detail = sprintf( __( 'Could not install the required plugin %s.', 'robotstxt-manager' ), $dep_slug );
return new WP_Error(
'robotstxt_manager_dependency',
$detail . ' ' . $result->get_error_message()
);
}
$installed_names[] = $result;
}
if ( array() === $installed_names ) {
return '';
}
/* translators: %s: list of installed dependency names. */
return sprintf( __( 'Installed required plugins: %s.', 'robotstxt-manager' ), implode( ', ', $installed_names ) );
}
/**
* Installs a single dependency: catalog plugin via the store, else wp.org.
*
* @param string $dep_slug Dependency slug.
*
* @return string|WP_Error The dependency's display name on success.
*/
private function install_one_dependency( string $dep_slug ) {
if ( null !== $this->find_catalog_entry( $dep_slug ) ) {
return $this->install_via_store( $dep_slug );
}
// Not in the catalog — treat as a WordPress.org plugin.
return $this->install_via_wordpress_org( $dep_slug );
}
/**
* Installs a dependency that exists in the store catalog.
*
* @param string $dep_slug Dependency slug.
*
* @return string|WP_Error Dependency name on success.
*/
protected function install_via_store( string $dep_slug ) {
$result = $this->download_and_install( $dep_slug );
if ( is_wp_error( $result ) ) {
return $result;
}
$entry = $this->find_catalog_entry( $dep_slug );
return $this->entry_name( is_array( $entry ) ? $entry : null, $dep_slug );
}
/**
* Installs a dependency that is not in the catalog from WordPress.org.
*
* @param string $dep_slug wp.org plugin slug.
*
* @return string|WP_Error Plugin name on success.
*/
protected function install_via_wordpress_org( string $dep_slug ) {
return $this->install_wporg_zip( $dep_slug );
}
/**
* Resolves a plugin's dependency slugs (parsed, deduplicated, deps-first
* order, self-references dropped).
*
* @param string $slug Plugin slug.
*
* @return list<string> Dependency slugs.
*/
private function dependencies_for( string $slug ): array {
$all = array( $slug => true );
$queue = array( $slug );
$ordered = array();
while ( ! empty( $queue ) ) {
$current = array_shift( $queue );
foreach ( $this->raw_dependencies_of( $current ) as $dep ) {
if ( isset( $all[ $dep ] ) ) {
continue; // Already seen: self, duplicate, or cycle.
}
$all[ $dep ] = true;
$ordered[] = $dep;
$queue[] = $dep;
}
}
// Dependencies discovered later are deeper — reverse so dependencies
// of dependencies install first.
return array_reverse( $ordered );
}
/**
* Reads the raw requires-plugins list of a catalog entry.
*
* @param string $slug Plugin slug.
*
* @return list<string> Dependency slugs (unresolved).
*/
private function raw_dependencies_of( string $slug ): array {
$entry = $this->find_catalog_entry( $slug );
if ( null === $entry ) {
return array(); // wp.org plugin: dependencies come from its own headers on install.
}
$raw = $entry['requires_plugins'] ?? '';
$raw = is_string( $raw ) ? $raw : '';
if ( '' === $raw ) {
return array();
}
$slugs = array();
foreach ( explode( ',', $raw ) as $part ) {
$dep = sanitize_key( trim( $part ) );
if ( '' !== $dep ) {
$slugs[ $dep ] = true;
}
}
return array_keys( $slugs );
}
/**
* Installs a WordPress.org plugin by slug via plugins_api + Plugin_Upgrader.
*
* @param string $slug wp.org plugin slug.
*
* @return string|WP_Error Plugin name on success.
*/
private function install_wporg_zip( string $slug ) {
$this->ensure_plugin_functions();
if ( ! function_exists( 'plugins_api' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
}
$api = plugins_api(
'plugin_information',
array(
'slug' => $slug,
'fields' => array(
'sections' => false,
'versions' => false,
'downloaded' => false,
'rating' => false,
),
)
);
if ( is_wp_error( $api ) ) {
/* translators: %s: error message from WordPress.org. */
return new WP_Error( 'robotstxt_manager_wporg', sprintf( __( 'WordPress.org lookup failed: %s', 'robotstxt-manager' ), $api->get_error_message() ) );
}
$download_link = is_object( $api ) && isset( $api->download_link ) && is_string( $api->download_link )
? $api->download_link
: '';
if ( '' === $download_link ) {
/* translators: %s: plugin slug. */
return new WP_Error( 'robotstxt_manager_wporg', sprintf( __( 'No download found on WordPress.org for %s.', 'robotstxt-manager' ), $slug ) );
}
$tmp_file = wp_tempnam( $slug . '.zip' );
if ( ! $tmp_file ) {
return new WP_Error( 'robotstxt_manager_temp', __( 'Could not create a temporary file for download.', 'robotstxt-manager' ) );
}
$response = wp_remote_get(
$download_link,
array(
'timeout' => 300,
'stream' => true,
'filename' => $tmp_file,
)
);
if ( is_wp_error( $response ) ) {
wp_delete_file( $tmp_file );
/* translators: %s: HTTP transport error message. */
return new WP_Error( 'robotstxt_manager_download', sprintf( __( 'Download failed: %s', 'robotstxt-manager' ), $response->get_error_message() ) );
}
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
wp_delete_file( $tmp_file );
/* translators: %d: HTTP status code. */
return new WP_Error( 'robotstxt_manager_http', sprintf( __( 'Download failed (HTTP %d).', 'robotstxt-manager' ), (int) wp_remote_retrieve_response_code( $response ) ) );
}
if ( ! $this->is_valid_zip( $tmp_file ) ) {
wp_delete_file( $tmp_file );
return new WP_Error( 'robotstxt_manager_zip', __( 'The store returned an invalid file.', 'robotstxt-manager' ) );
}
$upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() );
$result = $upgrader->install(
$tmp_file,
array(
'overwrite' => false,
'overwrite_package' => false,
)
);
wp_delete_file( $tmp_file );
if ( true !== $result ) {
$detail = ( $result instanceof WP_Error ) ? $result->get_error_message() : '';
/* translators: %s: upgrader error message. */
return new WP_Error( 'robotstxt_manager_install', '' !== $detail ? sprintf( __( 'Installation failed: %s', 'robotstxt-manager' ), $detail ) : __( 'Installation failed.', 'robotstxt-manager' ) );
}
$file = $this->find_plugin_file( $slug );
if ( '' === $file ) {
/* translators: %s: plugin slug. */
return new WP_Error( 'robotstxt_manager_wporg', sprintf( __( 'The downloaded plugin for %s does not have the expected folder structure.', 'robotstxt-manager' ), $slug ) );
}
$data = get_plugins();
$raw_name = isset( $data[ $file ]['Name'] ) && is_string( $data[ $file ]['Name'] ) ? $data[ $file ]['Name'] : '';
return '' !== $raw_name ? $raw_name : $slug;
}
/**
@ -101,7 +379,9 @@ class Robotstxt_Manager_Installer {
}
/**
* Updates an installed plugin to the latest catalog version.
* Updates an installed plugin to the latest catalog version or, when a
* security patch is declared for the exact installed version (Core
* 1.16.0+), to that patch instead of the feature mainline.
*
* @return void
*/
@ -116,7 +396,17 @@ class Robotstxt_Manager_Installer {
$this->redirect_error( __( 'Plugin is not installed.', 'robotstxt-manager' ) );
}
$result = $this->download_and_install( $slug, true );
$patch = '';
$entry = $this->find_catalog_entry( $slug );
if ( is_array( $entry ) ) {
$all = get_plugins();
$version = isset( $all[ $file ]['Version'] ) && is_string( $all[ $file ]['Version'] ) ? $all[ $file ]['Version'] : '';
$patch = Robotstxt_Manager_Updater::security_patch_for( $entry, $version );
}
$result = $this->download_and_install( $slug, true, $patch );
if ( is_wp_error( $result ) ) {
$this->redirect_error( $result->get_error_message() );
@ -139,7 +429,7 @@ class Robotstxt_Manager_Installer {
* @return string The sanitized plugin slug.
*/
private function authorize( string $action ): string {
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-manager' ) );
}
@ -224,10 +514,12 @@ class Robotstxt_Manager_Installer {
*
* @param string $slug Plugin slug.
* @param bool $overwrite Whether to overwrite an existing install (update).
* @param string $security_version Patch version to download instead of the
* stable mainline (Core 1.16.0+), '' for stable.
*
* @return true|WP_Error True on success.
*/
private function download_and_install( string $slug, bool $overwrite = false ) {
private function download_and_install( string $slug, bool $overwrite = false, string $security_version = '' ) {
$client = Robotstxt_Manager_Core_Client::from_options();
if ( ! $client->is_configured() ) {
@ -249,12 +541,28 @@ class Robotstxt_Manager_Installer {
// Free plugins that publish a public download URL in the catalog are
// fetched directly (no auth). Everything else goes through Core's
// authenticated download endpoint (account API key as Bearer).
$use_download_endpoint = ! ( $is_free && '' !== $dl_url );
// authenticated download endpoint (account API key as Bearer), with
// this site's domain for per-domain license binding (Core 1.11.0+).
// Security patches always stream through the endpoint with a version
// parameter — the public URL only carries the mainline stable ZIP.
$use_download_endpoint = '' !== $security_version || ! ( $is_free && '' !== $dl_url );
$zip_url = $use_download_endpoint
? $client->get_store_url() . '/wp-json/robotstxt-core/v1/plugins/' . rawurlencode( $slug ) . '/download'
: $dl_url;
if ( $use_download_endpoint ) {
$dl_args = array(
'domain' => rawurlencode( $this->site_domain() ),
);
if ( '' !== $security_version ) {
$dl_args['version'] = rawurlencode( $security_version );
}
$zip_url = add_query_arg(
$dl_args,
$client->get_store_url() . '/wp-json/robotstxt-core/v1/plugins/' . rawurlencode( $slug ) . '/download'
);
} else {
$zip_url = $dl_url;
}
$tmp_file = wp_tempnam( $slug . '.zip' );
@ -265,7 +573,7 @@ class Robotstxt_Manager_Installer {
$headers = array();
if ( $use_download_endpoint ) {
$api_key_raw = get_option( 'robotstxt_manager_api_key', '' );
$api_key_raw = get_site_option( 'robotstxt_manager_api_key', '' );
$api_key = is_string( $api_key_raw ) ? Robotstxt_Manager_Encryption::decrypt( $api_key_raw ) : '';
if ( '' !== $api_key ) {
@ -303,13 +611,25 @@ class Robotstxt_Manager_Installer {
if ( 200 !== $code ) {
wp_delete_file( $tmp_file );
// Surface the store's own error message (e.g. the per-domain
// license "change the domain in your account" explanation).
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$detail = is_array( $body ) && isset( $body['message'] ) && is_string( $body['message'] ) ? $body['message'] : '';
return new WP_Error(
'robotstxt_manager_http',
sprintf(
/* translators: %d: HTTP status code. */
__( 'Download failed (HTTP %d).', 'robotstxt-manager' ),
$code
)
'' !== $detail
? sprintf(
/* translators: 1: HTTP status code, 2: store error message. */
__( 'Download failed (HTTP %1$d): %2$s', 'robotstxt-manager' ),
$code,
$detail
)
: sprintf(
/* translators: %d: HTTP status code. */
__( 'Download failed (HTTP %d).', 'robotstxt-manager' ),
$code
)
);
}
@ -408,19 +728,31 @@ class Robotstxt_Manager_Installer {
'robotstxt_manager_result' => $result,
'robotstxt_manager_message' => rawurlencode( $message ),
),
admin_url( 'admin.php' )
( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
)
);
exit;
}
/**
* Returns the normalised domain of the current site.
*
* @return string Domain (e.g. 'example.com').
*/
private function site_domain(): string {
$host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
// Strip the literal "www." prefix (ltrim would eat any leading w/).
return (string) preg_replace( '/^www\./', '', $host );
}
/**
* Validates a downloaded archive: must exist, be non-empty, and start
* with the ZIP magic bytes "PK".
*
* @param string $file Absolute path to the downloaded file.
*
* @return bool True when the file looks like a valid ZIP archive.
* @return bool True when the file looks like a ZIP archive.
*/
private function is_valid_zip( string $file ): bool {
if ( ! file_exists( $file ) ) {

View file

@ -41,8 +41,10 @@ class Robotstxt_Manager_Settings {
* @return void
*/
public function register( Robotstxt_Manager_Loader $loader ): void {
$loader->add_action( 'admin_menu', $this, 'add_settings_page' );
$menu_hook = is_multisite() ? 'network_admin_menu' : 'admin_menu';
$loader->add_action( $menu_hook, $this, 'add_settings_page' );
$loader->add_action( 'admin_init', $this, 'register_settings' );
$loader->add_action( 'admin_init', $this, 'handle_form_submission' );
$loader->add_action( 'admin_enqueue_scripts', $this, 'enqueue_scripts' );
$loader->add_action( 'wp_ajax_robotstxt_manager_test_connection', $this, 'handle_test_connection' );
$loader->add_action( 'wp_ajax_robotstxt_manager_delete_key', $this, 'handle_delete_key' );
@ -54,11 +56,12 @@ class Robotstxt_Manager_Settings {
* @return void
*/
public function add_settings_page(): void {
$cap = is_multisite() ? 'manage_network_options' : 'manage_options';
add_submenu_page(
Robotstxt_Manager_Admin::PAGE_SLUG,
esc_html__( 'Manager (by ROBOTSTXT) — Settings', 'robotstxt-manager' ),
esc_html__( 'Settings', 'robotstxt-manager' ),
'manage_options',
$cap,
self::PAGE_SLUG,
array( $this, 'render_page' )
);
@ -203,20 +206,83 @@ class Robotstxt_Manager_Settings {
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-manager' ) );
}
require_once ROBOTSTXT_MANAGER_DIR . 'admin/views/page-settings.php';
}
/**
* Handles manual form submission for network settings.
*
* The WordPress Settings API (options.php) does not handle network
* options, so the settings page must process its own form. Hooked to
* admin_init so the redirect runs before any output is sent.
*
* @return void
*/
public function handle_form_submission(): void {
if ( ! isset( $_POST['robotstxt_manager_settings_group_nonce'] ) ) {
return;
}
check_admin_referer( 'robotstxt_manager_settings_group', 'robotstxt_manager_settings_group_nonce' );
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to manage settings.', 'robotstxt-manager' ) );
}
// Store URL.
$store_url = '';
if ( isset( $_POST['robotstxt_manager_store_url'] ) ) {
$raw = wp_unslash( $_POST['robotstxt_manager_store_url'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below.
$store_url = is_string( $raw ) ? esc_url_raw( $raw ) : '';
}
update_site_option( 'robotstxt_manager_store_url', $store_url );
// API key.
$api_key = $this->sanitize_api_key( '' );
if ( isset( $_POST['robotstxt_manager_api_key'] ) ) {
$raw = wp_unslash( $_POST['robotstxt_manager_api_key'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by sanitize_api_key().
if ( is_string( $raw ) ) {
$api_key = $this->sanitize_api_key( $raw );
}
}
update_site_option( 'robotstxt_manager_api_key', $api_key );
// Cache TTL.
$cache_ttl = 60;
if ( isset( $_POST['robotstxt_manager_cache_ttl_minutes'] ) ) {
$raw = wp_unslash( $_POST['robotstxt_manager_cache_ttl_minutes'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by sanitize_cache_ttl().
$cache_ttl = $this->sanitize_cache_ttl( $raw );
}
update_site_option( 'robotstxt_manager_cache_ttl_minutes', $cache_ttl );
// Delete on uninstall.
$delete_on_uninstall = isset( $_POST['robotstxt_manager_delete_data_on_uninstall'] ) ? true : false;
update_site_option( 'robotstxt_manager_delete_data_on_uninstall', $delete_on_uninstall );
// Redirect with success flag.
$goback = add_query_arg(
array(
'page' => self::PAGE_SLUG,
'settings-updated' => 'true',
),
( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
);
wp_safe_redirect( $goback );
exit;
}
/**
* Renders the Store URL field.
*
* @return void
*/
public function render_field_store_url(): void {
$raw = get_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
$raw = get_site_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
$value = is_string( $raw ) ? $raw : 'https://www.robotstxt.software';
printf(
@ -234,7 +300,7 @@ class Robotstxt_Manager_Settings {
* @return void
*/
public function render_field_api_key(): void {
$stored = get_option( 'robotstxt_manager_api_key', '' );
$stored = get_site_option( 'robotstxt_manager_api_key', '' );
$has_key = is_string( $stored ) && '' !== $stored;
$last4 = '';
@ -263,7 +329,16 @@ class Robotstxt_Manager_Settings {
}
echo '</p>';
} else {
echo '<p class="description">' . esc_html__( 'Account-level API key issued by the ROBOTSTXT store. Required to authenticate catalog and subscription requests. The value is encrypted before storage.', 'robotstxt-manager' ) . '</p>';
printf(
'<p class="description">%s</p>',
wp_kses_post(
sprintf(
/* translators: %s: Registration URL. */
__( 'Account-level API key from the ROBOTSTXT store (<a href="%s" target="_blank" rel="noopener">create your free account there to get one</a>). Optional — the free catalog works without it — but required to link your subscriptions, install premium plugins, and receive their updates. Encrypted before storage.', 'robotstxt-manager' ),
esc_url( 'https://www.robotstxt.software/wp-login.php?action=register' )
)
)
);
}
// Action buttons.
@ -288,7 +363,7 @@ class Robotstxt_Manager_Settings {
* @return void
*/
public function render_field_cache_ttl(): void {
$raw = get_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
$raw = get_site_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
$value = is_numeric( $raw ) ? (int) $raw : 60;
printf(
@ -304,7 +379,7 @@ class Robotstxt_Manager_Settings {
* @return void
*/
public function render_field_delete_on_uninstall(): void {
$value = (bool) get_option( 'robotstxt_manager_delete_data_on_uninstall', false );
$value = (bool) get_site_option( 'robotstxt_manager_delete_data_on_uninstall', false );
echo '<label>';
printf(
'<input type="checkbox" id="robotstxt_manager_delete_data_on_uninstall" name="robotstxt_manager_delete_data_on_uninstall" value="1"%s />',
@ -329,10 +404,18 @@ class Robotstxt_Manager_Settings {
$plain = sanitize_text_field( is_string( $value ) ? $value : '' );
if ( '' === $plain ) {
$raw = get_option( 'robotstxt_manager_api_key', '' );
$raw = get_site_option( 'robotstxt_manager_api_key', '' );
return is_string( $raw ) ? $raw : '';
}
// If the input is already encrypted (v2: prefix), it means the browser
// auto-filled the password field with the stored encrypted value.
// Return it as-is (already encrypted) rather than re-encrypting or
// trying to read the option (which may not be saved yet in the WP flow).
if ( str_starts_with( $plain, 'v2:' ) ) {
return $plain;
}
// Account keys are UUIDs issued by the store; reject anything that
// cannot be one rather than storing a mangled key that only fails
// later at connection time.
@ -343,11 +426,12 @@ class Robotstxt_Manager_Settings {
esc_html__( 'The API key format is invalid. Copy the full key from your ROBOTSTXT account page.', 'robotstxt-manager' )
);
$raw = get_option( 'robotstxt_manager_api_key', '' );
$raw = get_site_option( 'robotstxt_manager_api_key', '' );
return is_string( $raw ) ? $raw : '';
}
delete_transient( 'robotstxt_manager_catalog' );
delete_site_transient( 'robotstxt_manager_catalog' );
delete_site_transient( 'robotstxt_manager_subscriptions' );
return Robotstxt_Manager_Encryption::encrypt( $plain );
}
@ -374,11 +458,29 @@ class Robotstxt_Manager_Settings {
public function handle_test_connection(): void {
check_ajax_referer( 'robotstxt_manager_test_connection', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-manager' ) ) );
}
$client = Robotstxt_Manager_Core_Client::from_options();
// Allow testing a key from the form field (not yet saved) by passing it in the request.
$input_key = '';
if ( isset( $_POST['robotstxt_manager_api_key'] ) ) {
$unslashed = wp_unslash( $_POST['robotstxt_manager_api_key'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below.
if ( is_string( $unslashed ) ) {
$input_key = sanitize_text_field( $unslashed );
}
}
$store_url = get_site_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
$store_url = is_string( $store_url ) ? $store_url : 'https://www.robotstxt.software';
// Use input key if provided, otherwise fall back to saved (decrypted) key.
if ( '' !== $input_key ) {
$client = new Robotstxt_Manager_Core_Client( $store_url, $input_key );
} else {
$client = Robotstxt_Manager_Core_Client::from_options();
}
$result = $client->test_connection();
if ( $result['ok'] ) {
@ -400,12 +502,13 @@ class Robotstxt_Manager_Settings {
public function handle_delete_key(): void {
check_ajax_referer( 'robotstxt_manager_delete_key', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-manager' ) ) );
}
delete_option( 'robotstxt_manager_api_key' );
delete_transient( 'robotstxt_manager_catalog' );
delete_site_option( 'robotstxt_manager_api_key' );
delete_site_transient( 'robotstxt_manager_catalog' );
delete_site_transient( 'robotstxt_manager_subscriptions' );
wp_send_json_success(
array(

View file

@ -20,11 +20,14 @@
$btn.prop( 'disabled', true ).text( RobotstxtManagerSettings.i18n.testing );
$result.text( '' ).css( 'color', '' );
var apiKey = $( '#robotstxt_manager_api_key' ).val();
$.post(
RobotstxtManagerSettings.ajaxUrl,
{
action: 'robotstxt_manager_test_connection',
nonce: RobotstxtManagerSettings.nonce,
robotstxt_manager_api_key: apiKey,
},
function ( response ) {
if ( response.success ) {

View file

@ -2,10 +2,14 @@
/**
* Admin page view: ROBOTSTXT Plugins catalog.
*
* Available variables:
* $catalog list<array<string,mixed>> Catalog entries from Core.
* $local array<string, array{installed:bool, active:bool, version:string}>
* Local install state, keyed by slug.
* Catalog table (Plugin · Version · Requires WP · Requires PHP · Price ·
* Action · Status) with an always-open detail row under each plugin
* (website link + description), an intro paragraph, and Support /
* Payments sections below the table.
*
* Also available:
* $manager_subscriptions array<string, array{status:string, expires_at:string}>
* Account subscriptions keyed by slug (may be empty).
*
* @package Robotstxt_Manager
*/
@ -19,6 +23,11 @@ if ( ! defined( 'ABSPATH' ) ) {
*
* @var list<array<string,mixed>> $catalog
* @var array<string, array{installed:bool, active:bool, version:string}> $local
* @var array<string, array<string,mixed>> $manager_subscriptions
* @var list<array{type:string, message:string}> $manager_notices
* @var list<array{slug:string, name:string, installed:string, patch:string}> $manager_security_updates
* @var bool $manager_has_api_key
* @var string $manager_store_url
*/
$client_configured = Robotstxt_Manager_Core_Client::from_options()->is_configured();
@ -57,10 +66,55 @@ $compat_warnings = 0;
<div class="wrap">
<h1><?php esc_html_e( 'Manager (by ROBOTSTXT) — Plugins', 'robotstxt-manager' ); ?></h1>
<p class="description">
<?php esc_html_e( 'This is the ROBOTSTXT plugin store: the catalog of plugins published by ROBOTSTXT, installable and updatable straight from your own wp-admin. Free plugins install with one click; premium plugins require an annual subscription that you purchase on our website.', 'robotstxt-manager' ); ?>
</p>
<?php if ( $refreshed ) : ?>
<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Catalog refreshed.', 'robotstxt-manager' ); ?></p></div>
<?php endif; ?>
<?php foreach ( ( $manager_notices ?? array() ) as $manager_notice ) : ?>
<div class="notice notice-<?php echo esc_attr( is_string( $manager_notice['type'] ?? '' ) ? $manager_notice['type'] : 'warning' ); ?>">
<p><?php echo esc_html( is_string( $manager_notice['message'] ?? '' ) ? $manager_notice['message'] : '' ); ?></p>
</div>
<?php endforeach; ?>
<?php foreach ( ( $manager_security_updates ?? array() ) as $manager_security ) : ?>
<div class="notice notice-error">
<p>
<?php
echo wp_kses_post(
sprintf(
/* translators: 1: plugin name, 2: installed version, 3: patch version, 4: update URL. */
__( '<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href="%4$s">Update now</a> — this is a security patch for the version this site runs, not a feature update.', 'robotstxt-manager' ),
esc_html( $manager_security['name'] ),
esc_html( $manager_security['installed'] ),
esc_html( $manager_security['patch'] ),
esc_url( Robotstxt_Manager_Admin::action_url( 'update', $manager_security['slug'] ) )
)
);
?>
</p>
</div>
<?php endforeach; ?>
<?php if ( empty( $manager_has_api_key ) && '' !== ( $manager_store_url ?? '' ) ) : ?>
<div class="notice notice-info">
<p>
<?php
echo wp_kses_post(
sprintf(
/* translators: %s: store registration URL. */
__( 'Create your free account at the <a href="%s" target="_blank" rel="noopener noreferrer">ROBOTSTXT store</a> to get your personal API key. The key links your subscriptions to this site and unlocks premium plugins and updates.', 'robotstxt-manager' ),
esc_url( $manager_store_url . '/wp-login.php?action=register' )
)
);
?>
</p>
</div>
<?php endif; ?>
<?php if ( '' !== $notice_message && in_array( $notice_result, array( 'success', 'error' ), true ) ) : ?>
<div class="notice notice-<?php echo esc_attr( $notice_result ); ?> is-dismissible"><p><?php echo esc_html( $notice_message ); ?></p></div>
<?php endif; ?>
@ -73,7 +127,7 @@ $compat_warnings = 0;
sprintf(
/* translators: %s: settings URL. */
__( 'ROBOTSTXT Manager is not configured yet. <a href="%s">Set the Store URL and API key</a> to see your plugin catalog.', 'robotstxt-manager' ),
esc_url( admin_url( 'admin.php?page=' . Robotstxt_Manager_Settings::PAGE_SLUG ) )
esc_url( ( is_multisite() ? network_admin_url( 'admin.php?page=' . Robotstxt_Manager_Settings::PAGE_SLUG ) : admin_url( 'admin.php?page=' . Robotstxt_Manager_Settings::PAGE_SLUG ) ) )
)
);
?>
@ -95,12 +149,12 @@ $compat_warnings = 0;
<thead>
<tr>
<th scope="col"><?php esc_html_e( 'Plugin', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Type', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Price', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Version', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Requires WP', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Requires PHP', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Local state', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Price', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Action', 'robotstxt-manager' ); ?></th>
<th scope="col"><?php esc_html_e( 'Status', 'robotstxt-manager' ); ?></th>
</tr>
</thead>
<tbody>
@ -116,6 +170,7 @@ $compat_warnings = 0;
$raw_desc = $entry['description'] ?? '';
$raw_rwp = $entry['requires_wp'] ?? '';
$raw_rphp = $entry['requires_php'] ?? '';
$raw_icon = $entry['icon_url'] ?? '';
$slug = is_string( $raw_slug ) ? $raw_slug : '';
$name = is_string( $raw_name ) ? $raw_name : $slug;
@ -127,10 +182,41 @@ $compat_warnings = 0;
$desc = is_string( $raw_desc ) ? trim( $raw_desc ) : '';
$req_wp = is_string( $raw_rwp ) ? $raw_rwp : '';
$req_php = is_string( $raw_rphp ) ? $raw_rphp : '';
$icon_url = is_string( $raw_icon ) ? esc_url_raw( $raw_icon ) : '';
$website_url = '' !== $page_url ? $page_url : $homepage;
$expandable = ( '' !== $desc || '' !== $website_url );
$toggle_id = 'robotstxt-manager-toggle-' . esc_attr( $slug );
$has_detail = ( '' !== $website_url || '' !== $desc );
// Localised description: the admin user's locale first,
// falling back to the (English) default.
$raw_translations = $entry['description_translations'] ?? '';
$translations = is_array( $raw_translations ) ? $raw_translations : array();
$user_locale = function_exists( 'get_user_locale' ) ? get_user_locale() : get_locale();
$locale_text = $translations[ $user_locale ] ?? '';
$display_desc = is_string( $locale_text ) && '' !== trim( $locale_text )
? trim( $locale_text )
: $desc;
$has_detail = ( '' !== $website_url || '' !== $desc || '' !== $display_desc );
// Subscription state: premium plugins are only usable
// (install/update) with an active or in-grace subscription.
$is_premium = ( $cost > 0.0 || 'premium' === $kind );
$sub_row = $manager_subscriptions[ $slug ] ?? null;
$raw_status = is_array( $sub_row ) ? ( $sub_row['status'] ?? '' ) : '';
$sub_status = is_string( $raw_status ) ? $raw_status : '';
$subscribed = in_array( $sub_status, array( 'active', 'payment_failed' ), true );
$raw_reqs = $entry['requires_plugins'] ?? '';
$req_slugs = array();
if ( is_string( $raw_reqs ) && '' !== trim( $raw_reqs ) ) {
foreach ( explode( ',', $raw_reqs ) as $part ) {
$dep = sanitize_key( trim( $part ) );
if ( '' !== $dep ) {
$req_slugs[] = $dep;
}
}
}
$has_detail = $has_detail || array() !== $req_slugs;
// Compatibility checks.
$wp_ok = '' === $req_wp || version_compare( $local_wp_version, $req_wp, '>=' );
@ -151,31 +237,28 @@ $compat_warnings = 0;
$l_version = is_string( $raw_lv ) ? $raw_lv : '';
$update_available = $l_installed && '' !== $remote_v && '' !== $l_version
&& version_compare( $l_version, $remote_v, '<' );
&& version_compare( $l_version, $remote_v, '<' );
// Security patch declared for exactly the installed version:
// the Update action installs the patch, not the mainline.
$security_patch = Robotstxt_Manager_Updater::security_patch_for( $entry, $l_version );
if ( '' !== $security_patch ) {
$update_available = true;
}
$row_class = ( ! $wp_ok || ! $php_ok ) ? ' robotstxt-manager-row--incompatible' : '';
?>
<tr class="<?php echo esc_attr( ltrim( $row_class ) ); ?>">
<td class="robotstxt-manager-name-cell">
<?php if ( $expandable ) : ?>
<input type="checkbox" class="robotstxt-manager-toggle" id="<?php echo esc_attr( $toggle_id ); ?>" aria-label="<?php esc_attr_e( 'Show more information', 'robotstxt-manager' ); ?>" />
<label class="robotstxt-manager-toggle-label" for="<?php echo esc_attr( $toggle_id ); ?>"><span class="robotstxt-manager-toggle-arrow"></span></label>
<?php if ( '' !== $icon_url ) : ?>
<img class="robotstxt-manager-plugin-icon" src="<?php echo esc_url( $icon_url ); ?>" alt="" width="64" height="64" loading="lazy" />
<?php else : ?>
<span class="robotstxt-manager-plugin-icon robotstxt-manager-plugin-icon--placeholder" aria-hidden="true"></span>
<?php endif; ?>
<strong><?php echo esc_html( $name ); ?></strong>
<?php if ( '' !== $remote_v ) : ?>
<br /><span class="description"><?php echo esc_html( sprintf( 'v%s', $remote_v ) ); ?></span>
<?php endif; ?>
</td>
<td><?php echo esc_html( 'premium' === $kind ? __( 'Premium', 'robotstxt-manager' ) : __( 'Free', 'robotstxt-manager' ) ); ?></td>
<td>
<?php
if ( $cost > 0.0 ) {
echo esc_html( sprintf( '€%s / year', number_format_i18n( $cost, 0 ) ) );
} else {
echo '&mdash;';
}
?>
</td>
<td><?php echo '' !== $remote_v ? esc_html( $remote_v ) : '&mdash;'; ?></td>
<td>
<?php if ( '' !== $req_wp ) : ?>
<span class="<?php echo $wp_ok ? 'robotstxt-manager-compat--ok' : 'robotstxt-manager-compat--fail'; ?>">
@ -200,12 +283,106 @@ $compat_warnings = 0;
&mdash;
<?php endif; ?>
</td>
<td>
<?php
if ( $is_premium ) {
echo esc_html(
sprintf(
/* translators: %s: formatted price number. */
__( '€%s / year', 'robotstxt-manager' ),
number_format_i18n( $cost, 0 )
)
);
// Subscription pill for premium plugins.
$sub_text = '';
if ( is_array( $sub_row ) ) {
$raw_sub_expiry = $sub_row['expires_at'] ?? '';
$sub_expiry = is_string( $raw_sub_expiry ) ? $raw_sub_expiry : '';
if ( 'active' === $sub_status && '' !== $sub_expiry ) {
$days_left = (int) floor( ( (int) strtotime( $sub_expiry ) - time() ) / DAY_IN_SECONDS );
if ( $days_left >= 0 && $days_left <= 14 ) {
$sub_text = sprintf(
/* translators: %d: days remaining. */
__( 'Subscribed — %d days left', 'robotstxt-manager' ),
$days_left
);
} else {
$sub_text = __( 'Subscribed', 'robotstxt-manager' );
}
} elseif ( 'payment_failed' === $sub_status ) {
$sub_text = __( 'Payment failed', 'robotstxt-manager' );
} elseif ( 'cancelled' === $sub_status ) {
$sub_text = __( 'Cancelled', 'robotstxt-manager' );
} elseif ( 'expired' === $sub_status ) {
$sub_text = __( 'Expired', 'robotstxt-manager' );
}
// Bound domain (per-domain licenses, Core 1.11.0+):
// shown once the license is tied to a site.
$raw_bound = $sub_row['bound_domain'] ?? '';
$bound_text = is_string( $raw_bound ) ? trim( $raw_bound ) : '';
// Multi-license: how many licenses the account
// holds for this plugin (Core 1.14.0+).
$raw_count = $sub_row['license_count'] ?? 1;
$count_int = is_numeric( $raw_count ) ? (int) ( $raw_count + 0 ) : 1;
$count_txt = $count_int > 1 ? ' ×' . $count_int : '';
if ( '' !== $bound_text && in_array( $sub_status, array( 'active', 'payment_failed' ), true ) ) {
$sub_text = '' !== $sub_text
? $sub_text . ' @ ' . $bound_text . $count_txt
: $bound_text . $count_txt;
} elseif ( '' !== $count_txt ) {
$sub_text = '' !== $sub_text ? $sub_text . $count_txt : __( 'Subscribed', 'robotstxt-manager' ) . $count_txt;
}
}
if ( '' !== $sub_text ) {
echo '<br /><span class="robotstxt-manager-subscription robotstxt-manager-subscription--'
. esc_attr( $sub_status )
. '">' . esc_html( $sub_text ) . '</span>';
}
} else {
echo esc_html__( 'Free', 'robotstxt-manager' );
}
?>
</td>
<td>
<?php if ( ! $wp_ok || ! $php_ok ) : ?>
<span class="description"><?php esc_html_e( 'Incompatible', 'robotstxt-manager' ); ?></span>
<?php elseif ( $is_premium && ! $subscribed ) : ?>
<?php if ( '' !== $page_url ) : ?>
<a class="button button-primary" href="<?php echo esc_url( $page_url ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Buy', 'robotstxt-manager' ); ?></a>
<?php else : ?>
&mdash;
<?php endif; ?>
<?php elseif ( ! $l_installed ) : ?>
<a class="button button-primary" href="<?php echo esc_url( Robotstxt_Manager_Admin::action_url( 'install', $slug ) ); ?>"><?php esc_html_e( 'Install', 'robotstxt-manager' ); ?></a>
<?php elseif ( ! $l_active ) : ?>
<a class="button button-primary" href="<?php echo esc_url( Robotstxt_Manager_Admin::action_url( 'activate', $slug ) ); ?>"><?php esc_html_e( 'Activate', 'robotstxt-manager' ); ?></a>
<?php elseif ( $update_available ) : ?>
<a class="button button-secondary" href="<?php echo esc_url( Robotstxt_Manager_Admin::action_url( 'update', $slug ) ); ?>"><?php esc_html_e( 'Update', 'robotstxt-manager' ); ?></a>
<?php endif; ?>
</td>
<td>
<?php
if ( ! $l_installed ) {
echo '<span class="robotstxt-manager-state robotstxt-manager-state--not-installed">' . esc_html__( 'Not installed', 'robotstxt-manager' ) . '</span>';
} elseif ( ! $l_active ) {
echo '<span class="robotstxt-manager-state robotstxt-manager-state--inactive">' . esc_html__( 'Installed (inactive)', 'robotstxt-manager' ) . '</span>';
} elseif ( '' !== $security_patch ) {
echo '<span class="robotstxt-manager-state robotstxt-manager-state--security">' . esc_html(
sprintf(
/* translators: 1: installed version, 2: security patch version. */
__( 'Security update available (v%1$s → v%2$s)', 'robotstxt-manager' ),
$l_version,
$security_patch
)
) . '</span>';
} elseif ( $update_available ) {
echo '<span class="robotstxt-manager-state robotstxt-manager-state--update">' . esc_html(
sprintf(
@ -220,35 +397,29 @@ $compat_warnings = 0;
}
?>
</td>
<td>
<?php if ( ! $l_installed ) : ?>
<?php if ( ! $wp_ok || ! $php_ok ) : ?>
<span class="description"><?php esc_html_e( 'Incompatible', 'robotstxt-manager' ); ?></span>
<?php else : ?>
<?php if ( $cost > 0.0 && '' !== $page_url ) : ?>
<a class="button button-secondary" href="<?php echo esc_url( $page_url ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Buy', 'robotstxt-manager' ); ?></a>
<?php endif; ?>
<?php
$install_class = ( $cost > 0.0 && '' !== $page_url ) ? 'button-secondary' : 'button-primary';
?>
<a class="button <?php echo esc_attr( $install_class ); ?>" href="<?php echo esc_url( Robotstxt_Manager_Admin::action_url( 'install', $slug ) ); ?>"><?php esc_html_e( 'Install', 'robotstxt-manager' ); ?></a>
<?php endif; ?>
<?php elseif ( ! $l_active ) : ?>
<a class="button button-primary" href="<?php echo esc_url( Robotstxt_Manager_Admin::action_url( 'activate', $slug ) ); ?>"><?php esc_html_e( 'Activate', 'robotstxt-manager' ); ?></a>
<?php elseif ( $update_available ) : ?>
<a class="button button-secondary" href="<?php echo esc_url( Robotstxt_Manager_Admin::action_url( 'update', $slug ) ); ?>"><?php esc_html_e( 'Update', 'robotstxt-manager' ); ?></a>
<?php endif; ?>
</td>
</tr>
<?php if ( $expandable ) : ?>
<?php if ( $has_detail ) : ?>
<tr class="robotstxt-manager-detail-row">
<td colspan="7">
<?php if ( '' !== $desc ) : ?>
<p class="robotstxt-manager-detail-description"><?php echo esc_html( $desc ); ?></p>
<?php endif; ?>
<?php if ( '' !== $website_url ) : ?>
<a href="<?php echo esc_url( $website_url ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Visit website', 'robotstxt-manager' ); ?> <span class="screen-reader-text"><?php echo esc_html( sprintf( /* translators: %s: plugin name. */ __( '(about %s)', 'robotstxt-manager' ), $name ) ); ?></span></a>
<?php endif; ?>
<?php if ( '' !== $display_desc ) : ?>
<span class="robotstxt-manager-detail-description"><?php echo esc_html( $display_desc ); ?></span>
<?php endif; ?>
<?php if ( array() !== $req_slugs ) : ?>
<span class="robotstxt-manager-detail-requires">
<?php
echo esc_html(
sprintf(
/* translators: %s: list of plugin slugs. */
__( 'Requires: %s', 'robotstxt-manager' ),
implode( ', ', $req_slugs )
)
);
?>
</span>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
@ -256,6 +427,16 @@ $compat_warnings = 0;
</tbody>
</table>
<h2><?php esc_html_e( 'Support', 'robotstxt-manager' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Need help with a ROBOTSTXT plugin? Visit the plugin\'s website (the link in its row above) for documentation and guides, or contact us through our website — we are happy to help.', 'robotstxt-manager' ); ?>
</p>
<h2><?php esc_html_e( 'Payments, and how it works', 'robotstxt-manager' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Free plugins install instantly at no cost. Premium plugins are annual subscriptions: you pay once on our website and the subscription renews automatically every year until you cancel. You can cancel at any time from your ROBOTSTXT account page — access keeps working until the end of the paid period. All payments are processed securely by Mollie; we never see or store your card details.', 'robotstxt-manager' ); ?>
</p>
<?php if ( $compat_warnings > 0 ) : ?>
<div class="notice notice-warning inline" style="margin-top:1em">
<p>
@ -289,13 +470,25 @@ $compat_warnings = 0;
.robotstxt-manager-compat--fail { color: #d63638; font-weight: 600; }
.robotstxt-manager-compat-warning { cursor: help; }
.robotstxt-manager-row--incompatible { background-color: #fef7f0 !important; }
.robotstxt-manager-state--security { color: #d63638; font-weight: 600; }
/* Expandable detail rows (CSS-only toggle). */
.robotstxt-manager-toggle { display: none; }
.robotstxt-manager-toggle-label { cursor: pointer; display: inline-block; width: 1.4em; }
.robotstxt-manager-toggle-arrow::before { content: "\25B8"; /* ▸ */ }
.robotstxt-manager-detail-row { display: none; }
.robotstxt-manager-detail-row td { border-top: none !important; }
tr:has(.robotstxt-manager-toggle:checked) + tr.robotstxt-manager-detail-row { display: table-row; }
tr:has(.robotstxt-manager-toggle:checked) .robotstxt-manager-toggle-arrow::before { content: "\25BE"; /* ▾ */ }
/* Detail rows (always open): muted, attached to the row above. */
.robotstxt-manager-detail-row td { border-top: none !important; padding-top: 0; }
.robotstxt-manager-detail-description { color: #50575e; }
/* Plugin icons: 64x64, 1:1, beside the name. */
.robotstxt-manager-plugin-icon {
display: inline-block;
vertical-align: middle;
width: 64px;
height: 64px;
margin-right: 10px;
border-radius: 4px;
object-fit: contain;
background: #fff;
}
.robotstxt-manager-plugin-icon--placeholder {
border: 1px solid #dcdcde;
box-sizing: border-box;
}
</style>

View file

@ -11,9 +11,10 @@ if ( ! defined( 'ABSPATH' ) ) {
?>
<div class="wrap">
<h1><?php esc_html_e( 'Manager (by ROBOTSTXT) — Settings', 'robotstxt-manager' ); ?></h1>
<form method="post" action="<?php echo esc_url( admin_url( 'options.php' ) ); ?>">
<?php settings_errors(); ?>
<form method="post" action="">
<?php
settings_fields( Robotstxt_Manager_Settings::OPTION_GROUP );
wp_nonce_field( 'robotstxt_manager_settings_group', 'robotstxt_manager_settings_group_nonce' );
do_settings_sections( Robotstxt_Manager_Settings::PAGE_SLUG );
submit_button();
?>

View file

@ -1,5 +1,315 @@
== Changelog ==
= 1.9.1 =
_Release date: 2026-09-23_
**Changed**
* Maintenance pass over the 1.9.0 security-patch feature: `composer update` (no changes — dependencies already current, no known CVEs), full code + security review of the 1.9.0 diff (clean-context pre-deploy audit: escaping, capability gating, CSRF, and the 1.8.1 updater invariants all verified correct; its two suggestions were applied — see below), and floors re-verified.
* Pre-deploy audit hardening: `security_patch_for()` only accepts declarations that strictly increase the version — a store typo (self-mapping or downgrade) can no longer produce a downgrade/re-install "update" notice; the `version` argument in premium/free package URLs is now `rawurlencode`d, matching the installer. POT regenerated at 1.9.1.
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified 2026-09-23: wp-compat ladder clean at 4.4 through 4.7; no WordPress API newer than 4.4 in use; the dev environment here runs WordPress 7.2-alpha trunk with the suite green, but 7.2 is not GA and `Tested up to` stays at the AGENTS window top)
* PHP: 8.0 - 8.5 (scan-verified 2026-09-23: PHPCompatibility ladder 5.6-8.5 — scan-clean from 7.4; manual audit keeps the real floor at 8.0: `mixed` hints, `str_contains()`, `str_starts_with()`)
**Tests**
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
* PHPStan max (level 10) + wp-compat: pass
* PHPUnit: 158 tests, 423 assertions (also green against WordPress 7.2-alpha trunk / test library rebuilt from wordpress-develop master)
= 1.9.0 =
_Release date: 2026-09-22_
**Added**
* Security-update notices (with Core 1.16.0+): when the store declares a security patch for the exact version this site runs (e.g. 1.2.3.1 applies to 1.2.3), Manager shows a red "Security update available: %name% (vX → vY) — Update now" notice on its catalog page **and** on the Plugins screen, and the catalog row status reads "Security update available (vX → vY)". The Update action installs the declared patch — never the feature mainline — so sites receive the security fix without being pushed across feature versions.
* The native update integration (update badge, `wp plugin update`, auto-updates) also targets the declared patch: the injected update entry carries the patch version and a versioned, authenticated package URL. Patches declared for other versions never apply (exact match on the installed version). Chained patches work (1.2.3.1 → 1.2.3.2 once declared).
* Spanish (es_ES) and Catalan (ca) translations for the new strings; POT regenerated.
**Requires Core 1.16.0+ on the store** for patch data and versioned downloads (older Core: the catalog simply carries no patches and Manager behaves as before).
**Tests**
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
* PHPStan max (level 10) + wp-compat: pass
* PHPUnit: 157 tests, 420 assertions
**Compatibility**
* WordPress: 4.4 - 7.1 (unchanged)
* PHP: 8.0 - 8.5 (unchanged)
= 1.8.1 =
_Release date: 2026-09-22_
**Fixed**
* Pending updates now appear on sites where the WordPress.org update check never completes (api.wordpress.org unreachable, blocked, or firewalled hosts — common on a lot of servers). `Robotstxt_Manager_Updater` moved from the write-side hook (`pre_set_site_transient_update_plugins`, which only fires when a full `wp_update_plugins()` cycle finishes) to the read-side filter (`site_transient_update_plugins`, which fires every time anything reads the update data: Plugins screen, Updates page, WP-CLI, auto-updates). Local versions are resolved from `get_plugins()` instead of the transient's `checked` list, which never exists in those environments. Diagnosed and verified end-to-end against the live store: update detection, listing (`wp plugin list`), dry-run, and the actual update run all work now even with api.wordpress.org blocked.
* Stale Manager-owned update entries no longer mask newer versions: WordPress persists the filtered read during a (failed) update check, so a previously injected entry can sit in the stored transient forever. The updater now recognizes its own entries (package URL host matches the store) and replaces or removes them with fresh catalog data — killing stale-version masking, phantom "update available" badges after updating, and expired download tokens in one guard. Entries from other update servers (bundled SDKs pointing elsewhere, WordPress.org) are never touched.
* Premium download-token exchanges are now cached (5-minute site transient, failures negatively cached for 1 minute): without the cache, every read of the update data while a premium update is pending triggered a blocking HTTP call to the store — several per admin page load.
* Opt-in data deletion on uninstall now also removes the cached subscriptions site transient (`robotstxt_manager_subscriptions`) — previously only the catalog transient was deleted, so subscription data could outlive the plugin when "Delete all plugin data" was enabled.
* A hardcoded "Subscribed" fallback label in the multi-license catalog pill (unknown subscription status with more than one license) is now translatable like every other pill label.
**Changed**
* PHPStan raised from level 9 to `max` (level 10); the two `mixed`-strictness findings it surfaced were fixed with real narrowing (`AUTH_KEY`/`AUTH_SALT` string checks in the encryption key derivation, the `plugins_api` slug check).
* `Robotstxt_Manager_Core_Client::get_subscriptions()` normalizes rows restored from the transient cache the same way `get_catalog()` does (string keys enforced) — resolves the single level-9 error surfaced by PHPStan 2.2.14; no behavior change.
* Development tooling updated via `composer update`: phpstan 2.2.8 → 2.2.14, phpstan-wordpress 2.0.3 → 2.0.4, wordpress-stubs 6.9.4 → 7.1.0, wp-hooks/wordpress-core 1.12.0 → 1.13.0, nikic/php-parser 5.8.0 → 5.9.0.
* Tests: dropped `ReflectionMethod::setAccessible()` calls (no-op since PHP 8.1, deprecated on PHP 8.5) so the suite runs notice-free on the maximum supported PHP version. Regression tests cover the "WordPress.org check never completed" transient shape, the `false` transient, stale own-entry replacement/removal, and download-token caching.
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified 2026-09-22: wp-compat ladder clean from 4.4; verified across 4.4-7.1 with WordPress stubs 7.1.0 — no API newer than 4.4 in use, so 7.1 GA remains covered; 7.2 is not GA)
* PHP: 8.0 - 8.5 (scan-verified 2026-09-22: PHPCompatibility ladder 5.6-8.5 + manual audit — `str_contains()`, `str_starts_with()`, and `mixed` type hints keep the real floor at 8.0)
**Tests**
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
* PHPStan max (level 10) + wp-compat: pass
* PHPUnit: 152 tests, 401 assertions
* composer audit: no known CVEs
= 1.8.0 =
_Release date: 2026-08-24_
**Added**
* Multi-domain license awareness (with Core 1.14.0): `Robotstxt_Manager_Core_Client::get_subscriptions()` groups the flat `/me/subscriptions` rows per plugin slug — any-active status wins, the first bound domain is shown, and a `license_count` is kept. Catalog subscription pills render the count when a plugin has more than one license (e.g. "Subscribed @ example.com ×2").
**Tests**
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
* PHPStan level 9 + wp-compat: pass
* PHPUnit: 149 tests, 391 assertions
= 1.7.0 =
_Release date: 2026-08-18_
**Added**
* Per-domain license support (Core 1.11.0+): install/update downloads and the download-token exchange now send this site's normalized domain, so premium licenses bind to this site and the store rejects requests from other domains. Catalog subscription pills show the bound domain when the store reports one (e.g. "Subscribed @ example.com").
**Changed**
* Download failures now surface the store's own error message from the JSON body (e.g. the domain-mismatch explanation) instead of only "Download failed (HTTP 403)." — falls back to the bare code when no message is present.
**Tests**
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
* PHPStan level 9 + wp-compat: pass
* PHPUnit: 149 tests, 391 assertions
= 1.6.2 =
_Release date: 2026-08-18_
**Added**
* `ROBOTSTXT_MANAGER_NOTICED` presence constant (guarded with `defined()`, value `true`), defined when Manager loads: ecosystem plugins (Core, Mollie, …) detect an active Manager via `defined( 'ROBOTSTXT_MANAGER_NOTICED' )` instead of scanning the plugin list. The guard keeps a double-load or a conflicting definition from fatalling.
**Tests**
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
* PHPStan level 9 + wp-compat: pass
* PHPUnit: 147 tests, 386 assertions
= 1.6.1 =
_Release date: 2026-08-18_
**Fixed**
* "Cannot modify header information — headers already sent" warning after saving the settings: the form handler ran inside the page renderer (after output started). It now runs on `admin_init`, before any output, so the redirect succeeds.
= 1.6.0 =
_Release date: 2026-08-18_
**Added**
* WordPress Multisite compatibility: `Network: true` header forces network-wide activation. Menus appear in the network admin on Multisite, in the regular admin on single-site. All options and transients use network-level storage (`get_site_option` / `get_site_transient`). Settings form handles submission manually (the Settings API `options.php` does not handle network options).
**Fixed**
* API key not saving when the browser auto-fills the password field with the stored encrypted value — the sanitizer now detects already-encrypted input (`v2:` prefix) and returns it as-is instead of failing UUID validation.
* "Test connection" validated against the public catalog endpoint (`/plugins`), so invalid API keys reported "Connected". Now uses the authenticated `/me/subscriptions` endpoint; a 401 response produces "Invalid API key".
* "Test connection" read the key from saved options only — entering a new key and testing before saving always tested the old (or empty) key. The AJAX handler now accepts the key from the form field.
**Added**
* `settings_errors()` call on the settings page so validation messages (e.g. "invalid API key format") are actually visible to the user.
* Registration link (`https://www.robotstxt.software/wp-login.php?action=register`) in the API key field description.
**Tests**
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
* PHPStan level 9 + wp-compat: pass
* PHPUnit: 146 tests, 385 assertions
= 1.5.0 =
_Release date: 2026-08-17_
**Changed**
* Plugin identity now points at the ROBOTSTXT software site: Plugin URI and the new Update URI header are `https://www.robotstxt.software/plugins/robotstxt-manager/`, Author URI is `https://www.robotstxt.software/`, and the readme.txt full-changelog link points to the same page.
* Contributors list reordered: robotstxt first, then javiercasares.
* readme.txt changelog section trimmed to the 3 latest versions per the documentation template.
* Tooling aligned with the declared real floors: PHPCS `testVersion` and the preflight scan range now cover PHP 8.0-8.5 (previously 7.4-8.5 / 8.4-8.5).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat ladder clean from 4.4; 10 errors at 4.0)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility ladder + manual audit — `mixed` hints and `str_contains()` are the 8.0 floor)
**Tests**
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
* PHPStan level 9 + wp-compat: pass
= 1.4.0 =
_Release date: 2026-08-17_
**Changed**
* Premium package URLs use short-lived download tokens (Core 1.9.0+ `POST /me/download-token`): the updater exchanges the account key each update cycle and embeds the 15-minute token instead of the API key, so the long-lived key no longer sits in the `update_plugins` transient or server access logs. Falls back to the API-key flow automatically on older Core. (Closes the 0.5.1 audit finding.)
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 1.3.1 =
_Release date: 2026-08-17_
**Added**
* Localized descriptions: catalog rows and the "View details" modal show the description in the logged-in admin's language (`get_user_locale()`, falling back to the site locale), using the new `description_translations` map from Core 1.8.0+; missing translations fall back to the English default.
**Changed**
* Plugin version 1.3.0 → 1.3.1. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 1.3.0 =
_Release date: 2026-08-17_
**Highlights**
* The catalog now works **without an API key**: only the Store URL is required. The free catalog, prices, and product pages are public on Core, so a fresh install shows the store immediately. An info notice invites visitors to create their free account at the store to get a personal API key (linking subscriptions and unlocking premium plugins and updates).
**Changed**
* Action buttons follow subscription state: premium plugins without an active subscription show **Buy** only (Install/Update hidden — the download would be rejected); with an active or in-grace subscription (and for free plugins) they show **Install / Activate / Update** as usual. Previously premium rows always offered Install alongside Buy.
* The API-key setting description clarifies the key is optional for browsing, required for subscriptions/premium.
* Plugin version 1.2.1 → 1.3.0. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 1.2.1 =
_Release date: 2026-08-17_
**Added**
* Plugin icons in the catalog: each row shows the plugin's `icon_url` from the Core API at 64×64 px (1:1, lazy-loaded) in the Plugin column; plugins without an icon get a plain white 64×64 placeholder.
**Changed**
* Plugin version 1.2.0 → 1.2.1. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 1.2.0 =
_Release date: 2026-08-15_
**Highlights**
* Subscription status, visible at a glance (Phase 4). The catalog now pulls the account's subscriptions from Core (`GET /me/subscriptions`, cached for one hour; refreshed on catalog refresh and key change) and shows a pill next to each premium plugin's price: Subscribed (with "N days left" when expiring within 14 days), Payment failed, Cancelled, or Expired. Admin notices at the top of the page warn about failed payments, subscriptions expiring within 14 days, and expired subscriptions whose plugin is still active on this site.
**Changed**
* Plugin version 1.1.0 → 1.2.0. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 1.1.0 =
_Release date: 2026-08-15_
**Added**
* Cascade dependency install: when installing a plugin whose catalog entry declares `requires_plugins` (Core 1.7.0+), Manager first installs every missing dependency — ecosystem plugins through the store flow, WordPress.org plugins (e.g. Action Scheduler) via `plugins_api` and their repository ZIPs — dependencies of dependencies first, cycle-safe. The success notice lists what was installed; failures name the dependency and abort before the main install. Catalog rows show a "Requires: …" hint in the detail line.
**Changed**
* Plugin version 1.0.0 → 1.1.0. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 1.0.0 =
_Release date: 2026-08-15_
**Highlights**
* Catalog page redesign. The table columns are now Plugin · Version · Requires WP · Requires PHP · Price · Action · Status: the Type column is merged into Price ("Free" for cost-0 plugins, the annual price for premium), the version moved into its own column, the action buttons sit before the status, and "Local state" is renamed "Status". Each plugin's detail row — website link first, then its description — is now **always open** (the click-to-expand toggle is gone). An intro paragraph after the page title explains the store, and Support + Payments sections below the table describe how to get help and how the annual-subscription model works (automatic renewal, cancellation from the account page, Mollie processing).
**Changed**
* Plugin version 0.6.0 → 1.0.0 — first stable release. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 0.6.0 =
_Release date: 2026-08-15_
**Added**
* Spanish (es_ES) and Catalan (ca) translations — all 73 admin strings, regenerated POT included.
* "Refresh catalog" is rate-limited to 6 refreshes per minute per user (transient bucket), redirecting back with an error notice when exceeded; the limit rejects before clearing any cache (Phase 6 hardening).
**Changed**
* Plugin version 0.5.3 → 0.6.0. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 0.5.3 =
_Release date: 2026-08-15_

View file

@ -34,7 +34,7 @@ class Robotstxt_Manager_Activator {
* @return void
*/
public static function deactivate(): void {
delete_transient( 'robotstxt_manager_catalog' );
delete_site_transient( 'robotstxt_manager_catalog' );
}
/**
@ -43,12 +43,12 @@ class Robotstxt_Manager_Activator {
* @return void
*/
private static function ensure_defaults(): void {
if ( '' === get_option( 'robotstxt_manager_store_url', '' ) ) {
update_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
if ( '' === get_site_option( 'robotstxt_manager_store_url', '' ) ) {
update_site_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
}
if ( '' === get_option( 'robotstxt_manager_cache_ttl_minutes', '' ) ) {
update_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
if ( '' === get_site_option( 'robotstxt_manager_cache_ttl_minutes', '' ) ) {
update_site_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
}
}
}

View file

@ -79,8 +79,8 @@ class Robotstxt_Manager_Core_Client {
* @return self
*/
public static function from_options(): self {
$store_url_raw = get_option( 'robotstxt_manager_store_url', '' );
$api_key_raw = get_option( 'robotstxt_manager_api_key', '' );
$store_url_raw = get_site_option( 'robotstxt_manager_store_url', '' );
$api_key_raw = get_site_option( 'robotstxt_manager_api_key', '' );
$store_url = is_string( $store_url_raw ) ? $store_url_raw : '';
$api_key = is_string( $api_key_raw )
@ -91,12 +91,25 @@ class Robotstxt_Manager_Core_Client {
}
/**
* Returns whether the client has both a URL and a key configured.
* Returns whether the client can talk to the store.
*
* Only the Store URL is required: the catalog (free plugins, prices,
* product pages) is public on Core. The API key is optional it is
* needed for subscription data and premium downloads, not for listing.
*
* @return bool
*/
public function is_configured(): bool {
return '' !== $this->store_url && '' !== $this->api_key;
return '' !== $this->store_url;
}
/**
* Returns whether an API key is configured.
*
* @return bool
*/
public function has_api_key(): bool {
return '' !== $this->api_key;
}
/**
@ -136,7 +149,7 @@ class Robotstxt_Manager_Core_Client {
);
}
$response = $this->get( '/plugins' );
$response = $this->get( '/me/subscriptions' );
if ( is_wp_error( $response ) ) {
return array(
@ -148,6 +161,13 @@ class Robotstxt_Manager_Core_Client {
$code = (int) wp_remote_retrieve_response_code( $response );
if ( 401 === $code ) {
return array(
'ok' => false,
'message' => __( 'Invalid API key. Please check your key and try again.', 'robotstxt-manager' ),
);
}
if ( 200 !== $code ) {
return array(
'ok' => false,
@ -162,7 +182,7 @@ class Robotstxt_Manager_Core_Client {
return array(
'ok' => true,
'message' => __( 'Connected.', 'robotstxt-manager' ),
'catalog_count' => $count,
'subscriptions' => $count,
);
}
@ -177,7 +197,7 @@ class Robotstxt_Manager_Core_Client {
}
$cache_key = 'robotstxt_manager_catalog';
$cached = get_transient( $cache_key );
$cached = get_site_transient( $cache_key );
if ( is_array( $cached ) ) {
$typed = array();
@ -228,7 +248,7 @@ class Robotstxt_Manager_Core_Client {
}
$ttl = $this->get_catalog_ttl();
set_transient( $cache_key, $catalog, $ttl );
set_site_transient( $cache_key, $catalog, $ttl );
return $catalog;
}
@ -240,7 +260,7 @@ class Robotstxt_Manager_Core_Client {
* @return void
*/
public function clear_catalog_cache(): void {
delete_transient( 'robotstxt_manager_catalog' );
delete_site_transient( 'robotstxt_manager_catalog' );
}
/**
@ -249,7 +269,7 @@ class Robotstxt_Manager_Core_Client {
* @return int
*/
private function get_catalog_ttl(): int {
$raw = get_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
$raw = get_site_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
$min = is_numeric( $raw ) ? (int) $raw : 60;
if ( $min < 1 ) {
@ -269,15 +289,197 @@ class Robotstxt_Manager_Core_Client {
private function get( string $endpoint ) {
$url = $this->store_url . '/wp-json/' . self::REST_NAMESPACE . $endpoint;
$headers = array( 'Accept' => 'application/json' );
if ( '' !== $this->api_key ) {
$headers['Authorization'] = 'Bearer ' . $this->api_key;
}
return wp_remote_get(
$url,
array(
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_key,
'Accept' => 'application/json',
),
'headers' => $headers,
'timeout' => self::TIMEOUT,
)
);
}
/**
* Returns the account's subscriptions (Core's /me/subscriptions),
* cached in a transient for one hour. Keys: plugin_slug => row.
*
* @return array<string, array<string, mixed>> Rows keyed by plugin slug.
*/
public function get_subscriptions(): array {
if ( ! $this->is_configured() ) {
return array();
}
$cache_key = 'robotstxt_manager_subscriptions';
$cached = get_site_transient( $cache_key );
if ( is_array( $cached ) ) {
$typed = array();
foreach ( $cached as $slug => $row ) {
if ( is_string( $slug ) && is_array( $row ) ) {
$typed_row = array();
foreach ( $row as $k => $v ) {
if ( is_string( $k ) ) {
$typed_row[ $k ] = $v;
}
}
$typed[ $slug ] = $typed_row;
}
}
return $typed;
}
$response = $this->get( '/me/subscriptions' );
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
return array(); // Not cached — retried on the next view.
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return array();
}
$rows = array();
foreach ( $data as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$slug = $row['plugin_slug'] ?? '';
$slug = is_string( $slug ) ? $slug : '';
if ( '' === $slug ) {
continue;
}
$raw_status = $row['status'] ?? '';
$raw_expires_at = $row['expires_at'] ?? '';
$raw_bound = $row['bound_domain'] ?? '';
$status = is_string( $raw_status ) ? $raw_status : '';
$expires_at = is_string( $raw_expires_at ) ? $raw_expires_at : '';
$bound = is_string( $raw_bound ) ? $raw_bound : '';
// Group multi-license rows (Core 1.14.0+: one row per domain)
// into one entry per slug: any-active wins, first bound domain
// shown, license count kept for the pill.
if ( isset( $rows[ $slug ] ) ) {
$existing = $rows[ $slug ];
if ( 'active' === $status && 'active' !== $existing['status'] ) {
$existing['status'] = 'active';
$existing['expires_at'] = $expires_at;
}
if ( '' === $existing['bound_domain'] && '' !== $bound ) {
$existing['bound_domain'] = $bound;
}
$existing['license_count'] = (int) $existing['license_count'] + 1;
$rows[ $slug ] = $existing;
continue;
}
$rows[ $slug ] = array(
'status' => $status,
'expires_at' => $expires_at,
'bound_domain' => $bound,
'license_count' => 1,
);
}
set_site_transient( $cache_key, $rows, HOUR_IN_SECONDS );
return $rows;
}
/**
* Exchanges the account API key for a short-lived download token
* (Core 1.9.0+ `POST /me/download-token`).
*
* Tokens are cached in a short-TTL site transient (5 minutes, a fraction
* of the 15-minute token lifetime) because the updater rebuilds package
* URLs on every read of the update_plugins transient. Failed exchanges
* are negatively cached for one minute so a slow or down store is not
* queried on every read either.
*
* @param string $slug Plugin slug the token may download.
*
* @return string Token string, or '' when unavailable (older Core, no
* key, inactive subscription, or transport error
* callers fall back to the API-key flow).
*/
public function exchange_download_token( string $slug ): string {
if ( ! $this->has_api_key() ) {
return '';
}
$cache_key = 'robotstxt_manager_dl_token_' . sanitize_key( $slug );
$cached = get_site_transient( $cache_key );
if ( is_string( $cached ) ) {
return $cached; // Token, or '' from a negatively cached failure.
}
// Send this site's domain so per-domain license binding is enforced
// at token issuance (Core 1.11.0+); older Core ignores the field.
$host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
$domain = (string) preg_replace( '/^www\./', '', $host );
$response = wp_remote_post(
$this->store_url . '/wp-json/' . self::REST_NAMESPACE . '/me/download-token',
array(
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_key,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
),
'body' => (string) wp_json_encode(
array(
'slug' => $slug,
'domain' => $domain,
)
),
'timeout' => self::TIMEOUT,
)
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
set_site_transient( $cache_key, '', MINUTE_IN_SECONDS );
return '';
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$token = is_array( $data ) ? ( $data['token'] ?? '' ) : '';
$token = is_string( $token ) ? $token : '';
if ( '' === $token ) {
set_site_transient( $cache_key, '', MINUTE_IN_SECONDS );
return '';
}
set_site_transient( $cache_key, $token, 5 * MINUTE_IN_SECONDS );
return $token;
}
/**
* Clears the cached subscriptions response (key change, manual refresh).
*
* @return void
*/
public function clear_subscriptions_cache(): void {
delete_site_transient( 'robotstxt_manager_subscriptions' );
}
}

View file

@ -195,8 +195,8 @@ class Robotstxt_Manager_Encryption {
* @return string 32-byte raw key.
*/
private static function derive_key(): string {
$auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'auth_key_not_defined';
$auth_salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'auth_salt_not_defined';
$auth_key = defined( 'AUTH_KEY' ) && is_string( AUTH_KEY ) ? AUTH_KEY : 'auth_key_not_defined';
$auth_salt = defined( 'AUTH_SALT' ) && is_string( AUTH_SALT ) ? AUTH_SALT : 'auth_salt_not_defined';
return substr(
hash_hmac( 'sha256', self::CONTEXT, $auth_key . $auth_salt, true ),
@ -214,8 +214,8 @@ class Robotstxt_Manager_Encryption {
* @return string 32-byte raw key.
*/
private static function derive_mac_key(): string {
$auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'auth_key_not_defined';
$auth_salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'auth_salt_not_defined';
$auth_key = defined( 'AUTH_KEY' ) && is_string( AUTH_KEY ) ? AUTH_KEY : 'auth_key_not_defined';
$auth_salt = defined( 'AUTH_SALT' ) && is_string( AUTH_SALT ) ? AUTH_SALT : 'auth_salt_not_defined';
return substr(
hash_hmac( 'sha256', self::MAC_CONTEXT, $auth_key . $auth_salt, true ),

View file

@ -16,11 +16,19 @@ if ( ! defined( 'ABSPATH' ) ) {
* show the standard "Update available" badge and update through the regular
* wp-admin flow, with the store's download proxy as the package source.
*
* - `pre_set_site_transient_update_plugins`: adds entries to ->response for
* installed catalog plugins with a newer remote version, and to ->no_update
* for up-to-date ones (prevents false WordPress.org matches).
* - `site_transient_update_plugins`: injects entries into ->response for
* installed catalog plugins with a newer remote version, and into ->no_update
* for up-to-date ones (prevents false WordPress.org matches). The injection
* runs on the READ side of the transient, so it works even when a full
* wp_update_plugins() cycle never completes for example on hosts where
* api.wordpress.org is unreachable, where WordPress bails before building
* the transient and write-side injection would never fire.
* - `plugins_api`: serves the "View details" modal from catalog data.
*
* Local install state is resolved directly from get_plugins() (object-cached
* per request) instead of the transient's ->checked list, which is only
* populated by a completed WordPress.org check.
*
* Plugins that bundle their own update SDK already inject their own entries;
* Manager never overwrites an existing response entry.
*/
@ -34,26 +42,23 @@ class Robotstxt_Manager_Updater {
* @return void
*/
public function register( Robotstxt_Manager_Loader $loader ): void {
$loader->add_filter( 'pre_set_site_transient_update_plugins', $this, 'inject_updates' );
$loader->add_filter( 'site_transient_update_plugins', $this, 'inject_updates' );
$loader->add_filter( 'plugins_api', $this, 'plugins_api_filter', 10, 3 );
}
/**
* Injects catalog update data into the WordPress update transient.
*
* @param mixed $transient The update_plugins transient object.
* Read-side filter for get_site_transient( 'update_plugins' ): every
* consumer (Plugins screen, Updates page, WP-CLI, auto-updates) passes
* through here, so catalog updates surface regardless of whether a full
* WordPress.org update-check cycle has completed.
*
* @return mixed Modified transient.
* @param mixed $transient The stored update_plugins transient (object or false).
*
* @return mixed Transient object with catalog entries injected.
*/
public function inject_updates( mixed $transient ): mixed {
if ( ! is_object( $transient )
|| ! property_exists( $transient, 'checked' )
|| ! is_array( $transient->checked )
|| empty( $transient->checked )
) {
return $transient;
}
$client = Robotstxt_Manager_Core_Client::from_options();
if ( ! $client->is_configured() ) {
@ -66,48 +71,177 @@ class Robotstxt_Manager_Updater {
return $transient;
}
$local = $this->local_plugin_versions();
if ( array() === $local ) {
return $transient;
}
$entries = $this->catalog_by_slug( $catalog );
foreach ( $transient->checked as $plugin_file => $raw_version ) {
$version = is_string( $raw_version ) ? $raw_version : '';
$slug = $this->slug_from_file( (string) $plugin_file );
$entry = $entries[ $slug ] ?? null;
$updates = ( $transient instanceof stdClass ) ? $transient : new stdClass();
if ( null === $entry || '' === $version ) {
if ( ! property_exists( $updates, 'response' ) || ! is_array( $updates->response ) ) {
$updates->response = array();
}
if ( ! property_exists( $updates, 'no_update' ) || ! is_array( $updates->no_update ) ) {
$updates->no_update = array();
}
foreach ( $local as $plugin_file => $version ) {
$slug = $this->slug_from_file( $plugin_file );
$entry = $entries[ $slug ] ?? null;
if ( null === $entry || '' === $version || '' === $entry['new_version'] ) {
continue;
}
$new_version = $entry['new_version'];
// Never overwrite an entry injected by the plugin's own SDK or
// by WordPress.org. Entries Manager itself produced earlier are
// the exception: WordPress persists the filtered read during
// wp_update_plugins(), so on hosts where api.wordpress.org is
// unreachable a stale Manager entry would otherwise occupy the
// slot forever and mask newer catalog versions.
$occupied = $updates->response[ $plugin_file ] ?? null;
if ( '' === $new_version ) {
if ( null !== $occupied && ! $this->is_own_entry( $occupied, $client ) ) {
continue;
}
// Never overwrite an entry injected by the plugin's own SDK.
if ( property_exists( $transient, 'response' )
&& is_array( $transient->response )
&& isset( $transient->response[ $plugin_file ] )
) {
continue;
}
// Security patch declared for exactly this installed version
// (Core 1.16.0+): offer the patch, never the feature mainline.
$patch = self::security_patch_for( $entry, $version );
if ( version_compare( $version, $new_version, '<' ) ) {
// Premium updates need the account key in the package URL;
// without a usable key the native updater would only hit a
// 403, so skip injecting the entry.
if ( '' !== $patch ) {
if ( 'premium' === $entry['type'] && ! $this->has_api_key() ) {
unset( $updates->response[ $plugin_file ] );
continue;
}
if ( property_exists( $transient, 'response' ) && is_array( $transient->response ) ) {
$transient->response[ $plugin_file ] = $this->build_update_object( $slug, (string) $plugin_file, $entry );
$updates->response[ $plugin_file ] = $this->build_update_object( $slug, $plugin_file, $entry, null, $patch );
unset( $updates->no_update[ $plugin_file ] );
continue;
}
if ( version_compare( $version, $entry['new_version'], '<' ) ) {
// Premium updates need the account key in the package URL;
// without a usable key the native updater would only hit a
// 403, so drop any stale own entry and skip.
if ( 'premium' === $entry['type'] && ! $this->has_api_key() ) {
unset( $updates->response[ $plugin_file ] );
continue;
}
$updates->response[ $plugin_file ] = $this->build_update_object( $slug, $plugin_file, $entry );
unset( $updates->no_update[ $plugin_file ] );
} else {
// Up to date: a stale own response entry must go, or the
// Plugins screen would keep offering a phantom update.
unset( $updates->response[ $plugin_file ] );
if ( ! isset( $updates->no_update[ $plugin_file ] ) ) {
$updates->no_update[ $plugin_file ] = $this->build_update_object( $slug, $plugin_file, $entry, $version );
}
} elseif ( property_exists( $transient, 'no_update' ) && is_array( $transient->no_update ) ) {
$transient->no_update[ $plugin_file ] = $this->build_update_object( $slug, (string) $plugin_file, $entry, $version );
}
}
return $transient;
return $updates;
}
/**
* Returns whether a response entry was produced by Manager itself (or by
* a bundled SDK pulling from the same store), by comparing the package
* URL host against the configured store host.
*
* @param mixed $entry Existing response entry.
* @param Robotstxt_Manager_Core_Client $client Configured core client.
*
* @return bool True when the entry's package comes from this store.
*/
private function is_own_entry( mixed $entry, Robotstxt_Manager_Core_Client $client ): bool {
if ( ! is_object( $entry ) || ! isset( $entry->package ) || ! is_string( $entry->package ) ) {
return false;
}
$store_host = strtolower( (string) wp_parse_url( $client->get_store_url(), PHP_URL_HOST ) );
$entry_host = strtolower( (string) wp_parse_url( $entry->package, PHP_URL_HOST ) );
return '' !== $store_host && $store_host === $entry_host;
}
/**
* Returns the security-patch version declared for exactly the given
* installed version, or '' when none applies.
*
* Exact-match by design: a patch declared for 1.2.3 applies only to
* sites running 1.2.3 other versions follow the normal mainline flow.
* A declaration that does not strictly increase the version (typo,
* downgrade, re-install loop) never counts as a patch.
*
* @param array<string, mixed> $entry Catalog row (raw or normalised).
* @param string $installed_version Version installed on this site.
*
* @return string Patch version, or ''.
*/
public static function security_patch_for( array $entry, string $installed_version ): string {
if ( '' === $installed_version ) {
return '';
}
$patches = self::normalize_patches( $entry );
$patch = $patches[ $installed_version ] ?? '';
if ( '' === $patch || ! version_compare( $installed_version, $patch, '<' ) ) {
return '';
}
return $patch;
}
/**
* Normalises a catalog row's security_patches field into a string map.
*
* @param array<string, mixed> $row Catalog row.
*
* @return array<string, string> Installed version => patch version.
*/
private static function normalize_patches( array $row ): array {
$raw = $row['security_patches'] ?? array();
$raw = is_array( $raw ) ? $raw : array();
$clean = array();
foreach ( $raw as $applies_to => $patch ) {
if ( is_string( $applies_to ) && is_string( $patch ) ) {
$clean[ $applies_to ] = $patch;
}
}
return $clean;
}
/**
* Returns the installed plugin versions, from the object-cached plugin list.
*
* @return array<string, string> Map of plugin file ("slug/file.php") to version.
*/
private function local_plugin_versions(): array {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$versions = array();
foreach ( get_plugins() as $file => $data ) {
if ( ! is_string( $file ) ) {
continue;
}
$version = isset( $data['Version'] ) && is_string( $data['Version'] ) ? $data['Version'] : '';
$versions[ $file ] = $version;
}
return $versions;
}
/**
@ -136,7 +270,7 @@ class Robotstxt_Manager_Updater {
return $result;
}
$slug = sanitize_key( (string) $args->slug );
$slug = is_string( $args->slug ) ? sanitize_key( $args->slug ) : '';
$entries = $this->catalog_by_slug( $catalog );
$entry = $entries[ $slug ] ?? null;
@ -189,7 +323,7 @@ class Robotstxt_Manager_Updater {
*
* @param list<array<string,mixed>> $catalog Catalog entries from Core.
*
* @return array<string, array<string,string>> Normalised entries keyed by slug.
* @return array<string, array{name:string, type:string, new_version:string, security_patches:array<string,string>, requires_wp:string, requires_php:string, tested_up_to:string, page_url:string, description:string, icon_url:string, banner_url:string}> Normalised entries keyed by slug.
*/
private function catalog_by_slug( array $catalog ): array {
$entries = array();
@ -203,16 +337,17 @@ class Robotstxt_Manager_Updater {
}
$entries[ $slug ] = array(
'name' => $this->str( $row, 'name', $slug ),
'type' => $this->str( $row, 'type', 'free' ),
'new_version' => $this->str( $row, 'current_version', '' ),
'requires_wp' => $this->str( $row, 'requires_wp', '' ),
'requires_php' => $this->str( $row, 'requires_php', '' ),
'tested_up_to' => $this->str( $row, 'tested_up_to', '' ),
'page_url' => $this->str( $row, 'page_url', '' ),
'description' => $this->str( $row, 'description', '' ),
'icon_url' => $this->str( $row, 'icon_url', '' ),
'banner_url' => $this->str( $row, 'banner_url', '' ),
'name' => $this->str( $row, 'name', $slug ),
'type' => $this->str( $row, 'type', 'free' ),
'new_version' => $this->str( $row, 'current_version', '' ),
'security_patches' => self::normalize_patches( $row ),
'requires_wp' => $this->str( $row, 'requires_wp', '' ),
'requires_php' => $this->str( $row, 'requires_php', '' ),
'tested_up_to' => $this->str( $row, 'tested_up_to', '' ),
'page_url' => $this->str( $row, 'page_url', '' ),
'description' => $this->localized_description( $row ),
'icon_url' => $this->str( $row, 'icon_url', '' ),
'banner_url' => $this->str( $row, 'banner_url', '' ),
);
}
@ -222,25 +357,29 @@ class Robotstxt_Manager_Updater {
/**
* Builds the update/no-update object for the WordPress transient.
*
* @param string $slug Plugin slug.
* @param string $plugin_file Plugin basename.
* @param array<string, string> $entry Normalised catalog entry.
* @param string|null $current_version Installed version; when null an
* update entry is built, otherwise a
* no-update entry pinned to this version.
* @param string $slug Plugin slug.
* @param string $plugin_file Plugin basename.
* @param array{name:string, type:string, new_version:string, security_patches:array<string,string>, requires_wp:string, requires_php:string, tested_up_to:string, page_url:string, description:string, icon_url:string, banner_url:string} $entry Normalised catalog entry.
* @param string|null $current_version Installed version; when null an
* update entry is built, otherwise a
* no-update entry pinned to this version.
* @param string $security_version Patch version when the update is
* a security patch ('' otherwise).
*
* @return object stdClass for the transient bucket.
*/
private function build_update_object( string $slug, string $plugin_file, array $entry, ?string $current_version = null ): object {
private function build_update_object( string $slug, string $plugin_file, array $entry, ?string $current_version = null, string $security_version = '' ): object {
$is_no_update = null !== $current_version;
$data = array(
'id' => $plugin_file,
'slug' => $slug,
'plugin' => $plugin_file,
'new_version' => $is_no_update ? $current_version : $entry['new_version'],
'new_version' => $is_no_update
? $current_version
: ( '' !== $security_version ? $security_version : $entry['new_version'] ),
'url' => $entry['page_url'],
'package' => $is_no_update ? '' : $this->package_url( $slug, $entry['type'] ),
'package' => $is_no_update ? '' : $this->package_url( $slug, $entry['type'], $security_version ),
'requires' => $entry['requires_wp'],
'requires_php' => $entry['requires_php'],
'tested' => $entry['tested_up_to'],
@ -273,7 +412,7 @@ class Robotstxt_Manager_Updater {
* @return bool True when a usable key exists.
*/
private function has_api_key(): bool {
$api_key_raw = get_option( 'robotstxt_manager_api_key', '' );
$api_key_raw = get_site_option( 'robotstxt_manager_api_key', '' );
$api_key = is_string( $api_key_raw ) ? Robotstxt_Manager_Encryption::decrypt( $api_key_raw ) : '';
return '' !== $api_key;
@ -285,13 +424,15 @@ class Robotstxt_Manager_Updater {
* Free plugins stream through the proxy without auth (Core 1.4.0+).
* Premium plugins append the account API key as an api_key query parameter
* (accepted by Core 1.5.0+) the native upgrader cannot send headers.
* Security patches add a validated `version` parameter (Core 1.16.0+).
*
* @param string $slug Plugin slug.
* @param string $type Plugin type ('free' or 'premium').
* @param string $security_version Patch version when serving a security patch.
*
* @return string Package URL.
*/
private function package_url( string $slug, string $type ): string {
private function package_url( string $slug, string $type, string $security_version = '' ): string {
$client = Robotstxt_Manager_Core_Client::from_options();
$url = $client->get_store_url() . '/wp-json/robotstxt-core/v1/plugins/' . rawurlencode( $slug ) . '/download';
@ -299,12 +440,25 @@ class Robotstxt_Manager_Updater {
'domain' => $this->site_domain(),
);
if ( 'premium' === $type ) {
$api_key_raw = get_option( 'robotstxt_manager_api_key', '' );
$api_key = is_string( $api_key_raw ) ? Robotstxt_Manager_Encryption::decrypt( $api_key_raw ) : '';
if ( '' !== $security_version ) {
$args['version'] = rawurlencode( $security_version );
}
if ( '' !== $api_key ) {
$args['api_key'] = $api_key;
if ( 'premium' === $type ) {
// Preferred: a short-lived download token (Core 1.9.0+) — keeps the
// long-lived API key out of the update transient and access logs.
$token = $client->exchange_download_token( $slug );
if ( '' !== $token ) {
$args['token'] = $token;
} else {
// Fallback (older Core): the API key itself.
$api_key_raw = get_site_option( 'robotstxt_manager_api_key', '' );
$api_key = is_string( $api_key_raw ) ? Robotstxt_Manager_Encryption::decrypt( $api_key_raw ) : '';
if ( '' !== $api_key ) {
$args['api_key'] = $api_key;
}
}
}
@ -340,6 +494,27 @@ class Robotstxt_Manager_Updater {
return $slug;
}
/**
* Picks the description for the current admin user's locale, falling
* back to the default (English) description.
*
* @param array<string, mixed> $row Catalog row.
*
* @return string The localized (or default) description.
*/
private function localized_description( array $row ): string {
$raw_translations = $row['description_translations'] ?? '';
$translations = is_array( $raw_translations ) ? $raw_translations : array();
$user_locale = function_exists( 'get_user_locale' ) ? get_user_locale() : get_locale();
$locale_text = $translations[ $user_locale ] ?? '';
if ( is_string( $locale_text ) && '' !== trim( $locale_text ) ) {
return trim( $locale_text );
}
return $this->str( $row, 'description', '' );
}
/**
* Extracts a string value from a mixed-value row.
*

Binary file not shown.

View file

@ -1,184 +1,592 @@
# Copyright (C) 2026 ROBOTSTXT
# This file is distributed under the GPL-3.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Manager (by ROBOTSTXT) 0.1.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-manager\n"
"POT-Creation-Date: 2026-08-12T13:09:24+00:00\n"
"PO-Revision-Date: 2026-08-12 13:15+0000\n"
"Last-Translator: ROBOTSTXT <info@robotstxt.es>\n"
"Language-Team: Català\n"
"Project-Id-Version: Manager (by ROBOTSTXT) 1.4.1\n"
"Report-Msgid-Bugs-To: https://www.robotstxt.software/plugins/robotstxt-manager/\n"
"PO-Revision-Date: 2026-08-17 15:41+0000\n"
"Last-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"POT-Creation-Date: 2026-08-17T14:52:09+00:00\n"
"X-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"Language-Team: Català <ca@robotstxt.es>\n"
"Language: ca\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Domain: robotstxt-manager\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Domain: robotstxt-manager\n"
#. Plugin Name of the plugin
#: robotstxt-manager.php
msgid "Manager (by ROBOTSTXT)"
msgstr "Manager (by ROBOTSTXT)"
msgstr "Manager (de ROBOTSTXT)"
msgid "Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog from a remote Plugins Core install, resolves local install/update state, and surfaces subscription health. Phase 1: read-only catalog view."
msgstr "Tauler de control del costat del client per a l'ecosistema de plugins de ROBOTSTXT. Llista el catàleg des d'una instal·lació remota de Plugins Core, resol l'estat local d'instal·lació/actualització, i mostra l'estat de les subscripcions. Fase 1: vista de catàleg de només lectura."
#. Plugin URI of the plugin
#: robotstxt-manager.php
msgid "https://www.robotstxt.software/plugins/robotstxt-manager/"
msgstr "https://www.robotstxt.software/plugins/robotstxt-manager/"
#. Description of the plugin
#: robotstxt-manager.php
msgid ""
"Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog "
"from a remote Plugins Core install, resolves local install/update state, and "
"installs, activates, and updates plugins directly from the store."
msgstr ""
"Tauler del costat del client per a l'ecosistema de plugins de ROBOTSTXT. "
"Mostra el catàleg d'una instal·lació remota de Plugins Core, resol l'estat "
"local d'instal·lació i actualització, i instal·la, activa i actualitza "
"plugins directament des de la botiga."
#. Author of the plugin
#: robotstxt-manager.php admin/class-robotstxt-manager-admin.php:48
msgid "ROBOTSTXT"
msgstr "ROBOTSTXT"
#. Author URI of the plugin
#: robotstxt-manager.php
msgid "https://www.robotstxt.software/"
msgstr "https://www.robotstxt.software/"
#: admin/class-robotstxt-manager-admin.php:47 admin/views/page-catalog.php:66
msgid "Manager (by ROBOTSTXT) — Plugins"
msgstr "Manager (by ROBOTSTXT) — Plugins"
#: admin/class-robotstxt-manager-admin.php:64
#: admin/class-robotstxt-manager-settings.php:207
msgid "You do not have sufficient permissions to access this page."
msgstr "No tens permisos suficients per accedir a aquesta pàgina."
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-admin.php:108
#, php-format
msgid ""
"The payment for %s failed. Update your payment method from your ROBOTSTXT "
"account page to keep access."
msgstr ""
"El pagament de %s ha fallat. Actualitza el teu mètode de pagament des de la "
"teva pàgina de compte de ROBOTSTXT per mantenir l'accés."
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-admin.php:119
#, php-format
msgid ""
"The subscription for %s has expired, but the plugin is still active on this "
"site. Renew from your ROBOTSTXT account page to keep receiving updates."
msgstr ""
"La subscripció de %s ha caducat, però el plugin encara és actiu en aquest "
"lloc. Renova-la des de la teva pàgina de compte de ROBOTSTXT per seguir "
"rebent actualitzacions."
#. translators: 1: plugin slug, 2: days remaining.
#: admin/class-robotstxt-manager-admin.php:132
#, php-format
msgid "The subscription for %1$s expires in %2$d day."
msgid_plural "The subscription for %1$s expires in %2$d days."
msgstr[0] "La subscripció de %1$s caduca en %2$d dia."
msgstr[1] "La subscripció de %1$s caduca en %2$d dies."
#: admin/class-robotstxt-manager-admin.php:153
#: admin/class-robotstxt-manager-installer.php:421
#: admin/class-robotstxt-manager-settings.php:379
#: admin/class-robotstxt-manager-settings.php:405
msgid "Insufficient permissions."
msgstr "Permisos insuficients."
#: admin/class-robotstxt-manager-admin.php:197
msgid "Too many refreshes. Please wait a minute before refreshing again."
msgstr "Massa actualitzacions. Espereu un minut abans de tornar a actualitzar."
#: admin/class-robotstxt-manager-installer.php:51
#: admin/class-robotstxt-manager-installer.php:518
msgid "Plugin not found in catalog."
msgstr "Plugin no trobat al catàleg."
#. translators: %s: plugin name.
#: admin/class-robotstxt-manager-installer.php:73
#, php-format
msgid "%s installed. Activate it from the list below."
msgstr "%s instal·lat. Activa'l des de la llista de sota."
#. translators: %s: dependency slug.
#: admin/class-robotstxt-manager-installer.php:114
#, php-format
msgid "Could not install the required plugin %s."
msgstr "No s'ha pogut instal·lar el plugin requerit %s."
#. translators: %s: list of installed dependency names.
#: admin/class-robotstxt-manager-installer.php:130
#, php-format
msgid "Installed required plugins: %s."
msgstr "Plugins requerits instal·lats: %s."
#. translators: %s: error message from WordPress.org.
#: admin/class-robotstxt-manager-installer.php:272
#, php-format
msgid "WordPress.org lookup failed: %s"
msgstr "La cerca a WordPress.org ha fallat: %s"
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-installer.php:281
#, php-format
msgid "No download found on WordPress.org for %s."
msgstr "No s'ha trobat cap baixada a WordPress.org per a %s."
#: admin/class-robotstxt-manager-installer.php:287
#: admin/class-robotstxt-manager-installer.php:540
msgid "Could not create a temporary file for download."
msgstr "No s'ha pogut crear un fitxer temporal per a la baixada."
#. translators: %s: HTTP transport error message.
#: admin/class-robotstxt-manager-installer.php:303
#: admin/class-robotstxt-manager-installer.php:573
#, php-format
msgid "Download failed: %s"
msgstr "Error en la baixada: %s"
#. translators: %d: HTTP status code.
#: admin/class-robotstxt-manager-installer.php:310
#: admin/class-robotstxt-manager-installer.php:588
#, php-format
msgid "Download failed (HTTP %d)."
msgstr "Error en la baixada (HTTP %d)."
#: admin/class-robotstxt-manager-installer.php:316
#: admin/class-robotstxt-manager-installer.php:597
msgid "The store returned an invalid file."
msgstr "La botiga ha retornat un fitxer no vàlid."
#. translators: %s: upgrader error message.
#: admin/class-robotstxt-manager-installer.php:334
#: admin/class-robotstxt-manager-installer.php:622
#, php-format
msgid "Installation failed: %s"
msgstr "Error en la instal·lació: %s"
#. translators: %s: upgrader error message.
#: admin/class-robotstxt-manager-installer.php:334
#: admin/class-robotstxt-manager-installer.php:625
msgid "Installation failed."
msgstr "Error en la instal·lació."
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-installer.php:341
#, php-format
msgid ""
"The downloaded plugin for %s does not have the expected folder structure."
msgstr "El plugin baixat per a %s no té l'estructura de carpetes esperada."
#: admin/class-robotstxt-manager-installer.php:363
#: admin/class-robotstxt-manager-installer.php:394
msgid "Plugin is not installed."
msgstr "El plugin no està instal·lat."
#. translators: %s: plugin name (slug).
#: admin/class-robotstxt-manager-installer.php:375
#, php-format
msgid "%s activated."
msgstr "%s activat."
#. translators: %s: plugin name (slug).
#: admin/class-robotstxt-manager-installer.php:406
#, php-format
msgid "%s updated."
msgstr "%s actualitzat."
#: admin/class-robotstxt-manager-installer.php:512
msgid "Store is not configured."
msgstr "La botiga no està configurada."
#: admin/class-robotstxt-manager-settings.php:59
#: admin/views/page-settings.php:13
msgid "Manager (by ROBOTSTXT) — Settings"
msgstr "Manager (by ROBOTSTXT) — Configuració"
msgstr "Manager (by ROBOTSTXT) — Paràmetres"
#: admin/class-robotstxt-manager-settings.php:60
msgid "Settings"
msgstr "Configuració"
msgstr "Paràmetres"
#: admin/class-robotstxt-manager-settings.php:75
msgid "Connection"
msgstr "Connexió"
#: admin/class-robotstxt-manager-settings.php:92
msgid "Store URL"
msgstr "URL del store"
msgstr "URL de la botiga"
#: admin/class-robotstxt-manager-settings.php:110
msgid "API Key"
msgstr "Clau API"
#: admin/class-robotstxt-manager-settings.php:118
msgid "Cache"
msgstr "Memòria cau"
#: admin/class-robotstxt-manager-settings.php:135
msgid "Catalog Cache (minutes)"
msgstr "Memòria cau del catàleg (minuts)"
#: admin/class-robotstxt-manager-settings.php:153
msgid "Data on Uninstall"
msgstr "Dades en desinstal·lar"
#: admin/class-robotstxt-manager-settings.php:188
msgid "Testing…"
msgstr "Provant…"
msgstr "S'està provant…"
#: admin/class-robotstxt-manager-settings.php:189
#: admin/class-robotstxt-manager-settings.php:274
msgid "Test connection"
msgstr "Provar connexió"
msgstr "Prova la connexió"
#: admin/class-robotstxt-manager-settings.php:190
msgid "Deleting…"
msgstr "Eliminant…"
msgstr "S'està esborrant…"
#: admin/class-robotstxt-manager-settings.php:191
#: admin/class-robotstxt-manager-settings.php:278
msgid "Delete API key"
msgstr "Eliminar clau API"
msgstr "Esborra la clau API"
msgid "Delete the stored API key? The catalog and subscription data will stop working until a new key is entered."
msgstr "Eliminar la clau API emmagatzemada? Les dades del catàleg i les subscripcions deixaran de funcionar fins que s'introdueixi una nova clau."
#: admin/class-robotstxt-manager-settings.php:192
msgid ""
"Delete the stored API key? The catalog and subscription data will stop "
"working until a new key is entered."
msgstr ""
"Segur que voleu esborrar la clau API desada? El catàleg i les dades de "
"subscripció deixaran de funcionar fins que s'introdueixi una clau nova."
#: admin/class-robotstxt-manager-settings.php:193
#: admin/class-robotstxt-manager-settings.php:414
msgid "API key deleted. Save changes to persist."
msgstr "Clau API eliminada. Desa els canvis per persistir."
msgstr "Clau API esborrada. Desa els canvis perquè faci efecte."
#: admin/class-robotstxt-manager-settings.php:194
msgid "An unexpected error occurred."
msgstr "S'ha produït un error inesperat."
msgid "Base URL of the remote Plugins Core installation that this site will pull the plugin catalog from."
msgstr "URL base de la instal·lació remota de Plugins Core des d'on aquest lloc obtindrà el catàleg de plugins."
#: admin/class-robotstxt-manager-settings.php:226
msgid ""
"Base URL of the remote Plugins Core installation that this site will pull "
"the plugin catalog from."
msgstr ""
"URL base de la instal·lació remota de Plugins Core d'on aquest lloc obtindrà "
"el catàleg de plugins."
msgid "A key is stored (last 4 characters: <code>%s</code>). Leave blank to keep the existing key; enter a new value to replace it."
msgstr "Hi ha una clau emmagatzemada (últims 4 caràcters: <code>%s</code>). Deixa-ho en blanc per mantenir la clau existent; introdueix un valor nou per reemplaçar-la."
#. translators: %s: last 4 characters of the stored API key.
#: admin/class-robotstxt-manager-settings.php:257
#, php-format
msgid ""
"A key is stored (last 4 characters: <code>%s</code>). Leave blank to keep "
"the existing key; enter a new value to replace it."
msgstr ""
"Hi ha una clau desada (últims 4 caràcters: <code>%s</code>). Deixa-ho en "
"blanc per mantenir la clau actual; escriu un valor nou per substituir-la."
msgid "A key is stored but could not be decoded. You can replace it by entering a new value above, or delete it with the button below."
msgstr "Hi ha una clau emmagatzemada però no s'ha pogut descodificar. Pots reemplaçar-la introduint un valor nou a dalt, o eliminar-la amb el botó de baix."
#: admin/class-robotstxt-manager-settings.php:262
msgid ""
"A key is stored but could not be decoded. You can replace it by entering a "
"new value above, or delete it with the button below."
msgstr ""
"Hi ha una clau desada però no s'ha pogut descodificar. Pots substituir-la "
"escrivint un valor nou a dalt, o esborrar-la amb el botó de sota."
msgid "Account-level API key issued by the ROBOTSTXT store. Required to authenticate catalog and subscription requests. The value is encrypted before storage."
msgstr "Clau API de nivell de compte emesa pel store de ROBOTSTXT. Necessària per autenticar les peticions de catàleg i subscripció. El valor es xifra abans d'emmagatzemar-se."
#: admin/class-robotstxt-manager-settings.php:266
msgid ""
"Account-level API key from the ROBOTSTXT store (create your free account "
"there to get one). Optional — the free catalog works without it — but "
"required to link your subscriptions, install premium plugins, and receive "
"their updates. Encrypted before storage."
msgstr ""
"Clau API a nivell de compte de la botiga de ROBOTSTXT (crea allà el teu "
"compte gratuït per obtenir-la). Opcional: el catàleg de plugins gratuïts "
"funciona sense ella, però és necessària per vincular les teves "
"subscripcions, instal·lar plugins premium i rebre les actualitzacions. Es "
"xifra abans de desar-se."
msgid "How long the catalog response from Core is cached in a transient. Default: 60 minutes. Lower values refresh more often at the cost of more requests to Core. Maximum: 1440 (24 hours)."
msgstr "Quant de temps s'emmagatzema a la memòria cau la resposta del catàleg de Core. Per defecte: 60 minuts. Valors més baixos actualitzen més sovint a costa de més peticions a Core. Màxim: 1440 (24 hores)."
#: admin/class-robotstxt-manager-settings.php:298
msgid ""
"How long the catalog response from Core is cached in a transient. Default: "
"60 minutes. Lower values refresh more often at the cost of more requests to "
"Core. Maximum: 1440 (24 hours)."
msgstr ""
"Quant de temps es desa a la memòria cau la resposta del catàleg de Core en "
"un transient. Per defecte: 60 minuts. Els valors més baixos actualitzen més "
"sovint a costa de més sol·licituds a Core. Màxim: 1440 (24 hores)."
#: admin/class-robotstxt-manager-settings.php:313
msgid "Delete all plugin data when the plugin is uninstalled."
msgstr "Eliminar totes les dades del plugin quan es desinstal·li."
msgstr "Esborra totes les dades del plugin quan es desinstal·li."
#: admin/class-robotstxt-manager-settings.php:343
msgid ""
"The API key format is invalid. Copy the full key from your ROBOTSTXT account "
"page."
msgstr ""
"El format de la clau API no és vàlid. Copia la clau completa des de la teva "
"pàgina de compte de ROBOTSTXT."
#: admin/views/page-catalog.php:69
msgid ""
"This is the ROBOTSTXT plugin store: the catalog of plugins published by "
"ROBOTSTXT, installable and updatable straight from your own wp-admin. Free "
"plugins install with one click; premium plugins require an annual "
"subscription that you purchase on our website."
msgstr ""
"Aquesta és la botiga de plugins de ROBOTSTXT: el catàleg de plugins "
"publicats per ROBOTSTXT, instal·lables i actualitzables directament des del "
"teu propi wp-admin. Els plugins gratuïts s'instal·len amb un clic; els "
"plugins premium requereixen una subscripció anual que es compra al nostre "
"lloc web."
#: admin/views/page-catalog.php:73
msgid "Catalog refreshed."
msgstr "Catàleg actualitzat."
msgid "ROBOTSTXT Manager is not configured yet. <a href=\"%s\">Set the Store URL and API key</a> to see your plugin catalog."
msgstr "ROBOTSTXT Manager encara no està configurat. <a href=\"%s\">Configura la URL del store i la clau API</a> per veure el teu catàleg de plugins."
#. translators: %s: store registration URL.
#: admin/views/page-catalog.php:89
#, php-format
msgid ""
"Create your free account at the <a href=\"%s\" target=\"_blank\" "
"rel=\"noopener noreferrer\">ROBOTSTXT store</a> to get your personal API "
"key. The key links your subscriptions to this site and unlocks premium "
"plugins and updates."
msgstr ""
"Creeu el vostre compte gratuït a <a href=\"%s\" target=\"_blank\" rel=\"noopener "
"noreferrer\">la botiga de ROBOTSTXT</a> per obtenir la teva clau API "
"personal. La clau vincula les teves subscripcions a aquest lloc i "
"desbloqueja els plugins premium i les seves actualitzacions."
msgid "No plugins were returned by the ROBOTSTXT store. Check the Store URL and API key on the Settings page, or click Refresh to try again."
msgstr "El store de ROBOTSTXT no ha retornat cap plugin. Comprova la URL del store i la clau API a la pàgina de Configuració, o fes clic a Actualitzar per reintentar."
#. translators: %s: settings URL.
#: admin/views/page-catalog.php:109
#, php-format
msgid ""
"ROBOTSTXT Manager is not configured yet. <a href=\"%s\">Set the Store URL "
"and API key</a> to see your plugin catalog."
msgstr ""
"ROBOTSTXT Manager encara no està configurat. <a href=\"%s\">Configura la URL "
"de la botiga i la clau API</a> per veure el teu catàleg de plugins."
#: admin/views/page-catalog.php:119
msgid ""
"No plugins were returned by the ROBOTSTXT store. Check the Store URL and API "
"key on the Settings page, or click Refresh to try again."
msgstr ""
"La botiga ROBOTSTXT no ha retornat cap plugin. Revisa la URL de la botiga i "
"la clau API a la pàgina de paràmetres, o prem Actualitza per tornar-ho a "
"provar."
#: admin/views/page-catalog.php:122 admin/views/page-catalog.php:125
msgid "Refresh catalog"
msgstr "Actualitzar catàleg"
msgstr "Actualitza el catàleg"
#: admin/views/page-catalog.php:131
msgid "Plugin"
msgstr "Plugin"
msgid "Type"
msgstr "Tipus"
msgid "Price"
msgstr "Preu"
#: admin/views/page-catalog.php:132
msgid "Version"
msgstr "Versió"
#: admin/views/page-catalog.php:133
msgid "Requires WP"
msgstr "Requereix WP"
#: admin/views/page-catalog.php:134
msgid "Requires PHP"
msgstr "Requereix PHP"
msgid "Local state"
msgstr "Estat local"
#: admin/views/page-catalog.php:135
msgid "Price"
msgstr "Preu"
#: admin/views/page-catalog.php:136
msgid "Action"
msgstr "Acció"
msgid "Premium"
msgstr "Premium"
#: admin/views/page-catalog.php:137
msgid "Status"
msgstr "Estat"
#: admin/views/page-catalog.php:239
msgid "Your site runs WordPress"
msgstr "El vostre lloc funciona amb WordPress"
#: admin/views/page-catalog.php:251
msgid "Your server runs PHP"
msgstr "El vostre servidor funciona amb PHP"
#. translators: %s: formatted price number.
#: admin/views/page-catalog.php:264
#, php-format
msgid "€%s / year"
msgstr "%s €/any"
#. translators: %d: days remaining.
#: admin/views/page-catalog.php:282
#, php-format
msgid "Subscribed — %d days left"
msgstr "Subscrit — queden %d dies"
#: admin/views/page-catalog.php:286
msgid "Subscribed"
msgstr "Subscrit"
#: admin/views/page-catalog.php:289
msgid "Payment failed"
msgstr "Pagament fallit"
#: admin/views/page-catalog.php:291
msgid "Cancelled"
msgstr "Cancel·lada"
#: admin/views/page-catalog.php:293
msgid "Expired"
msgstr "Caducada"
#: admin/views/page-catalog.php:303
msgid "Free"
msgstr "Gratuït"
msgid "Your site runs WordPress"
msgstr "El teu lloc usa WordPress"
msgid "Your server runs PHP"
msgstr "El teu servidor usa PHP"
msgid "Not installed"
msgstr "No instal·lat"
msgid "Installed (inactive)"
msgstr "Instal·lat (inactiu)"
msgid "Update available (v%1$s → v%2$s)"
msgstr "Actualització disponible (v%1$s → v%2$s)"
msgid "Up to date"
msgstr "Actualitzat"
#: admin/views/page-catalog.php:309
msgid "Incompatible"
msgstr "Incompatible"
#: admin/views/page-catalog.php:312
msgid "Buy"
msgstr "Comprar"
msgid "Install (soon)"
msgstr "Instal·lar (properament)"
#: admin/views/page-catalog.php:317
msgid "Install"
msgstr "Instal·la"
msgid "Update (soon)"
msgstr "Actualitzar (properament)"
#: admin/views/page-catalog.php:319
msgid "Activate"
msgstr "Activa"
msgid "%d plugin in the catalog is not compatible with this site's WordPress or PHP version."
msgid_plural "%d plugins in the catalog are not compatible with this site's WordPress or PHP version."
msgstr[0] "%d plugin del catàleg no és compatible amb la versió de WordPress o PHP d'aquest lloc."
msgstr[1] "%d plugins del catàleg no són compatibles amb la versió de WordPress o PHP d'aquest lloc."
#: admin/views/page-catalog.php:321
msgid "Update"
msgstr "Actualitza"
#: admin/views/page-catalog.php:327
msgid "Not installed"
msgstr "No instal·lat"
#: admin/views/page-catalog.php:329
msgid "Installed (inactive)"
msgstr "Instal·lat (inactiu)"
#. translators: 1: installed version, 2: available version.
#: admin/views/page-catalog.php:334
#, php-format
msgid "Update available (v%1$s → v%2$s)"
msgstr "Actualització disponible (v%1$s → v%2$s)"
#: admin/views/page-catalog.php:340
msgid "Up to date"
msgstr "Actualitzat"
#: admin/views/page-catalog.php:349
msgid "Visit website"
msgstr "Visita el lloc web"
#. translators: %s: plugin name.
#: admin/views/page-catalog.php:349
#, php-format
msgid "(about %s)"
msgstr "(sobre %s)"
#. translators: %s: list of plugin slugs.
#: admin/views/page-catalog.php:360
#, php-format
msgid "Requires: %s"
msgstr "Requereix: %s"
#: admin/views/page-catalog.php:374
msgid "Support"
msgstr "Suport"
#: admin/views/page-catalog.php:376
msgid ""
"Need help with a ROBOTSTXT plugin? Visit the plugin's website (the link in "
"its row above) for documentation and guides, or contact us through our "
"website — we are happy to help."
msgstr ""
"Necessites ajuda amb un plugin de ROBOTSTXT? Visita el lloc web del plugin "
"(l'enllaç a la seva fila de dalt) per veure la documentació i les guies, o "
"contacta amb nosaltres a través del nostre lloc web: estarem encantats "
"d'ajudar-te."
#: admin/views/page-catalog.php:379
msgid "Payments, and how it works"
msgstr "Pagaments, i com funciona"
#: admin/views/page-catalog.php:381
msgid ""
"Free plugins install instantly at no cost. Premium plugins are annual "
"subscriptions: you pay once on our website and the subscription renews "
"automatically every year until you cancel. You can cancel at any time from "
"your ROBOTSTXT account page — access keeps working until the end of the paid "
"period. All payments are processed securely by Mollie; we never see or store "
"your card details."
msgstr ""
"Els plugins gratuïts s'instal·len a l'instant i sense cost. Els plugins "
"premium són subscripcions anuals: pagues una vegada al nostre lloc web i la "
"subscripció es renova automàticament cada any fins que la cancellis. Pots "
"cancel·lar-la en qualsevol moment des de la teva pàgina de compte de "
"ROBOTSTXT; l'accés segueix funcionant fins al final del període pagat. Tots "
"els pagaments els processa de manera segura Mollie; nosaltres mai no veiem "
"ni desem les dades de la teva targeta."
#. translators: %d: number of incompatible plugins.
#: admin/views/page-catalog.php:391
#, php-format
msgid ""
"%d plugin in the catalog is not compatible with this site's WordPress or PHP "
"version."
msgid_plural ""
"%d plugins in the catalog are not compatible with this site's WordPress or "
"PHP version."
msgstr[0] ""
"%d plugin del catàleg no és compatible amb la versió de WordPress o de PHP "
"d'aquest lloc."
msgstr[1] ""
"%d plugins del catàleg no són compatibles amb la versió de WordPress o de "
"PHP d'aquest lloc."
#. translators: 1: local WP version, 2: local PHP version.
#: admin/views/page-catalog.php:400
#, php-format
msgid "This site runs WordPress %1$s on PHP %2$s."
msgstr "Aquest lloc usa WordPress %1$s en PHP %2$s."
msgstr "Aquest lloc funciona amb WordPress %1$s i PHP %2$s."
#: includes/class-robotstxt-manager-core-client.php:141
msgid "Store URL is not configured."
msgstr "La URL del store no està configurada."
msgstr "La URL de la botiga no està configurada."
#: includes/class-robotstxt-manager-core-client.php:148
msgid "API key is not configured."
msgstr "La clau API no està configurada."
#. translators: %s: HTTP transport error message.
#: includes/class-robotstxt-manager-core-client.php:158
#, php-format
msgid "Could not reach Plugins Core: %s"
msgstr "No s'ha pogut connectar amb Plugins Core: %s"
#. translators: %d: HTTP status code.
#: includes/class-robotstxt-manager-core-client.php:168
#, php-format
msgid "The store responded with HTTP %d. Check the Store URL and API key."
msgstr "La botiga ha respost amb HTTP %d. Comproveu l'URL de la botiga i la clau API."
#: includes/class-robotstxt-manager-core-client.php:177
msgid "Connected."
msgstr "Connectat."
#: includes/class-robotstxt-manager-updater.php admin/class-robotstxt-manager-admin.php
msgid "<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Update now</a> — this is a security patch for the version this site runs, not a feature update."
msgstr "<strong>Actualització de seguretat disponible:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Actualitza ara</a> — és un pedaç de seguretat per a la versió que fa servir aquest lloc, no una actualització de funcions."
msgid "Security update available (v%1$s → v%2$s)"
msgstr "Actualització de seguretat disponible (v%1$s → v%2$s)"

Binary file not shown.

View file

@ -1,184 +1,591 @@
# Copyright (C) 2026 ROBOTSTXT
# This file is distributed under the GPL-3.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Manager (by ROBOTSTXT) 0.1.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-manager\n"
"POT-Creation-Date: 2026-08-12T13:09:24+00:00\n"
"PO-Revision-Date: 2026-08-12 13:15+0000\n"
"Last-Translator: ROBOTSTXT <info@robotstxt.es>\n"
"Language-Team: Español\n"
"Project-Id-Version: Manager (by ROBOTSTXT) 1.4.1\n"
"Report-Msgid-Bugs-To: https://www.robotstxt.software/plugins/robotstxt-manager/\n"
"PO-Revision-Date: 2026-08-17 15:41+0000\n"
"Last-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"POT-Creation-Date: 2026-08-17T14:52:09+00:00\n"
"X-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"Language-Team: Español (España) <es_ES@robotstxt.es>\n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Domain: robotstxt-manager\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Domain: robotstxt-manager\n"
#. Plugin Name of the plugin
#: robotstxt-manager.php
msgid "Manager (by ROBOTSTXT)"
msgstr "Manager (by ROBOTSTXT)"
msgstr "Manager (de ROBOTSTXT)"
msgid "Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog from a remote Plugins Core install, resolves local install/update state, and surfaces subscription health. Phase 1: read-only catalog view."
msgstr "Panel de control del lado del cliente para el ecosistema de plugins de ROBOTSTXT. Lista el catálogo desde una instalación remota de Plugins Core, resuelve el estado local de instalación/actualización, y muestra el estado de las suscripciones. Fase 1: vista de catálogo de solo lectura."
#. Plugin URI of the plugin
#: robotstxt-manager.php
msgid "https://www.robotstxt.software/plugins/robotstxt-manager/"
msgstr "https://www.robotstxt.software/plugins/robotstxt-manager/"
#. Description of the plugin
#: robotstxt-manager.php
msgid ""
"Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog "
"from a remote Plugins Core install, resolves local install/update state, and "
"installs, activates, and updates plugins directly from the store."
msgstr ""
"Escritorio del lado del cliente para el ecosistema de plugins de ROBOTSTXT. "
"Muestra el catálogo de una instalación remota de Plugins Core, resuelve el "
"estado local de instalación y actualización, e instala, activa y actualiza "
"plugins directamente desde la tienda."
#. Author of the plugin
#: robotstxt-manager.php admin/class-robotstxt-manager-admin.php:48
msgid "ROBOTSTXT"
msgstr "ROBOTSTXT"
#. Author URI of the plugin
#: robotstxt-manager.php
msgid "https://www.robotstxt.software/"
msgstr "https://www.robotstxt.software/"
#: admin/class-robotstxt-manager-admin.php:47 admin/views/page-catalog.php:66
msgid "Manager (by ROBOTSTXT) — Plugins"
msgstr "Manager (by ROBOTSTXT) — Plugins"
#: admin/class-robotstxt-manager-admin.php:64
#: admin/class-robotstxt-manager-settings.php:207
msgid "You do not have sufficient permissions to access this page."
msgstr "No tienes permisos suficientes para acceder a esta página."
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-admin.php:108
#, php-format
msgid ""
"The payment for %s failed. Update your payment method from your ROBOTSTXT "
"account page to keep access."
msgstr ""
"El pago de %s falló. Actualiza tu método de pago desde tu página de cuenta "
"de ROBOTSTXT para mantener el acceso."
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-admin.php:119
#, php-format
msgid ""
"The subscription for %s has expired, but the plugin is still active on this "
"site. Renew from your ROBOTSTXT account page to keep receiving updates."
msgstr ""
"La suscripción de %s ha caducado, pero el plugin sigue activo en este sitio. "
"Renuévela desde tu página de cuenta de ROBOTSTXT para seguir recibiendo "
"actualizaciones."
#. translators: 1: plugin slug, 2: days remaining.
#: admin/class-robotstxt-manager-admin.php:132
#, php-format
msgid "The subscription for %1$s expires in %2$d day."
msgid_plural "The subscription for %1$s expires in %2$d days."
msgstr[0] "La suscripción de %1$s caduca en %2$d día."
msgstr[1] "La suscripción de %1$s caduca en %2$d días."
#: admin/class-robotstxt-manager-admin.php:153
#: admin/class-robotstxt-manager-installer.php:421
#: admin/class-robotstxt-manager-settings.php:379
#: admin/class-robotstxt-manager-settings.php:405
msgid "Insufficient permissions."
msgstr "Permisos insuficientes."
#: admin/class-robotstxt-manager-admin.php:197
msgid "Too many refreshes. Please wait a minute before refreshing again."
msgstr "Demasiadas actualizaciones seguidas. Espera un minuto antes de volver a actualizar."
#: admin/class-robotstxt-manager-installer.php:51
#: admin/class-robotstxt-manager-installer.php:518
msgid "Plugin not found in catalog."
msgstr "Plugin no encontrado en el catálogo."
#. translators: %s: plugin name.
#: admin/class-robotstxt-manager-installer.php:73
#, php-format
msgid "%s installed. Activate it from the list below."
msgstr "%s instalado. Actívalo desde la lista de abajo."
#. translators: %s: dependency slug.
#: admin/class-robotstxt-manager-installer.php:114
#, php-format
msgid "Could not install the required plugin %s."
msgstr "No se pudo instalar el plugin requerido %s."
#. translators: %s: list of installed dependency names.
#: admin/class-robotstxt-manager-installer.php:130
#, php-format
msgid "Installed required plugins: %s."
msgstr "Plugins requeridos instalados: %s."
#. translators: %s: error message from WordPress.org.
#: admin/class-robotstxt-manager-installer.php:272
#, php-format
msgid "WordPress.org lookup failed: %s"
msgstr "La búsqueda en WordPress.org falló: %s"
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-installer.php:281
#, php-format
msgid "No download found on WordPress.org for %s."
msgstr "No se encontró ninguna descarga en WordPress.org para %s."
#: admin/class-robotstxt-manager-installer.php:287
#: admin/class-robotstxt-manager-installer.php:540
msgid "Could not create a temporary file for download."
msgstr "No se pudo crear un archivo temporal para la descarga."
#. translators: %s: HTTP transport error message.
#: admin/class-robotstxt-manager-installer.php:303
#: admin/class-robotstxt-manager-installer.php:573
#, php-format
msgid "Download failed: %s"
msgstr "Fallo en la descarga: %s"
#. translators: %d: HTTP status code.
#: admin/class-robotstxt-manager-installer.php:310
#: admin/class-robotstxt-manager-installer.php:588
#, php-format
msgid "Download failed (HTTP %d)."
msgstr "Fallo en la descarga (HTTP %d)."
#: admin/class-robotstxt-manager-installer.php:316
#: admin/class-robotstxt-manager-installer.php:597
msgid "The store returned an invalid file."
msgstr "La tienda devolvió un archivo no válido."
#. translators: %s: upgrader error message.
#: admin/class-robotstxt-manager-installer.php:334
#: admin/class-robotstxt-manager-installer.php:622
#, php-format
msgid "Installation failed: %s"
msgstr "Fallo en la instalación: %s"
#. translators: %s: upgrader error message.
#: admin/class-robotstxt-manager-installer.php:334
#: admin/class-robotstxt-manager-installer.php:625
msgid "Installation failed."
msgstr "Fallo en la instalación."
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-installer.php:341
#, php-format
msgid ""
"The downloaded plugin for %s does not have the expected folder structure."
msgstr ""
"El plugin descargado para %s no tiene la estructura de carpetas esperada."
#: admin/class-robotstxt-manager-installer.php:363
#: admin/class-robotstxt-manager-installer.php:394
msgid "Plugin is not installed."
msgstr "El plugin no está instalado."
#. translators: %s: plugin name (slug).
#: admin/class-robotstxt-manager-installer.php:375
#, php-format
msgid "%s activated."
msgstr "%s activado."
#. translators: %s: plugin name (slug).
#: admin/class-robotstxt-manager-installer.php:406
#, php-format
msgid "%s updated."
msgstr "%s actualizado."
#: admin/class-robotstxt-manager-installer.php:512
msgid "Store is not configured."
msgstr "La tienda no está configurada."
#: admin/class-robotstxt-manager-settings.php:59
#: admin/views/page-settings.php:13
msgid "Manager (by ROBOTSTXT) — Settings"
msgstr "Manager (by ROBOTSTXT) — Ajustes"
#: admin/class-robotstxt-manager-settings.php:60
msgid "Settings"
msgstr "Ajustes"
#: admin/class-robotstxt-manager-settings.php:75
msgid "Connection"
msgstr "Conexión"
#: admin/class-robotstxt-manager-settings.php:92
msgid "Store URL"
msgstr "URL del store"
msgstr "URL de la tienda"
#: admin/class-robotstxt-manager-settings.php:110
msgid "API Key"
msgstr "Clave API"
#: admin/class-robotstxt-manager-settings.php:118
msgid "Cache"
msgstr "Caché"
#: admin/class-robotstxt-manager-settings.php:135
msgid "Catalog Cache (minutes)"
msgstr "Caché del catálogo (minutos)"
#: admin/class-robotstxt-manager-settings.php:153
msgid "Data on Uninstall"
msgstr "Datos al desinstalar"
#: admin/class-robotstxt-manager-settings.php:188
msgid "Testing…"
msgstr "Probando…"
#: admin/class-robotstxt-manager-settings.php:189
#: admin/class-robotstxt-manager-settings.php:274
msgid "Test connection"
msgstr "Probar conexión"
msgstr "Probar la conexión"
#: admin/class-robotstxt-manager-settings.php:190
msgid "Deleting…"
msgstr "Eliminando…"
msgstr "Borrando…"
#: admin/class-robotstxt-manager-settings.php:191
#: admin/class-robotstxt-manager-settings.php:278
msgid "Delete API key"
msgstr "Eliminar clave API"
msgstr "Borrar la clave API"
msgid "Delete the stored API key? The catalog and subscription data will stop working until a new key is entered."
msgstr "¿Eliminar la clave API almacenada? Los datos del catálogo y las suscripciones dejarán de funcionar hasta que se introduzca una nueva clave."
#: admin/class-robotstxt-manager-settings.php:192
msgid ""
"Delete the stored API key? The catalog and subscription data will stop "
"working until a new key is entered."
msgstr ""
"¿Seguro que quieres borrar la clave API guardada? El catálogo y los datos de "
"suscripción dejarán de funcionar hasta que se introduzca una clave nueva."
#: admin/class-robotstxt-manager-settings.php:193
#: admin/class-robotstxt-manager-settings.php:414
msgid "API key deleted. Save changes to persist."
msgstr "Clave API eliminada. Guarda los cambios para persistir."
msgstr "Clave API borrada. Guarda los cambios para que surta efecto."
#: admin/class-robotstxt-manager-settings.php:194
msgid "An unexpected error occurred."
msgstr "Ocurrió un error inesperado."
msgstr "Ha ocurrido un error inesperado."
msgid "Base URL of the remote Plugins Core installation that this site will pull the plugin catalog from."
msgstr "URL base de la instalación remota de Plugins Core desde la que este sitio obtendrá el catálogo de plugins."
#: admin/class-robotstxt-manager-settings.php:226
msgid ""
"Base URL of the remote Plugins Core installation that this site will pull "
"the plugin catalog from."
msgstr ""
"URL base de la instalación remota de Plugins Core de la que este sitio "
"obtendrá el catálogo de plugins."
msgid "A key is stored (last 4 characters: <code>%s</code>). Leave blank to keep the existing key; enter a new value to replace it."
msgstr "Hay una clave almacenada (últimos 4 caracteres: <code>%s</code>). Déjalo en blanco para mantener la clave existente; introduce un nuevo valor para reemplazarla."
#. translators: %s: last 4 characters of the stored API key.
#: admin/class-robotstxt-manager-settings.php:257
#, php-format
msgid ""
"A key is stored (last 4 characters: <code>%s</code>). Leave blank to keep "
"the existing key; enter a new value to replace it."
msgstr ""
"Hay una clave guardada (últimos 4 caracteres: <code>%s</code>). Déjalo en "
"blanco para mantener la clave actual; escribe un valor nuevo para "
"sustituirla."
msgid "A key is stored but could not be decoded. You can replace it by entering a new value above, or delete it with the button below."
msgstr "Hay una clave almacenada pero no se pudo decodificar. Puedes reemplazarla introduciendo un nuevo valor arriba, o eliminarla con el botón de abajo."
#: admin/class-robotstxt-manager-settings.php:262
msgid ""
"A key is stored but could not be decoded. You can replace it by entering a "
"new value above, or delete it with the button below."
msgstr ""
"Hay una clave guardada pero no se pudo descodificar. Puedes sustituirla "
"escribiendo un valor nuevo arriba, o borrarla con el botón de abajo."
msgid "Account-level API key issued by the ROBOTSTXT store. Required to authenticate catalog and subscription requests. The value is encrypted before storage."
msgstr "Clave API de nivel de cuenta emitida por el store de ROBOTSTXT. Necesaria para autenticar las peticiones de catálogo y suscripción. El valor se cifra antes de almacenarse."
#: admin/class-robotstxt-manager-settings.php:266
msgid ""
"Account-level API key from the ROBOTSTXT store (create your free account "
"there to get one). Optional — the free catalog works without it — but "
"required to link your subscriptions, install premium plugins, and receive "
"their updates. Encrypted before storage."
msgstr ""
"Clave API a nivel de cuenta de la tienda de ROBOTSTXT (crea allí tu cuenta "
"gratuita para obtenerla). Opcional: el catálogo de plugins gratis funciona "
"sin ella, pero es necesaria para vincular tus suscripciones, instalar "
"plugins premium y recibir sus actualizaciones. Se cifra antes de guardarse."
msgid "How long the catalog response from Core is cached in a transient. Default: 60 minutes. Lower values refresh more often at the cost of more requests to Core. Maximum: 1440 (24 hours)."
msgstr "Cuánto tiempo se cachea la respuesta del catálogo de Core en un transient. Por defecto: 60 minutos. Valores más bajos actualizan más a menudo a costa de más peticiones a Core. Máximo: 1440 (24 horas)."
#: admin/class-robotstxt-manager-settings.php:298
msgid ""
"How long the catalog response from Core is cached in a transient. Default: "
"60 minutes. Lower values refresh more often at the cost of more requests to "
"Core. Maximum: 1440 (24 hours)."
msgstr ""
"Cuánto tiempo se guarda en caché la respuesta del catálogo de Core en un "
"transient. Por defecto: 60 minutos. Los valores más bajos actualizan con más "
"frecuencia a costa de más solicitudes a Core. Máximo: 1440 (24 horas)."
#: admin/class-robotstxt-manager-settings.php:313
msgid "Delete all plugin data when the plugin is uninstalled."
msgstr "Eliminar todos los datos del plugin cuando se desinstale."
msgstr "Borrar todos los datos del plugin cuando se desinstale."
#: admin/class-robotstxt-manager-settings.php:343
msgid ""
"The API key format is invalid. Copy the full key from your ROBOTSTXT account "
"page."
msgstr ""
"El formato de la clave API no es válido. Copia la clave completa desde tu "
"página de cuenta de ROBOTSTXT."
#: admin/views/page-catalog.php:69
msgid ""
"This is the ROBOTSTXT plugin store: the catalog of plugins published by "
"ROBOTSTXT, installable and updatable straight from your own wp-admin. Free "
"plugins install with one click; premium plugins require an annual "
"subscription that you purchase on our website."
msgstr ""
"Esta es la tienda de plugins de ROBOTSTXT: el catálogo de plugins publicados "
"por ROBOTSTXT, instalables y actualizables directamente desde tu propio wp-"
"admin. Los plugins gratis se instalan con un clic; los plugins premium "
"requieren una suscripción anual que se compra en nuestro sitio web."
#: admin/views/page-catalog.php:73
msgid "Catalog refreshed."
msgstr "Catálogo actualizado."
msgid "ROBOTSTXT Manager is not configured yet. <a href=\"%s\">Set the Store URL and API key</a> to see your plugin catalog."
msgstr "ROBOTSTXT Manager aún no está configurado. <a href=\"%s\">Configura la URL del store y la clave API</a> para ver tu catálogo de plugins."
#. translators: %s: store registration URL.
#: admin/views/page-catalog.php:89
#, php-format
msgid ""
"Create your free account at the <a href=\"%s\" target=\"_blank\" "
"rel=\"noopener noreferrer\">ROBOTSTXT store</a> to get your personal API "
"key. The key links your subscriptions to this site and unlocks premium "
"plugins and updates."
msgstr ""
"Crea tu cuenta gratuita en <a href=\"%s\" target=\"_blank\" rel=\"noopener "
"noreferrer\">la tienda de ROBOTSTXT</a> para obtener tu clave API personal. "
"La clave vincula tus suscripciones a este sitio y desbloquea los plugins "
"premium y sus actualizaciones."
msgid "No plugins were returned by the ROBOTSTXT store. Check the Store URL and API key on the Settings page, or click Refresh to try again."
msgstr "El store de ROBOTSTXT no devolvió ningún plugin. Comprueba la URL del store y la clave API en la página de Ajustes, o haz clic en Actualizar para reintentar."
#. translators: %s: settings URL.
#: admin/views/page-catalog.php:109
#, php-format
msgid ""
"ROBOTSTXT Manager is not configured yet. <a href=\"%s\">Set the Store URL "
"and API key</a> to see your plugin catalog."
msgstr ""
"ROBOTSTXT Manager aún no está configurado. <a href=\"%s\">Configura la URL "
"de la tienda y la clave API</a> para ver tu catálogo de plugins."
#: admin/views/page-catalog.php:119
msgid ""
"No plugins were returned by the ROBOTSTXT store. Check the Store URL and API "
"key on the Settings page, or click Refresh to try again."
msgstr ""
"La tienda ROBOTSTXT no devolvió ningún plugin. Revisa la URL de la tienda y "
"la clave API en la página de ajustes, o pulsa Actualizar para volver a "
"intentarlo."
#: admin/views/page-catalog.php:122 admin/views/page-catalog.php:125
msgid "Refresh catalog"
msgstr "Actualizar catálogo"
msgstr "Actualizar el catálogo"
#: admin/views/page-catalog.php:131
msgid "Plugin"
msgstr "Plugin"
msgid "Type"
msgstr "Tipo"
msgid "Price"
msgstr "Precio"
#: admin/views/page-catalog.php:132
msgid "Version"
msgstr "Versión"
#: admin/views/page-catalog.php:133
msgid "Requires WP"
msgstr "Requiere WP"
#: admin/views/page-catalog.php:134
msgid "Requires PHP"
msgstr "Requiere PHP"
msgid "Local state"
msgstr "Estado local"
#: admin/views/page-catalog.php:135
msgid "Price"
msgstr "Precio"
#: admin/views/page-catalog.php:136
msgid "Action"
msgstr "Acción"
msgid "Premium"
msgstr "Premium"
#: admin/views/page-catalog.php:137
msgid "Status"
msgstr "Estado"
#: admin/views/page-catalog.php:239
msgid "Your site runs WordPress"
msgstr "Tu sitio funciona con WordPress"
#: admin/views/page-catalog.php:251
msgid "Your server runs PHP"
msgstr "Tu servidor funciona con PHP"
#. translators: %s: formatted price number.
#: admin/views/page-catalog.php:264
#, php-format
msgid "€%s / year"
msgstr "%s €/año"
#. translators: %d: days remaining.
#: admin/views/page-catalog.php:282
#, php-format
msgid "Subscribed — %d days left"
msgstr "Suscrito — quedan %d días"
#: admin/views/page-catalog.php:286
msgid "Subscribed"
msgstr "Suscrito"
#: admin/views/page-catalog.php:289
msgid "Payment failed"
msgstr "Pago fallido"
#: admin/views/page-catalog.php:291
msgid "Cancelled"
msgstr "Cancelada"
#: admin/views/page-catalog.php:293
msgid "Expired"
msgstr "Caducada"
#: admin/views/page-catalog.php:303
msgid "Free"
msgstr "Gratis"
msgid "Your site runs WordPress"
msgstr "Tu sitio usa WordPress"
msgid "Your server runs PHP"
msgstr "Tu servidor usa PHP"
msgid "Not installed"
msgstr "No instalado"
msgid "Installed (inactive)"
msgstr "Instalado (inactivo)"
msgid "Update available (v%1$s → v%2$s)"
msgstr "Actualización disponible (v%1$s → v%2$s)"
msgid "Up to date"
msgstr "Actualizado"
#: admin/views/page-catalog.php:309
msgid "Incompatible"
msgstr "Incompatible"
#: admin/views/page-catalog.php:312
msgid "Buy"
msgstr "Comprar"
msgid "Install (soon)"
msgstr "Instalar (próximamente)"
#: admin/views/page-catalog.php:317
msgid "Install"
msgstr "Instalar"
msgid "Update (soon)"
msgstr "Actualizar (próximamente)"
#: admin/views/page-catalog.php:319
msgid "Activate"
msgstr "Activar"
msgid "%d plugin in the catalog is not compatible with this site's WordPress or PHP version."
msgid_plural "%d plugins in the catalog are not compatible with this site's WordPress or PHP version."
msgstr[0] "%d plugin del catálogo no es compatible con la versión de WordPress o PHP de este sitio."
msgstr[1] "%d plugins del catálogo no son compatibles con la versión de WordPress o PHP de este sitio."
#: admin/views/page-catalog.php:321
msgid "Update"
msgstr "Actualizar"
#: admin/views/page-catalog.php:327
msgid "Not installed"
msgstr "No instalado"
#: admin/views/page-catalog.php:329
msgid "Installed (inactive)"
msgstr "Instalado (inactivo)"
#. translators: 1: installed version, 2: available version.
#: admin/views/page-catalog.php:334
#, php-format
msgid "Update available (v%1$s → v%2$s)"
msgstr "Actualización disponible (v%1$s → v%2$s)"
#: admin/views/page-catalog.php:340
msgid "Up to date"
msgstr "Actualizado"
#: admin/views/page-catalog.php:349
msgid "Visit website"
msgstr "Visitar el sitio web"
#. translators: %s: plugin name.
#: admin/views/page-catalog.php:349
#, php-format
msgid "(about %s)"
msgstr "(sobre %s)"
#. translators: %s: list of plugin slugs.
#: admin/views/page-catalog.php:360
#, php-format
msgid "Requires: %s"
msgstr "Requiere: %s"
#: admin/views/page-catalog.php:374
msgid "Support"
msgstr "Soporte"
#: admin/views/page-catalog.php:376
msgid ""
"Need help with a ROBOTSTXT plugin? Visit the plugin's website (the link in "
"its row above) for documentation and guides, or contact us through our "
"website — we are happy to help."
msgstr ""
"¿Necesitas ayuda con un plugin de ROBOTSTXT? Visita el sitio web del plugin "
"(el enlace en su fila de arriba) para ver la documentación y las guías, o "
"contáctanos a través de nuestro sitio web: estaremos encantados de ayudarte."
#: admin/views/page-catalog.php:379
msgid "Payments, and how it works"
msgstr "Pagos, y cómo funciona"
#: admin/views/page-catalog.php:381
msgid ""
"Free plugins install instantly at no cost. Premium plugins are annual "
"subscriptions: you pay once on our website and the subscription renews "
"automatically every year until you cancel. You can cancel at any time from "
"your ROBOTSTXT account page — access keeps working until the end of the paid "
"period. All payments are processed securely by Mollie; we never see or store "
"your card details."
msgstr ""
"Los plugins gratis se instalan al instante y sin coste. Los plugins premium "
"son suscripciones anuales: pagas una vez en nuestro sitio web y la "
"suscripción se renueva automáticamente cada año hasta que la canceles. "
"Puedes cancelarla en cualquier momento desde tu página de cuenta de "
"ROBOTSTXT; el acceso sigue funcionando hasta el final del periodo pagado. "
"Todos los pagos los procesa de forma segura Mollie; nosotros nunca vemos ni "
"guardamos los datos de tu tarjeta."
#. translators: %d: number of incompatible plugins.
#: admin/views/page-catalog.php:391
#, php-format
msgid ""
"%d plugin in the catalog is not compatible with this site's WordPress or PHP "
"version."
msgid_plural ""
"%d plugins in the catalog are not compatible with this site's WordPress or "
"PHP version."
msgstr[0] ""
"%d plugin del catálogo no es compatible con la versión de WordPress o de PHP "
"de este sitio."
msgstr[1] ""
"%d plugins del catálogo no son compatibles con la versión de WordPress o de "
"PHP de este sitio."
#. translators: 1: local WP version, 2: local PHP version.
#: admin/views/page-catalog.php:400
#, php-format
msgid "This site runs WordPress %1$s on PHP %2$s."
msgstr "Este sitio usa WordPress %1$s en PHP %2$s."
msgstr "Este sitio funciona con WordPress %1$s y PHP %2$s."
#: includes/class-robotstxt-manager-core-client.php:141
msgid "Store URL is not configured."
msgstr "La URL del store no está configurada."
msgstr "La URL de la tienda no está configurada."
#: includes/class-robotstxt-manager-core-client.php:148
msgid "API key is not configured."
msgstr "La clave API no está configurada."
#. translators: %s: HTTP transport error message.
#: includes/class-robotstxt-manager-core-client.php:158
#, php-format
msgid "Could not reach Plugins Core: %s"
msgstr "No se pudo conectar con Plugins Core: %s"
#. translators: %d: HTTP status code.
#: includes/class-robotstxt-manager-core-client.php:168
#, php-format
msgid "The store responded with HTTP %d. Check the Store URL and API key."
msgstr "La tienda respondió con HTTP %d. Comprueba la URL de la tienda y la clave API."
#: includes/class-robotstxt-manager-core-client.php:177
msgid "Connected."
msgstr "Conectado."
#: includes/class-robotstxt-manager-updater.php admin/class-robotstxt-manager-admin.php
msgid "<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Update now</a> — this is a security patch for the version this site runs, not a feature update."
msgstr "<strong>Actualización de seguridad disponible:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Actualizar ahora</a> — es un parche de seguridad para la versión que usa este sitio, no una actualización de funciones."
msgid "Security update available (v%1$s → v%2$s)"
msgstr "Actualización de seguridad disponible (v%1$s → v%2$s)"

View file

@ -2,14 +2,14 @@
# This file is distributed under the GPL-3.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Manager (by ROBOTSTXT) 0.1.3\n"
"Project-Id-Version: Manager (by ROBOTSTXT) 1.9.1\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-manager\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2026-08-12T14:17:17+00:00\n"
"POT-Creation-Date: 2026-09-23T04:51:33+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: robotstxt-manager\n"
@ -21,230 +21,454 @@ msgstr ""
#. Plugin URI of the plugin
#: robotstxt-manager.php
msgid "https://git.robotstxt.es/ROBOTSTXT/robotstxt-manager"
msgid "https://www.robotstxt.software/plugins/robotstxt-manager/"
msgstr ""
#. Description of the plugin
#: robotstxt-manager.php
msgid "Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog from a remote Plugins Core install, resolves local install/update state, and surfaces subscription health. Phase 1: read-only catalog view."
msgid "Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog from a remote Plugins Core install, resolves local install/update state, and installs, activates, and updates plugins directly from the store."
msgstr ""
#. Author of the plugin
#: robotstxt-manager.php
#: admin/class-robotstxt-manager-admin.php:48
#: admin/class-robotstxt-manager-admin.php:52
msgid "ROBOTSTXT"
msgstr ""
#. Author URI of the plugin
#: robotstxt-manager.php
msgid "https://www.robotstxt.es/"
msgid "https://www.robotstxt.software/"
msgstr ""
#: admin/class-robotstxt-manager-admin.php:47
#: admin/views/page-catalog.php:40
#: admin/class-robotstxt-manager-admin.php:51
#: admin/views/page-catalog.php:67
msgid "Manager (by ROBOTSTXT) — Plugins"
msgstr ""
#: admin/class-robotstxt-manager-admin.php:64
#: admin/class-robotstxt-manager-settings.php:207
#: admin/class-robotstxt-manager-admin.php:68
#: admin/class-robotstxt-manager-settings.php:210
msgid "You do not have sufficient permissions to access this page."
msgstr ""
#: admin/class-robotstxt-manager-admin.php:83
#: admin/class-robotstxt-manager-settings.php:364
#: admin/class-robotstxt-manager-settings.php:390
#. translators: 1: plugin name, 2: installed version, 3: patch version, 4: update URL.
#: admin/class-robotstxt-manager-admin.php:168
#: admin/views/page-catalog.php:90
#, php-format
msgid "<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Update now</a> — this is a security patch for the version this site runs, not a feature update."
msgstr ""
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-admin.php:206
#, php-format
msgid "The payment for %s failed. Update your payment method from your ROBOTSTXT account page to keep access."
msgstr ""
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-admin.php:217
#, php-format
msgid "The subscription for %s has expired, but the plugin is still active on this site. Renew from your ROBOTSTXT account page to keep receiving updates."
msgstr ""
#. translators: 1: plugin slug, 2: days remaining.
#: admin/class-robotstxt-manager-admin.php:230
#, php-format
msgid "The subscription for %1$s expires in %2$d day."
msgid_plural "The subscription for %1$s expires in %2$d days."
msgstr[0] ""
msgstr[1] ""
#: admin/class-robotstxt-manager-admin.php:251
#: admin/class-robotstxt-manager-installer.php:433
#: admin/class-robotstxt-manager-settings.php:462
#: admin/class-robotstxt-manager-settings.php:506
msgid "Insufficient permissions."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:59
#: admin/class-robotstxt-manager-admin.php:295
msgid "Too many refreshes. Please wait a minute before refreshing again."
msgstr ""
#: admin/class-robotstxt-manager-installer.php:51
#: admin/class-robotstxt-manager-installer.php:532
msgid "Plugin not found in catalog."
msgstr ""
#. translators: %s: plugin name.
#: admin/class-robotstxt-manager-installer.php:73
#, php-format
msgid "%s installed. Activate it from the list below."
msgstr ""
#. translators: %s: dependency slug.
#: admin/class-robotstxt-manager-installer.php:114
#, php-format
msgid "Could not install the required plugin %s."
msgstr ""
#. translators: %s: list of installed dependency names.
#: admin/class-robotstxt-manager-installer.php:130
#, php-format
msgid "Installed required plugins: %s."
msgstr ""
#. translators: %s: error message from WordPress.org.
#: admin/class-robotstxt-manager-installer.php:272
#, php-format
msgid "WordPress.org lookup failed: %s"
msgstr ""
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-installer.php:281
#, php-format
msgid "No download found on WordPress.org for %s."
msgstr ""
#: admin/class-robotstxt-manager-installer.php:287
#: admin/class-robotstxt-manager-installer.php:570
msgid "Could not create a temporary file for download."
msgstr ""
#. translators: %s: HTTP transport error message.
#: admin/class-robotstxt-manager-installer.php:303
#: admin/class-robotstxt-manager-installer.php:603
#, php-format
msgid "Download failed: %s"
msgstr ""
#. translators: %d: HTTP status code.
#: admin/class-robotstxt-manager-installer.php:310
#: admin/class-robotstxt-manager-installer.php:630
#, php-format
msgid "Download failed (HTTP %d)."
msgstr ""
#: admin/class-robotstxt-manager-installer.php:316
#: admin/class-robotstxt-manager-installer.php:639
msgid "The store returned an invalid file."
msgstr ""
#. translators: %s: upgrader error message.
#: admin/class-robotstxt-manager-installer.php:334
#: admin/class-robotstxt-manager-installer.php:664
#, php-format
msgid "Installation failed: %s"
msgstr ""
#. translators: %s: upgrader error message.
#: admin/class-robotstxt-manager-installer.php:334
#: admin/class-robotstxt-manager-installer.php:667
msgid "Installation failed."
msgstr ""
#. translators: %s: plugin slug.
#: admin/class-robotstxt-manager-installer.php:341
#, php-format
msgid "The downloaded plugin for %s does not have the expected folder structure."
msgstr ""
#: admin/class-robotstxt-manager-installer.php:363
#: admin/class-robotstxt-manager-installer.php:396
msgid "Plugin is not installed."
msgstr ""
#. translators: %s: plugin name (slug).
#: admin/class-robotstxt-manager-installer.php:375
#, php-format
msgid "%s activated."
msgstr ""
#. translators: %s: plugin name (slug).
#: admin/class-robotstxt-manager-installer.php:418
#, php-format
msgid "%s updated."
msgstr ""
#: admin/class-robotstxt-manager-installer.php:526
msgid "Store is not configured."
msgstr ""
#. translators: 1: HTTP status code, 2: store error message.
#: admin/class-robotstxt-manager-installer.php:624
#, php-format
msgid "Download failed (HTTP %1$d): %2$s"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:62
#: admin/views/page-settings.php:13
msgid "Manager (by ROBOTSTXT) — Settings"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:60
#: admin/class-robotstxt-manager-settings.php:63
msgid "Settings"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:75
#: admin/class-robotstxt-manager-settings.php:78
msgid "Connection"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:92
#: admin/class-robotstxt-manager-settings.php:95
msgid "Store URL"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:110
#: admin/class-robotstxt-manager-settings.php:113
msgid "API Key"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:118
#: admin/class-robotstxt-manager-settings.php:121
msgid "Cache"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:135
#: admin/class-robotstxt-manager-settings.php:138
msgid "Catalog Cache (minutes)"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:153
#: admin/class-robotstxt-manager-settings.php:156
msgid "Data on Uninstall"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:188
#: admin/class-robotstxt-manager-settings.php:191
msgid "Testing…"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:189
#: admin/class-robotstxt-manager-settings.php:274
#: admin/class-robotstxt-manager-settings.php:192
#: admin/class-robotstxt-manager-settings.php:349
msgid "Test connection"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:190
#: admin/class-robotstxt-manager-settings.php:193
msgid "Deleting…"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:191
#: admin/class-robotstxt-manager-settings.php:278
#: admin/class-robotstxt-manager-settings.php:194
#: admin/class-robotstxt-manager-settings.php:353
msgid "Delete API key"
msgstr ""
#: admin/class-robotstxt-manager-settings.php:192
#: admin/class-robotstxt-manager-settings.php:195
msgid "Delete the stored API key? The catalog and subscription data will stop working until a new key is entered."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:193
#: admin/class-robotstxt-manager-settings.php:398
#: admin/class-robotstxt-manager-settings.php:196
#: admin/class-robotstxt-manager-settings.php:515
msgid "API key deleted. Save changes to persist."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:194
#: admin/class-robotstxt-manager-settings.php:197
msgid "An unexpected error occurred."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:226
#: admin/class-robotstxt-manager-settings.php:233
msgid "You do not have sufficient permissions to manage settings."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:292
msgid "Base URL of the remote Plugins Core installation that this site will pull the plugin catalog from."
msgstr ""
#. translators: %s: last 4 characters of the stored API key.
#: admin/class-robotstxt-manager-settings.php:257
#: admin/class-robotstxt-manager-settings.php:323
#, php-format
msgid "A key is stored (last 4 characters: <code>%s</code>). Leave blank to keep the existing key; enter a new value to replace it."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:262
#: admin/class-robotstxt-manager-settings.php:328
msgid "A key is stored but could not be decoded. You can replace it by entering a new value above, or delete it with the button below."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:266
msgid "Account-level API key issued by the ROBOTSTXT store. Required to authenticate catalog and subscription requests. The value is encrypted before storage."
#. translators: %s: Registration URL.
#: admin/class-robotstxt-manager-settings.php:337
#, php-format
msgid "Account-level API key from the ROBOTSTXT store (<a href=\"%s\" target=\"_blank\" rel=\"noopener\">create your free account there to get one</a>). Optional — the free catalog works without it — but required to link your subscriptions, install premium plugins, and receive their updates. Encrypted before storage."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:298
#: admin/class-robotstxt-manager-settings.php:373
msgid "How long the catalog response from Core is cached in a transient. Default: 60 minutes. Lower values refresh more often at the cost of more requests to Core. Maximum: 1440 (24 hours)."
msgstr ""
#: admin/class-robotstxt-manager-settings.php:313
#: admin/class-robotstxt-manager-settings.php:388
msgid "Delete all plugin data when the plugin is uninstalled."
msgstr ""
#: admin/views/page-catalog.php:43
#: admin/class-robotstxt-manager-settings.php:426
msgid "The API key format is invalid. Copy the full key from your ROBOTSTXT account page."
msgstr ""
#: admin/views/page-catalog.php:70
msgid "This is the ROBOTSTXT plugin store: the catalog of plugins published by ROBOTSTXT, installable and updatable straight from your own wp-admin. Free plugins install with one click; premium plugins require an annual subscription that you purchase on our website."
msgstr ""
#: admin/views/page-catalog.php:74
msgid "Catalog refreshed."
msgstr ""
#. translators: %s: store registration URL.
#: admin/views/page-catalog.php:109
#, php-format
msgid "Create your free account at the <a href=\"%s\" target=\"_blank\" rel=\"noopener noreferrer\">ROBOTSTXT store</a> to get your personal API key. The key links your subscriptions to this site and unlocks premium plugins and updates."
msgstr ""
#. translators: %s: settings URL.
#: admin/views/page-catalog.php:53
#: admin/views/page-catalog.php:129
#, php-format
msgid "ROBOTSTXT Manager is not configured yet. <a href=\"%s\">Set the Store URL and API key</a> to see your plugin catalog."
msgstr ""
#: admin/views/page-catalog.php:63
#: admin/views/page-catalog.php:139
msgid "No plugins were returned by the ROBOTSTXT store. Check the Store URL and API key on the Settings page, or click Refresh to try again."
msgstr ""
#: admin/views/page-catalog.php:66
#: admin/views/page-catalog.php:69
#: admin/views/page-catalog.php:142
#: admin/views/page-catalog.php:145
msgid "Refresh catalog"
msgstr ""
#: admin/views/page-catalog.php:75
#: admin/views/page-catalog.php:151
msgid "Plugin"
msgstr ""
#: admin/views/page-catalog.php:76
msgid "Type"
#: admin/views/page-catalog.php:152
msgid "Version"
msgstr ""
#: admin/views/page-catalog.php:77
msgid "Price"
msgstr ""
#: admin/views/page-catalog.php:78
#: admin/views/page-catalog.php:153
msgid "Requires WP"
msgstr ""
#: admin/views/page-catalog.php:79
#: admin/views/page-catalog.php:154
msgid "Requires PHP"
msgstr ""
#: admin/views/page-catalog.php:80
msgid "Local state"
#: admin/views/page-catalog.php:155
msgid "Price"
msgstr ""
#: admin/views/page-catalog.php:81
#: admin/views/page-catalog.php:156
msgid "Action"
msgstr ""
#: admin/views/page-catalog.php:135
msgid "Premium"
#: admin/views/page-catalog.php:157
msgid "Status"
msgstr ""
#: admin/views/page-catalog.php:135
msgid "Free"
msgstr ""
#: admin/views/page-catalog.php:150
#: admin/views/page-catalog.php:267
msgid "Your site runs WordPress"
msgstr ""
#: admin/views/page-catalog.php:162
#: admin/views/page-catalog.php:279
msgid "Your server runs PHP"
msgstr ""
#: admin/views/page-catalog.php:172
#. translators: %s: formatted price number.
#: admin/views/page-catalog.php:292
#, php-format
msgid "€%s / year"
msgstr ""
#. translators: %d: days remaining.
#: admin/views/page-catalog.php:310
#, php-format
msgid "Subscribed — %d days left"
msgstr ""
#: admin/views/page-catalog.php:314
#: admin/views/page-catalog.php:340
msgid "Subscribed"
msgstr ""
#: admin/views/page-catalog.php:317
msgid "Payment failed"
msgstr ""
#: admin/views/page-catalog.php:319
msgid "Cancelled"
msgstr ""
#: admin/views/page-catalog.php:321
msgid "Expired"
msgstr ""
#: admin/views/page-catalog.php:350
msgid "Free"
msgstr ""
#: admin/views/page-catalog.php:356
msgid "Incompatible"
msgstr ""
#: admin/views/page-catalog.php:359
msgid "Buy"
msgstr ""
#: admin/views/page-catalog.php:364
msgid "Install"
msgstr ""
#: admin/views/page-catalog.php:366
msgid "Activate"
msgstr ""
#: admin/views/page-catalog.php:368
msgid "Update"
msgstr ""
#: admin/views/page-catalog.php:374
msgid "Not installed"
msgstr ""
#: admin/views/page-catalog.php:174
#: admin/views/page-catalog.php:376
msgid "Installed (inactive)"
msgstr ""
#. translators: 1: installed version, 2: security patch version.
#: admin/views/page-catalog.php:381
#, php-format
msgid "Security update available (v%1$s → v%2$s)"
msgstr ""
#. translators: 1: installed version, 2: available version.
#: admin/views/page-catalog.php:179
#: admin/views/page-catalog.php:390
#, php-format
msgid "Update available (v%1$s → v%2$s)"
msgstr ""
#: admin/views/page-catalog.php:185
#: admin/views/page-catalog.php:396
msgid "Up to date"
msgstr ""
#: admin/views/page-catalog.php:192
msgid "Incompatible"
#: admin/views/page-catalog.php:405
msgid "Visit website"
msgstr ""
#: admin/views/page-catalog.php:194
msgid "Buy"
#. translators: %s: plugin name.
#: admin/views/page-catalog.php:405
#, php-format
msgid "(about %s)"
msgstr ""
#: admin/views/page-catalog.php:196
msgid "Install (soon)"
#. translators: %s: list of plugin slugs.
#: admin/views/page-catalog.php:416
#, php-format
msgid "Requires: %s"
msgstr ""
#: admin/views/page-catalog.php:199
msgid "Update (soon)"
#: admin/views/page-catalog.php:430
msgid "Support"
msgstr ""
#: admin/views/page-catalog.php:432
msgid "Need help with a ROBOTSTXT plugin? Visit the plugin's website (the link in its row above) for documentation and guides, or contact us through our website — we are happy to help."
msgstr ""
#: admin/views/page-catalog.php:435
msgid "Payments, and how it works"
msgstr ""
#: admin/views/page-catalog.php:437
msgid "Free plugins install instantly at no cost. Premium plugins are annual subscriptions: you pay once on our website and the subscription renews automatically every year until you cancel. You can cancel at any time from your ROBOTSTXT account page — access keeps working until the end of the paid period. All payments are processed securely by Mollie; we never see or store your card details."
msgstr ""
#. translators: %d: number of incompatible plugins.
#: admin/views/page-catalog.php:214
#: admin/views/page-catalog.php:447
#, php-format
msgid "%d plugin in the catalog is not compatible with this site's WordPress or PHP version."
msgid_plural "%d plugins in the catalog are not compatible with this site's WordPress or PHP version."
@ -252,25 +476,35 @@ msgstr[0] ""
msgstr[1] ""
#. translators: 1: local WP version, 2: local PHP version.
#: admin/views/page-catalog.php:223
#: admin/views/page-catalog.php:456
#, php-format
msgid "This site runs WordPress %1$s on PHP %2$s."
msgstr ""
#: includes/class-robotstxt-manager-core-client.php:128
#: includes/class-robotstxt-manager-core-client.php:141
msgid "Store URL is not configured."
msgstr ""
#: includes/class-robotstxt-manager-core-client.php:135
#: includes/class-robotstxt-manager-core-client.php:148
msgid "API key is not configured."
msgstr ""
#. translators: %s: HTTP transport error message.
#: includes/class-robotstxt-manager-core-client.php:145
#: includes/class-robotstxt-manager-core-client.php:158
#, php-format
msgid "Could not reach Plugins Core: %s"
msgstr ""
#: includes/class-robotstxt-manager-core-client.php:154
#: includes/class-robotstxt-manager-core-client.php:167
msgid "Invalid API key. Please check your key and try again."
msgstr ""
#. translators: %d: HTTP status code.
#: includes/class-robotstxt-manager-core-client.php:175
#, php-format
msgid "The store responded with HTTP %d. Check the Store URL and API key."
msgstr ""
#: includes/class-robotstxt-manager-core-client.php:184
msgid "Connected."
msgstr ""

View file

@ -1,11 +1,11 @@
=== Manager (by ROBOTSTXT) ===
Contributors: javiercasares, robotstxt
Contributors: robotstxt, javiercasares
Tags: dashboard, catalog, updates, subscriptions, management
Requires at least: 4.4
Tested up to: 7.1
Stable tag: 0.5.3
Stable tag: 1.9.1
Requires PHP: 8.0
Version: 0.5.3
Version: 1.9.1
License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -42,11 +42,14 @@ Configure at **ROBOTSTXT → Settings**:
| Column | Description |
|---|---|
| Plugin | Name and current version from the store. Click ▸ to expand the description and website link. |
| Type | Free or Premium. |
| Price | Annual price in EUR, or — for free plugins. |
| Local state | Not installed / Installed (inactive) / Up to date / Update available (vX → vY). |
| Plugin | Name from the store. The row below each plugin carries its website link and description (always visible). |
| Version | Current version in the store. |
| Requires WP / Requires PHP | Minimum requirements, checked against this site (warning icon when not met). |
| Price | "Free", or the annual price for premium plugins. |
| Action | Install / Activate / Update / Buy, depending on local state. |
| Status | Not installed / Installed (inactive) / Up to date / Update available (vX → vY). |
Below the table: a Support section and an explanation of Payments (annual subscriptions, automatic renewal, cancellation, Mollie processing).
Click "Refresh catalog" to force a fresh fetch from Core. Install, Activate, and Update run on a classic page load (no JavaScript) and report the result as a standard admin notice.
@ -89,87 +92,33 @@ Encrypted at rest using AES-256-CBC with a key derived from your site's WordPres
== Changelog ==
= 0.5.3 =
Only the 3 last versions. The full changelog will be at changelog.txt
_Release date: 2026-08-15_
= 1.9.1 =
* Authenticated encryption (encrypt-then-MAC, matching Core 1.6.0) for the stored API key: tampered payloads fail closed; legacy-stored keys keep working and upgrade on next save.
_Release date: 2026-09-23_
= 0.5.2 =
* Maintenance: dependency audit (no updates, no CVEs), full code and security review of the security-patch feature, compatibility floors re-verified (WordPress 4.4, PHP 8.0). No runtime changes.
_Release date: 2026-08-15_
= 1.9.0 =
* Fixed: updating (and installing) a plugin from the Manager catalog page failed with a fatal or a bare "Installation failed." Two defects in the panel's upgrader path: `Plugin_Upgrader` was never loaded in the `admin-post` context (it lives in its own file since the WordPress 5.3 class split, and only `class-wp-upgrader.php` was required), and the overwrite option was passed under the key `overwrite`, which current WordPress reads as `overwrite_package` — so the existing folder was never cleared. Verified end-to-end against the live store.
_Release date: 2026-09-22_
= 0.5.1 =
* New: security-update notices (with Core 1.16.0+). When the store declares a security patch for the exact version your site runs, a red "Update now" notice appears on the Plugins screen and the Manager catalog, and the update installs only that patch — your site is never pushed across feature versions by a security fix.
* The WordPress update badge, `wp plugin update`, and auto-updates also target the declared patch.
_Release date: 2026-08-15_
= 1.8.1 =
* Stabilization release: full code/security audit, compatibility scans, and documentation alignment. Real floors declared: WordPress 4.4+, PHP 8.0+ (previously declared 4.7/7.4).
* Connection test and catalog fetch now check the HTTP status code — an unauthorized or failing store reports an error instead of "Connected" / an empty catalog (non-200 responses are no longer cached).
* API-key field validates the key format before storing.
* Premium plugins are not offered as native updates when no API key is configured (they would only fail with HTTP 403).
* Domain normalization for premium package URLs strips only the literal `www.` prefix (hosts starting with "w" were mangled).
* Opt-in uninstall now also purges the WordPress update transient (premium entries carry the API key in their package URL) and drops a phantom option.
_Release date: 2026-09-22_
= 0.5.0 =
_Release date: 2026-08-14_
* Native update integration: catalog plugins now show WordPress's standard "Update available" badge and update through the regular wp-admin flow. Updates download from the ROBOTSTXT store (premium plugins authenticate via the account API key; requires Core 1.5.0+). "View details" modal data comes from the catalog. Refreshing the catalog also forces a fresh WordPress update check.
= 0.4.0 =
_Release date: 2026-08-14_
* Click-to-expand rows in the catalog: each plugin's description and a link to its website are shown in a detail row under the plugin name (CSS-only toggle, no JavaScript). Requires Core 1.4.2+ on the store.
= 0.3.1 =
_Release date: 2026-08-14_
* Default store URL is now `https://www.robotstxt.software`. Existing saved URLs are preserved.
= 0.3.0 =
_Release date: 2026-08-13_
* Classic install/activate/update flow: actions run on a full page load (`admin-post.php` with per-action-and-slug nonces) and report the outcome through standard admin notices (green success / red error), replacing the previous AJAX flow.
= 0.2.0 =
_Release date: 2026-08-13_
* Install action: free and premium plugins install directly from the catalog, authenticated with the account-level API key against Core's `/download` endpoint (requires Core 1.4.0+).
= 0.1.3 =
_Release date: 2026-08-12_
* Composer PHP requirement aligned to scan floor (>=7.4).
= 0.1.2 =
_Release date: 2026-08-12_
* Documentation refresh: readme.txt and changelog.txt aligned to the standard templates. Compatibility scan results documented.
= 0.1.1 =
_Release date: 2026-08-12_
* PHPCompatibility scan (5.68.5): real PHP floor is 7.2; project deliberately declares 8.4 to match the ecosystem.
* wp-compat scan against WordPress 4.7: zero errors. Real WP floor is ≤ 4.7; project declares 7.0 to match the ecosystem support window.
= 0.1.0 =
_Release date: 2026-08-12_
* Phase 1 scaffold.
* Fixed: pending updates were invisible on sites where the WordPress.org update check never completes (api.wordpress.org blocked/unreachable — common on many hosts). Update data is now injected whenever WordPress reads it, so pending ROBOTSTXT updates show up on the Plugins screen, the Updates page, and WP-CLI regardless of WordPress.org connectivity.
* Fixed: opt-in uninstall now also deletes the cached subscriptions transient, so no subscription data outlives the plugin when data deletion is enabled.
* Fixed: a hardcoded "Subscribed" label in the multi-license catalog pill is now translatable.
* Maintenance: development tooling updated (PHPStan max, WordPress stubs 7.1.0); compatibility floors re-verified (WordPress 4.4, PHP 8.0).
= Previous versions =
If you want to see the full changelog, visit the [changelog.txt](https://git.robotstxt.es/ROBOTSTXT/robotstxt-manager/raw/branch/main/changelog.txt) file.
If you want to see the full changelog, visit the [plugin page](https://www.robotstxt.software/plugins/robotstxt-manager/).
== Compliance ==

View file

@ -1,17 +1,19 @@
<?php
/**
* Plugin Name: Manager (by ROBOTSTXT)
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-manager
* Plugin URI: https://www.robotstxt.software/plugins/robotstxt-manager/
* Description: Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog from a remote Plugins Core install, resolves local install/update state, and installs, activates, and updates plugins directly from the store.
* Version: 0.5.3
* Version: 1.9.1
* Requires at least: 4.4
* Requires PHP: 8.0
* Update URI: https://www.robotstxt.software/plugins/robotstxt-manager/
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
* Author URI: https://www.robotstxt.software/
* License: GPL-3.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
* Text Domain: robotstxt-manager
* Domain Path: /languages
* Network: true
*
* @package Robotstxt_Manager
*/
@ -21,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
/** Plugin version. */
define( 'ROBOTSTXT_MANAGER_VERSION', '0.5.3' );
define( 'ROBOTSTXT_MANAGER_VERSION', '1.9.1' );
/** Absolute path to the plugin directory, with trailing slash. */
define( 'ROBOTSTXT_MANAGER_DIR', plugin_dir_path( __FILE__ ) );
@ -32,6 +34,14 @@ define( 'ROBOTSTXT_MANAGER_URL', plugin_dir_url( __FILE__ ) );
/** Plugin basename. */
define( 'ROBOTSTXT_MANAGER_BASENAME', plugin_basename( __FILE__ ) );
// Presence flag for the ecosystem: other ROBOTSTXT plugins (Core, Mollie…)
// check defined( 'ROBOTSTXT_MANAGER_NOTICED' ) to detect that Manager is
// active without scanning the plugin list. Guarded so a double-load or a
// conflicting definition elsewhere cannot raise a fatal error.
if ( ! defined( 'ROBOTSTXT_MANAGER_NOTICED' ) ) {
define( 'ROBOTSTXT_MANAGER_NOTICED', true );
}
// Load Composer autoloader, with a manual fallback for environments where
// composer install has not been run.
if ( file_exists( ROBOTSTXT_MANAGER_DIR . 'vendor/autoload.php' ) ) {

View file

@ -9,7 +9,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
if ( ! get_option( 'robotstxt_manager_delete_data_on_uninstall', false ) ) {
if ( ! get_site_option( 'robotstxt_manager_delete_data_on_uninstall', false ) ) {
return;
}
@ -21,10 +21,11 @@ $option_keys = array(
);
foreach ( $option_keys as $key ) {
delete_option( $key );
delete_site_option( $key );
}
delete_transient( 'robotstxt_manager_catalog' );
delete_site_transient( 'robotstxt_manager_catalog' );
delete_site_transient( 'robotstxt_manager_subscriptions' );
// Purge the WordPress update transient: premium entries carry the API key
// in their package URL. WordPress rebuilds it on the next update check.