ga_service = $args['ga_service']; } } public function get_columns() { // The 'include' header carries an HTML rather than a // plain label so the column heading itself is the master // toggle. WP_List_Table emits column display names without // escaping, so the markup survives intact; 51D.js wires the // toggle to the per-row checkboxes by id. return [ 'property_name' => __('Property Name'), 'custom_dimension_name' => __('Custom Dimension'), 'include' => '' . '' . esc_html__('Include', 'fiftyonedegrees') . '', ]; } public function get_sortable_columns() { return [ 'property_name' => ['property_name', true], ]; } public function prepare_items() { $result = Pipeline::$data; if (!$result) { return; } $this->_column_headers = [ $this->get_columns(), [], $this->get_sortable_columns(), ]; $existing_dimensions = $this->fetch_existing_dimensions(); $passedDims = get_option(Options::GA_DIMENSIONS); $results = []; $ga_results = []; foreach ($result['properties'] as $dataKey => $properties) { foreach ($properties as $property) { $name = strtolower((string) $property['name']); if (strpos($name, 'javascript') !== false || strpos($name, 'setheader') !== false ) { continue; } $datakey = strtolower((string) $dataKey); $defaultParam = $this->derive_parameter_name($datakey, $name); // The admin's stored mapping may already point this // 51Degrees property at a non-default parameter // name (e.g. one they already created on GA4 and // selected from the dropdown last time). $selectedParam = isset($passedDims[$name]) ? (string) $passedDims[$name] : $defaultParam; $listbox = $this->get_custom_dimension_listbox( $existing_dimensions, $defaultParam, $selectedParam ); $matchedParam = $this->get_ga_custom_dimension_parameter_name( $existing_dimensions, $selectedParam ); // NOTE: `custom_dimension_options` carries the // listbox option list (an array of strings), not a // single name. display_rows iterates it as a JS // array. The legacy key `custom_dimension_name` // suggested a scalar; the rename eliminates the // confusion. $results[] = [ 'property_name' => $name, 'custom_dimension_options' => $listbox, ]; $ga_results[] = [ 'parameter_name' => $matchedParam !== '' ? $matchedParam : $defaultParam, 'display_name' => $this->derive_display_name($property['name']), 'property_name' => $name, 'custom_dimension_datakey' => $datakey, ]; } } usort($results, function ($a, $b) { $orderby = !empty($_GET['orderby']) ? sanitize_text_field($_GET['orderby']) : 'property_name'; $order = !empty($_GET['order']) ? sanitize_text_field($_GET['order']) : 'asc'; $cmp = strcmp((string) $a[$orderby], (string) $b[$orderby]); return $order === 'asc' ? $cmp : -$cmp; }); update_option(Options::GA_CUSTOM_DIMENSIONS_MAP, $ga_results); $this->items = $results; } public function display_rows() { $passedDims = get_option(Options::GA_DIMENSIONS); // Inclusion set (property_name => true) of ticked checkboxes. // Absent option (option === false) means "fresh, never submitted" // -> default everything to ticked so an onboarding admin sees the // familiar all-properties view. Present option (array, possibly // empty) means "the admin has interacted" -> property ticked iff // it appears as a key. Unticked properties are not stored, they // are simply absent from the set. $includedMap = get_option(Options::GA_DIMENSIONS_INCLUDED); $hasIncludedMap = is_array($includedMap); foreach ($this->items as $i => $rec) { $listBoxId = '51D_' . $rec['property_name']; $selectedPHP = $passedDims[$rec['property_name']] ?? ''; $includeName = '51D_include_' . $rec['property_name']; $isIncluded = $hasIncludedMap ? isset($includedMap[$rec['property_name']]) : true; echo ''; echo '' . esc_html($rec['property_name']) . ''; echo ''; echo '
'; ?> \n\n"; printf( '', esc_attr($includeName), checked($isIncluded, true, false), esc_attr(sprintf( /* translators: %s is the 51Degrees property name. */ __('Include %s as a Custom Dimension', 'fiftyonedegrees'), $rec['property_name'] )) ); echo "\n"; } } /** * Derives the default GA4 parameter_name for a 51Degrees * property and coerces the result into the GA4 Admin API's * `[A-Za-z][A-Za-z0-9_]{0,39}` shape so a long property name * or one starting with a digit cannot leak through to the * create() call and bounce as INVALID_ARGUMENT mid-batch. * * Coercion rules: * - Lowercase both segments and join with underscore. * - Replace any character outside [a-z0-9_] with underscore. * - If the first character is not a letter (e.g. "5g" / * digit-leading), prefix with "p_" so the GA4 leading-letter * rule is satisfied without losing information. * - If the result exceeds 40 characters, truncate and append * a 4-char hex hash of the original so collisions between * two overlong names that share a prefix do not merge. */ public function derive_parameter_name($datakey, $propertyName) { $datakey = strtolower((string) $datakey); $propertyName = strtolower((string) $propertyName); if ($datakey === '' || $propertyName === '') { return ''; } $candidate = $datakey . '_' . $propertyName; // Coerce any out-of-spec character to underscore. $candidate = preg_replace('/[^a-z0-9_]/', '_', $candidate); if ($candidate === null || $candidate === '') { return ''; } // Leading char must be [A-Za-z]; we are all-lowercase by // construction, so the test reduces to ctype_alpha on the // first character only. if (!ctype_alpha($candidate[0])) { $candidate = 'p_' . $candidate; } if (strlen($candidate) > self::PARAMETER_NAME_MAX_LENGTH) { // Reserve 5 chars for `_` + 4-char hash suffix so the // truncated body keeps as much of the human-readable // prefix as possible. $suffix = substr(md5($candidate), 0, 4); $candidate = substr($candidate, 0, self::PARAMETER_NAME_MAX_LENGTH - 5) . '_' . $suffix; } return $candidate; } /** * Auto-prefixed GA4 displayName the admin sees in the GA4 * console. Matches the prefix the Ga4DimensionService uses on * create. */ public function derive_display_name($propertyName) { return FiftyOneDegreesGa4DimensionService::DISPLAY_NAME_PREFIX . (string) $propertyName; } /** * Returns the matched parameter_name from $existing_dimensions * (the list fetched from the GA4 property) when $selectedParam * is one of the existing rows. Returns '' when not present — * caller treats that as "auto-create on Enable Tracking". * * @param array> $existing_dimensions * @param string $selectedParam * @return string parameter_name when matched, '' otherwise */ public function get_ga_custom_dimension_parameter_name( array $existing_dimensions, $selectedParam ) { $selectedParam = (string) $selectedParam; if ($selectedParam === '') { return ''; } foreach ($existing_dimensions as $row) { if (isset($row['parameter_name']) && $row['parameter_name'] === $selectedParam ) { return $selectedParam; } } return ''; } /** * Builds the dropdown options for a single property row. The * derived default goes first (so a fresh property auto-selects * a sensible name) followed by every existing CD on the * property — admin can pick "create new under default name" * or "map onto an existing CD". * * @param array> $existing_dimensions * @param string $defaultParam * @param string $selectedParam * @return array */ public function get_custom_dimension_listbox( array $existing_dimensions, $defaultParam, $selectedParam ) { $options = []; if ($defaultParam !== '') { $options[] = $defaultParam; } foreach ($existing_dimensions as $row) { if (!isset($row['parameter_name'])) { continue; } $param = (string) $row['parameter_name']; if ($param === '' || in_array($param, $options, true)) { continue; } $options[] = $param; } if ($selectedParam !== '' && !in_array($selectedParam, $options, true)) { $options[] = $selectedParam; } return $options; } /** * Pulls the GA4 property's existing Custom Dimensions via the * shared Ga4DimensionService. Three failure modes are handled * explicitly: * * - Auth-error path (token revoked / scope insufficient): * surface a reconnect notice via the shared OauthNotice * channel so the admin sees a banner on the next render, * and return [] so the table still draws with the * auto-derived defaults. * - Other API errors (5xx, network timeout): swallow + log * so the render path does not white-screen on a transient * blip. Caller still draws with the defaults. * - Missing property / unauthenticated: return [] silently * (handled upstream). * * Results are cached in a per-property transient with a short * TTL so opening + tweaking the CD tab does not re-hit the * Admin API on every render. Invalidated explicitly by * ga-service after Enable Tracking succeeds (so just-created * dimensions show up immediately) and on Logout. * * @return array> */ protected function fetch_existing_dimensions() { $property_id = get_option(Options::GA_PROPERTY_ID); if (empty($property_id)) { return []; } $cache_key = self::CACHE_TRANSIENT_PREFIX . $property_id; $cached = get_transient($cache_key); if (is_array($cached)) { return $cached; } $ga_service = $this->resolve_ga_service(); $client = $ga_service->authenticate(); if (!$client) { return []; } $admin = $ga_service->get_ga4_admin_service($client); try { $rows = FiftyOneDegreesGa4DimensionService::list_custom_dimensions( $admin, $property_id ); } catch (FiftyOneDegreesGa4AuthError $e) { // Hand off to the OauthNotice channel — surfaces on the // next render of any GA tab and routes through the same // copy lookup as the OAuth callback handlers. if (class_exists('FiftyOneDegreesOauthNotice')) { FiftyOneDegreesOauthNotice::set('reconnect_required'); } return []; } catch (\Throwable $e) { error_log( '51Degrees: failed to fetch GA4 Custom Dimensions for ' . 'property ' . $property_id . ': ' . $e->getMessage() ); return []; } set_transient($cache_key, $rows, self::CACHE_TTL_SECONDS); return $rows; } /** * Service seam: tests inject a stub via the constructor `args`; * production paths get a freshly-constructed * Fiftyonedegrees_Google_Analytics. */ private function resolve_ga_service() { if ($this->ga_service !== null) { return $this->ga_service; } return new Fiftyonedegrees_Google_Analytics(); } }