load_dependencies();
$this->init_modules();
$this->register_hooks();
}
public static function get_instance(): COOKROCO_Core {
if ( null === self::$instance ) self::$instance = new self();
return self::$instance;
}
private function load_dependencies(): void {
require_once COOKROCO_PLUGIN_DIR . 'includes/class-cr-log.php';
require_once COOKROCO_PLUGIN_DIR . 'includes/class-cr-consent.php';
if ( is_admin() ) require_once COOKROCO_PLUGIN_DIR . 'admin/class-cr-admin.php';
if ( ! is_admin() ) {
require_once COOKROCO_PLUGIN_DIR . 'public/class-cr-public.php';
require_once COOKROCO_PLUGIN_DIR . 'includes/class-cr-blocker.php';
}
}
private function init_modules(): void {
$this->log = new COOKROCO_Log();
$this->consent = new COOKROCO_Consent( $this->log );
if ( is_admin() ) { new COOKROCO_Admin( $this->consent, $this->log ); new COOKROCO_Deactivate(); }
if ( ! is_admin() ) { new COOKROCO_Public( $this->consent ); new COOKROCO_Blocker(); new COOKROCO_Embeds(); }
new COOKROCO_Policy(); // Registers the [cookie_rocket_policy] shortcode (front + admin preview).
}
private function register_hooks(): void {
add_shortcode( 'cookie-rocket-preferences', [ $this, 'shortcode_preferences_button' ] );
add_shortcode( 'cookie-rocket-withdraw', [ $this, 'shortcode_withdraw_link' ] );
add_filter( 'cron_schedules', [ $this, 'add_cron_schedules' ] );
add_action( 'cookroco_log_cleanup', [ $this->log, 'cleanup_old_records' ] );
add_action( 'init', [ $this, 'load_textdomain' ] );
add_action( 'init', [ $this, 'check_db_version' ] );
add_filter( 'load_textdomain_mofile', [ $this, 'map_plugin_locale' ], 10, 2 );
add_filter( 'cookroco_always_allowed_cookies', [ $this, 'whitelist_woocommerce_cookies' ] );
}
public static function activate(): void {
// A brand-new install has no settings yet; a reactivation of an existing site already does.
// Only the genuinely new install should be offered the setup wizard (see COOKROCO_Admin).
$is_new_install = ( false === get_option( 'cookroco_settings', false ) );
self::create_database_tables();
self::set_default_options();
if ( ! wp_next_scheduled( 'cookroco_log_cleanup' ) ) wp_schedule_event( time(), 'weekly', 'cookroco_log_cleanup' );
update_option( 'cookroco_db_version', COOKROCO_DB_VERSION );
// Seed the onboarding flag only on a genuinely new install: '0' = offer the wizard once.
// Existing installs (reactivation, or an over-the-air update where this hook never runs)
// are marked onboarded elsewhere, so they are never surprised by the wizard.
if ( $is_new_install && false === get_option( 'cookroco_onboarded', false ) ) {
add_option( 'cookroco_onboarded', '0' );
}
flush_rewrite_rules();
}
public static function deactivate(): void {
wp_clear_scheduled_hook( 'cookroco_log_cleanup' );
flush_rewrite_rules();
}
public static function uninstall(): void {
global $wpdb;
// Cookie Rocket Pro embeds this core and stores its tamper-evident consent ledger in the
// SAME table and options. If Pro is present, deleting the free plugin must NOT destroy that
// shared data — Pro owns its own opt-in cleanup. Bail if Pro is active, ever ran, or merely
// sits on disk: uploading Pro and deleting the free edition before activating it is the
// ordinary way to move up, and it used to erase the settings and every consent record.
if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; }
if ( is_plugin_active( 'cookie-rocket-pro/cookie-rocket-pro.php' ) || false !== get_option( 'crp_db_version', false ) ) { return; }
if ( ! defined( 'WP_PLUGIN_DIR' ) || file_exists( WP_PLUGIN_DIR . '/cookie-rocket-pro/cookie-rocket-pro.php' ) ) { return; }
// The consent log is the site's proof of consent: it goes only when the owner asked for it
// (Settings → General → "On uninstall"). Deleting and reinstalling to fix something must
// never cost the records an audit asks for.
$settings = get_option( 'cookroco_settings', [] );
if ( ! is_array( $settings ) || empty( $settings['delete_data_on_uninstall'] ) ) { return; }
foreach ( [ 'cookroco_settings','cookroco_banner_design','cookroco_cookie_categories','cookroco_db_version','cookroco_last_scan','cookroco_policy_page_id','cookroco_privacy_page_id','cookroco_activated_at','cookroco_review_dismissed','cookroco_law_notice_dismissed','cookroco_onboarded','cookroco_custom_services','cookroco_reject_notice_dismissed','cookroco_visitor_law_notice_dismissed'] as $k ) delete_option( $k );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be a placeholder; uninstall context.
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}" . esc_sql( COOKROCO_LOG_TABLE ) . "`" );
}
private static function create_database_tables(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$t = $wpdb->prefix . COOKROCO_LOG_TABLE;
$c = $wpdb->get_charset_collate();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Schema name cannot use prepare(); dbDelta handles SQL safely.
dbDelta( "CREATE TABLE {$t} (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
session_id varchar(64) NOT NULL,
ip_hash varchar(64) NOT NULL,
country_code char(2) NOT NULL DEFAULT '',
law_applied varchar(20) NOT NULL DEFAULT 'generic',
action varchar(20) NOT NULL,
categories_accepted text NOT NULL,
policy_version varchar(20) NOT NULL DEFAULT '1.0',
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_session (session_id),
KEY idx_created (created_at),
KEY idx_action (action)
) {$c};" );
}
private static function set_default_options(): void {
// Consent lasts six months where the site's law is the GDPR (the CNIL's recommendation) and a
// year elsewhere. Records are kept a year, so they always outlive the consent they prove.
// The floating button stays off until the owner turns it on (Settings → General).
$law = self::locale_default_law();
add_option( 'cookroco_settings', [ 'default_law'=>$law,'consent_duration_days'=>( 'gdpr' === $law ? 180 : 365 ),'policy_version'=>'1.0','log_retention_days'=>365,'show_floating_button'=>false,'banner_variant'=>'bottom_bar','banner_position'=>'right','show_reject_button'=>true,'default_state'=>'opt_in','woocommerce_mode'=>'auto','gcm_enabled'=>true,'block_scripts'=>true,'block_raw_html'=>false,'block_embeds'=>true ] );
add_option( 'cookroco_banner_design', [ 'primary_color'=>'#111827','text_color'=>'#1a1a1a','background_color'=>'#ffffff','accept_button_bg'=>'#111827','accept_text_color'=>'#ffffff','reject_button_bg'=>'#6b7280','border_radius'=>'0','dark_mode'=>'auto','position'=>'bottom','theme'=>'light' ] );
add_option( 'cookroco_cookie_categories', self::get_default_categories() );
add_option( 'cookroco_activated_at', time() ); // Starts the clock for the dismissible review ask on plugin pages.
}
private static function get_default_categories(): array {
return [
'necessary' => [ 'label'=>'Strictly Necessary', 'description'=>'These cookies are essential for the site to function and cannot be disabled.', 'required'=>true, 'enabled'=>true, 'order'=>1 ],
'functional' => [ 'label'=>'Functional & Preferences', 'description'=>'They let the site remember your choices and provide enhanced functionality.', 'required'=>false, 'enabled'=>false, 'order'=>2 ],
'analytics' => [ 'label'=>'Statistics & Analytics', 'description'=>'They help us understand how visitors interact with the website.', 'required'=>false, 'enabled'=>false, 'order'=>3 ],
'marketing' => [ 'label'=>'Marketing & Advertising', 'description'=>'Used by advertising networks to show you relevant ads.', 'required'=>false, 'enabled'=>false, 'order'=>4 ],
'personalization' => [ 'label'=>'Personalization', 'description'=>'They allow personalized content based on your browsing behavior.', 'required'=>false, 'enabled'=>false, 'order'=>5 ],
'social' => [ 'label'=>'Social Media & Embeds', 'description'=>'Set by social media services embedded in our pages.', 'required'=>false, 'enabled'=>false, 'order'=>6 ],
];
}
/**
* Default category labels and descriptions, registered for translation. Not executed —
* present so WP-CLI make-pot extracts these strings, letting the built-in categories be
* localized (es_ES, pt_BR, …). The banner renders them through translation at runtime;
* custom categories fall back to their stored text.
*/
private static function register_category_strings(): void {
__( 'Strictly Necessary', 'cookie-rocket' );
__( 'These cookies are essential for the site to function and cannot be disabled.', 'cookie-rocket' );
__( 'Functional & Preferences', 'cookie-rocket' );
__( 'They let the site remember your choices and provide enhanced functionality.', 'cookie-rocket' );
__( 'Statistics & Analytics', 'cookie-rocket' );
__( 'They help us understand how visitors interact with the website.', 'cookie-rocket' );
__( 'Marketing & Advertising', 'cookie-rocket' );
__( 'Used by advertising networks to show you relevant ads.', 'cookie-rocket' );
__( 'Personalization', 'cookie-rocket' );
__( 'They allow personalized content based on your browsing behavior.', 'cookie-rocket' );
__( 'Social Media & Embeds', 'cookie-rocket' );
__( 'Set by social media services embedded in our pages.', 'cookie-rocket' );
}
/**
* Suggest the compliance framework matching the site locale: es_MX → LFPDPPP,
* pt_BR → LGPD, EU/UK locales → GDPR, anything else → generic. Used as the
* default on activation and by the one-click suggestion on plugin pages.
*/
public static function locale_default_law(): string {
$locale = get_locale();
if ( 'es_MX' === $locale ) return 'lfpdppp';
if ( 'pt_BR' === $locale ) return 'lgpd';
$lang = strtolower( (string) strtok( $locale, '_' ) );
if ( 'es' === $lang ) return 'es_ES' === $locale ? 'gdpr' : 'generic';
if ( 'pt' === $lang ) return 'pt_PT' === $locale ? 'gdpr' : 'generic';
if ( 'en' === $lang ) return 'en_GB' === $locale ? 'gdpr' : 'generic';
$eu_langs = [ 'de','fr','it','nl','pl','da','sv','fi','el','cs','ro','hu','bg','sk','sl','hr','lt','lv','et','ga','mt' ];
return in_array( $lang, $eu_langs, true ) ? 'gdpr' : 'generic';
}
public function load_textdomain(): void {
// phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- Source strings are English; we ship bundled es_ES and pt_BR translations in /languages, so this explicit load is required (not just the wordpress.org language packs).
load_plugin_textdomain( 'cookie-rocket', false, dirname( plugin_basename( COOKROCO_PLUGIN_FILE ) ) . '/languages' );
$this->supplement_textdomain();
}
/** Internal domain the bundled catalog lives in, so it can be consulted without competing. */
private const SUPPLEMENT_DOMAIN = 'cookie-rocket-bundled';
/**
* Load the bundled catalog into a SEPARATE domain, drawn on only for the strings the
* wp.org language pack does not carry.
*
* The problem: load_plugin_textdomain() tries WP_LANG_DIR/plugins/ first and RETURNS as
* soon as it loads one, so the catalog shipped inside the plugin is never loaded. When
* the wp.org pack is incomplete, whatever it lacks comes out in English. Seen on a real
* site: es_MX carries 423 of 556 strings, and among the missing ones is the name of a
* consent-panel category — a legal document with one line in English.
* map_plugin_locale() does not cover it: it only acts when the requested .mo does NOT exist.
*
* Why a separate domain rather than loading both into one: in either order the bundled
* catalog ALWAYS wins, because holding the complete catalog it covers everything and
* leaves the locale's inert — which replaces the regional wording with another variant.
* Consulted apart, and only when a string comes back untranslated, filling gaps can never
* override what the locale does provide.
*/
private function supplement_textdomain(): void {
$locale = determine_locale();
$dir = plugin_dir_path( COOKROCO_PLUGIN_FILE ) . 'languages/';
$own_file = $dir . 'cookie-rocket-' . $locale . '.mo';
if ( file_exists( $own_file ) ) {
$catalog_file = $own_file;
} elseif ( 0 === strpos( $locale, 'es' ) ) {
$catalog_file = $dir . 'cookie-rocket-es_ES.mo'; // es_MX, es_CO, es_AR…
} elseif ( 0 === strpos( $locale, 'pt' ) ) {
$catalog_file = $dir . 'cookie-rocket-pt_BR.mo'; // pt_PT, pt_AO…
} else {
return;
}
if ( ! file_exists( $catalog_file ) || ! load_textdomain( self::SUPPLEMENT_DOMAIN, $catalog_file, $locale ) ) {
return;
}
// The filters are hooked only when a supplement is loaded: on an English site, or in a
// language we do not bundle, this costs absolutely nothing.
add_filter( 'gettext', [ $this, 'fill_missing_translation' ], 10, 3 );
add_filter( 'gettext_with_context', [ $this, 'fill_missing_translation_ctx' ], 10, 4 );
add_filter( 'ngettext', [ $this, 'fill_missing_plural' ], 10, 5 );
add_filter( 'ngettext_with_context', [ $this, 'fill_missing_plural_ctx' ], 10, 6 );
}
/**
* Last resort for a string that came back UNTRANSLATED: look it up in the bundled one.
* The domain guard cuts the recursion, because translate() fires the filter again with
* SUPPLEMENT_DOMAIN and it returns immediately there.
*/
public function fill_missing_translation( $translation, $text, $domain ) {
if ( 'cookie-rocket' !== $domain || $translation !== $text ) {
return $translation; // not ours, or already translated: left alone
}
// phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralDomain -- fallback lookup of a string that already went through __() with its literal; see supplement_textdomain().
$fallback = translate( $text, self::SUPPLEMENT_DOMAIN );
return $fallback !== $text ? $fallback : $translation;
}
/** Same as the previous one, for strings with context — _x(). */
public function fill_missing_translation_ctx( $translation, $text, $context, $domain ) {
if ( 'cookie-rocket' !== $domain || $translation !== $text ) {
return $translation;
}
// phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralContext,WordPress.WP.I18n.NonSingularStringLiteralDomain -- same fallback, for _x().
$fallback = translate_with_gettext_context( $text, $context, self::SUPPLEMENT_DOMAIN );
return $fallback !== $text ? $fallback : $translation;
}
/**
* The same for plurals. Without this, _n() strings fall outside the rescue: the gettext
* filter does not see them, so in a locale with an incomplete pack they come out in
* English even when the bundled catalog does carry them.
*
* $translation arrives already resolved to singular or plural according to $number, so it
* is compared against the form WordPress would have returned with no translation.
*/
public function fill_missing_plural( $translation, $singular, $plural, $number, $domain ) {
if ( 'cookie-rocket' !== $domain ) {
return $translation;
}
$untranslated = ( 1 === (int) $number ) ? $singular : $plural;
if ( $translation !== $untranslated ) {
return $translation; // already translated
}
// WordPress does not expose translate_plural() as a global function: the resolution
// lives in the translations object, which is what _n() uses internally. If the
// supplement domain is not loaded it returns NOOP_Translations and the string is untouched.
$fallback = get_translations_for_domain( self::SUPPLEMENT_DOMAIN )->translate_plural( $singular, $plural, $number );
return $fallback !== $untranslated ? $fallback : $translation;
}
/** Same, for plurals with context — _nx(). */
public function fill_missing_plural_ctx( $translation, $singular, $plural, $number, $context, $domain ) {
if ( 'cookie-rocket' !== $domain ) {
return $translation;
}
$untranslated = ( 1 === (int) $number ) ? $singular : $plural;
if ( $translation !== $untranslated ) {
return $translation;
}
$fallback = get_translations_for_domain( self::SUPPLEMENT_DOMAIN )->translate_plural( $singular, $plural, $number, $context );
return $fallback !== $untranslated ? $fallback : $translation;
}
/**
* Serve the bundled translations to every Spanish / Portuguese variant (es_MX, es_CO, pt_PT, …).
*
* Must filter the .mo PATH, not the locale: since WP 6.7 translations are held by
* WP_Translation_Controller under the locale passed to load_textdomain(), while __()
* looks them up under determine_locale(). Filtering 'plugin_locale' would file the
* catalog under es_ES and leave es_MX lookups unresolved.
*/
public function map_plugin_locale( string $mofile, string $domain ): string {
if ( 'cookie-rocket' !== $domain || file_exists( $mofile ) ) {
return $mofile;
}
$locale = substr( basename( $mofile, '.mo' ), strlen( $domain ) + 1 );
if ( 0 === strpos( $locale, 'es' ) ) {
$bundled = 'es_ES';
} elseif ( 0 === strpos( $locale, 'pt' ) ) {
$bundled = 'pt_BR';
} else {
return $mofile;
}
$fallback = dirname( $mofile ) . '/' . $domain . '-' . $bundled . '.mo';
return file_exists( $fallback ) ? $fallback : $mofile;
}
public function check_db_version(): void {
if ( version_compare( get_option( 'cookroco_db_version', '0' ), COOKROCO_DB_VERSION, '<' ) ) { self::create_database_tables(); update_option( 'cookroco_db_version', COOKROCO_DB_VERSION ); }
// Self-heal: if activation did not populate the options (for example the plugin was
// synced by files without firing the activation hook), create them now so the banner has
// valid categories and "Accept all" can grant/reactivate the script categories.
// add_option is idempotent.
if ( ! get_option( 'cookroco_cookie_categories' ) ) { self::set_default_options(); }
if ( ! get_option( 'cookroco_activated_at' ) ) { add_option( 'cookroco_activated_at', time() ); } // Updates from <2.12: start the review-ask clock now, not retroactively.
// Self-heal the weekly log purge. Activation is not the only way in: moving from the free
// edition to Pro deactivates the free plugin AFTER Pro is active, and that deactivation
// clears this very event; an over-the-air update never runs the activation hook at all.
// Without the event the consent log is never trimmed to the retention the site promises.
if ( ! wp_next_scheduled( 'cookroco_log_cleanup' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'weekly', 'cookroco_log_cleanup' ); }
}
public function add_cron_schedules( array $s ): array { if ( ! isset( $s['weekly'] ) ) { $s['weekly'] = [ 'interval'=>WEEK_IN_SECONDS,'display'=>__( 'Once a week', 'cookie-rocket' ) ]; } return $s; } // Only add 'weekly' if absent — core registers it (translated) since WP 5.4; don't clobber it.
public function shortcode_preferences_button( $atts = [] ): string {
$atts = shortcode_atts( [ 'label'=>__('Manage cookies','cookie-rocket'),'class'=>'' ], $atts );
// No onclick: a strict CSP rejects it and the button goes dead. The JS listens for it by its class.
return sprintf( '', esc_attr($atts['class']), esc_html($atts['label']) );
}
public function shortcode_withdraw_link( $atts = [] ): string {
$atts = shortcode_atts( [ 'label'=>__('Withdraw my consent','cookie-rocket'),'class'=>'' ], $atts );
return sprintf( '%s', esc_attr($atts['class']), esc_html($atts['label']) );
}
public function whitelist_woocommerce_cookies( array $cookies ): array {
if ( class_exists('WooCommerce') ) $cookies = array_merge( $cookies, ['woocommerce_cart_hash','woocommerce_items_in_cart','wp_woocommerce_session_*','woocommerce_recently_viewed'] );
return $cookies;
}
}