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; } }