$fingerprint,'stored'=>$stored); } } return $contexts; } public static function types($types) { $types['relationship'] = __('Related items (searchable multiple)', 'customfields-com'); return $types; } private static function error($message) { return new \WP_Error('cf_relationship', $message); } public static function hash($value) { return hash('sha256', wp_json_encode($value)); } public static function schema($field, $raw, $depth = 0) { if (is_wp_error($field) || $field['type'] !== 'relationship') { return $field; } $kind = $raw['relationship_object'] ?? 'post'; if (!in_array($kind, array('post','user','term'), true)) { return self::error(__('Relationships support posts, users or taxonomy terms.', 'customfields-com')); } $field['relationship_object'] = $kind; foreach (array('relationship_post_types'=>array('post'),'relationship_taxonomies'=>array('category')) as $key=>$default) { $value = $raw[$key] ?? $default; if (is_string($value)) { $value = preg_split('/[\s,]+/', trim($value), -1, PREG_SPLIT_NO_EMPTY); } if (!is_array($value) || count($value) > 30 || !$value) { return self::error(__('Choose 1–30 content type or taxonomy names.', 'customfields-com')); } foreach ($value as $slug) { if (!is_string($slug) || !preg_match('/^[a-z][a-z0-9_-]{0,31}$/D', $slug) || strpos($slug,'wp_') === 0 || in_array($slug,array('revision','attachment','nav_menu_item','custom_css','customize_changeset'),true)) { return self::error(__('Use editable content type and taxonomy names.', 'customfields-com')); } } $field[$key] = array_values(array_unique($value)); } $maximum = $raw['relationship_max'] ?? 20; if (filter_var($maximum, FILTER_VALIDATE_INT) === false || (int)$maximum < 1 || (int)$maximum > self::MAX_ITEMS) { return self::error(__('Relationship limit must be 1–100.', 'customfields-com')); } $field['relationship_max'] = (int)$maximum; $inverse = $raw['relationship_inverse'] ?? ''; if (!is_string($inverse) || ($inverse !== '' && (!preg_match('/^field_[a-zA-Z0-9_-]{1,80}$/D', $inverse) || $depth !== 0))) { return self::error(__('Reciprocal relationships require a top-level field key. Nested fields cannot maintain inverse edges.', 'customfields-com')); } $field['relationship_inverse'] = $inverse; if (!isset($raw['default']) || $raw['default'] === '') { $field['default'] = array(); } elseif (is_string($raw['default'])) { $field['default'] = json_decode($raw['default'], true); } if ($inverse !== '' && $field['default'] !== array()) { return self::error(__('Reciprocal fields must start empty; defaults cannot create saved inverse edges.', 'customfields-com')); } return $field; } public static function in_scope($id, $field, $permission = true) { $kind = $field['relationship_object']; if ($kind === 'post') { $post = get_post($id); return $post && in_array($post->post_type,$field['relationship_post_types'],true) && !in_array($post->post_status,array('trash','auto-draft'),true) && (!$permission || current_user_can('read_post',$id)); } if ($kind === 'user') { return (bool)get_userdata($id) && (!$permission || current_user_can('list_users') || get_current_user_id() === (int)$id); } $term = get_term($id); $taxonomy = $term && !is_wp_error($term) ? get_taxonomy($term->taxonomy) : null; return $taxonomy && in_array($term->taxonomy,$field['relationship_taxonomies'],true) && (!$permission || current_user_can($taxonomy->cap->assign_terms)); } public static function sanitize($clean, $field, $raw, $context = array()) { if (($field['type'] ?? '') !== 'relationship') { return $clean; } $stored = !empty($context['has_stored_value']) ? ($context['stored_value'] ?? array()) : ($field['default'] ?? array()); $trusted = !empty($field['_validating_default']) || !empty($field['_checking_storage']); if ($raw === '' || $raw === null) { $raw = array(); } if (!is_array($raw)) { return self::error(__('Expected an ordered list of related item IDs.', 'customfields-com')); } if (isset($raw['__fingerprint'])) { $expected = $raw['__fingerprint']; unset($raw['__fingerprint']); if (!$trusted && (!is_string($expected) || !hash_equals(self::hash($stored), $expected))) { return self::error(__('These relationships changed elsewhere. Reload before replacing them.', 'customfields-com')); } } unset($raw['__present']); if (count($raw) > ($field['relationship_max'] ?? 20)) { return self::error(__('Too many related items.', 'customfields-com')); } $out = array(); foreach ($raw as $key=>$id) { if (!ctype_digit((string)$key) || !is_scalar($id) || !preg_match('/^[1-9][0-9]*$/D',(string)$id) || (float)$id > PHP_INT_MAX) { return self::error(__('Related item IDs must be positive integers.', 'customfields-com')); } $id = (int)$id; // Preserve an existing inaccessible link without revealing its label or granting a new selection. $retained = !$trusted && !empty($context['has_stored_value']) && in_array($id,is_array($stored)?$stored:array(),true); if (empty($field['_checking_storage']) && ((!self::in_scope($id,$field,false) && !$retained) || (!$trusted && !self::in_scope($id,$field,true) && !$retained))) { return self::error(__('A selected item is unavailable or outside this field’s allowed scope.', 'customfields-com')); } if (in_array($id,$out,true)) { return self::error(__('Each related item may appear only once.', 'customfields-com')); } $out[] = $id; } if (!$trusted && isset($context['object_kind'],$context['object_id'])) { self::$expected[$context['object_kind'].':'.$context['object_id'].':'.$field['name']] = self::hash($stored); } return $out; } public static function storage($error, $value, $field) { if (($field['type'] ?? '') !== 'relationship') { return $error; } $field['_checking_storage'] = true; $field['required'] = false; $checked = self::sanitize(null,$field,$value); if (!is_wp_error($checked) && $checked !== $value) { return self::error(__('Stored relationships need a canonical ordered integer-ID list before editing.','customfields-com')); } return is_wp_error($checked) ? $checked : $error; } public static function group_matches($group,$kind,$id) { if (($group['target'] ?? 'post') !== $kind) { return false; } if ($kind === 'post') { return Editor::matches($group,get_post($id)); } if ($kind === 'user') { return (bool)get_userdata($id); } if ($kind === 'term') { $term=get_term($id); return $term && !is_wp_error($term) && in_array($term->taxonomy,$group['taxonomies']??array(),true); } return false; } public static function group_schema($group,$raw) { if (is_wp_error($group)) { return $group; } foreach ($group['fields'] as $field) { if (($field['type']??'') === 'relationship' && !empty($field['relationship_inverse']) && !in_array($group['target'],array('post','user','term'),true)) { return self::error(__('Reciprocal fields require a post, user or term group.','customfields-com')); } } return $group; } public static function find($key,$kind = null,$id = 0) { $found=null; foreach (customfields_get_groups() as $group) { if ($kind !== null && !self::group_matches($group,$kind,$id)) { continue; } foreach ($group['fields'] as $field) { if ($field['key'] === $key && $field['type'] === 'relationship') { if ($found !== null) { return null; } $found=array('group'=>$group,'field'=>$field); } } } return $found; } /** Called within the caller's transaction. Failure aborts forward and reverse values together. */ public static function synchronize($group,$kind,$id,$updates,$before) { foreach ($group['fields'] as $field) { if ($field['type'] !== 'relationship' || !array_key_exists($field['name'],$updates)) { continue; } $name=$field['name']; $old=$before[$name] ?? array(); $new=$updates[$name]; if (!is_array($old) || !is_array($new)) { throw new \RuntimeException(__('Relationship storage is incompatible. Nothing was changed.','customfields-com')); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. $expected=self::$expected[$kind.':'.$id.':'.$name]??null; unset(self::$expected[$kind.':'.$id.':'.$name]); if ($expected !== null && !hash_equals($expected,self::hash($old))) { throw new \RuntimeException(__('Relationships changed during this save. Reload before replacing them.','customfields-com')); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. if (empty($field['relationship_inverse'])) { continue; } if (!in_array($kind,array('post','user','term'),true)) { throw new \RuntimeException(__('Reciprocal relationships require a post, user or term owner.','customfields-com')); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. $targets=array_unique(array_merge($old,$new)); sort($targets,SORT_NUMERIC); foreach ($targets as $target) { $added=in_array($target,$new,true); $was=in_array($target,$old,true); $target_kind=$field['relationship_object']; if ($added && $target_kind===$kind && (int)$target===(int)$id) { throw new \RuntimeException(__('Reciprocal fields cannot link an object to itself. Use a one-way relationship for self references.','customfields-com')); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. if ($added === $was) { continue; } $target_object=$target_kind==='post'?get_post($target):($target_kind==='user'?get_userdata($target):get_term($target)); if (!$added && (!$target_object || is_wp_error($target_object))) { continue; } $inverse=self::find($field['relationship_inverse'],$target_kind,$target); if (!$inverse || $inverse['field']['relationship_object'] !== $kind || ($inverse['field']['relationship_inverse'] ?? '') !== $field['key'] || !self::in_scope($id,$inverse['field'],false) || !Editor::can_edit($target_kind,$target,$inverse['field']['name'])) { throw new \RuntimeException(__('The inverse field must point back to this field and you must be allowed to edit both linked items.','customfields-com')); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. $reverse=ContentStore::locked_value($target_kind,$target,$inverse['field']['name']); $reverse=$reverse['exists']?$reverse['value']:array(); if (is_wp_error(self::storage(null,$reverse,$inverse['field']))) { throw new \RuntimeException(__('The inverse value requires a compatible schema before linking.','customfields-com')); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. $reverse=array_values(array_diff($reverse,array((int)$id))); if ($added) { $reverse[]=(int)$id; } if (count($reverse)>$inverse['field']['relationship_max'] || (!$reverse && !empty($inverse['field']['required']))) { throw new \RuntimeException(__('The inverse field’s required or maximum constraint prevents this change.','customfields-com')); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. ContentStore::write_locked($inverse['group'],$target_kind,$target,$inverse['field']['name'],$reverse); } } } /** Preload editor assets only on admin screens where these controls can appear. */ public static function admin_assets($hook) { $screen = get_current_screen(); $editor_screen = $screen && in_array($screen->base, array('post', 'profile', 'user-edit', 'term', 'edit-tags'), true); $plugin_screen = $screen && ($screen->parent_base ?? '') === 'customfields'; $options_screen = $hook === 'tools_page_customfields-pro-options' || strpos($hook, 'tools_page_customfields-pro-options-') === 0; if (!$editor_screen && !$plugin_screen && !$options_screen) { return; } self::assets(); } public static function assets() { wp_enqueue_style('customfields-content-platform',CUSTOMFIELDS_URL.'assets/content-platform.css',array(),CUSTOMFIELDS_VERSION); wp_enqueue_script('customfields-relationships', CUSTOMFIELDS_URL.'assets/relationships.js',array('wp-i18n'),CUSTOMFIELDS_VERSION,true); wp_set_script_translations('customfields-relationships', 'customfields-com', CUSTOMFIELDS_PATH . 'languages'); wp_localize_script('customfields-relationships','CustomFieldsRelationships',array('url'=>admin_url('admin-ajax.php'),'nonce'=>wp_create_nonce('customfields_relationship_search'),'userId'=>get_current_user_id())); } private static function label($id,$field) { if (!self::in_scope($id,$field,true)) { return sprintf(/* translators: %d: Object ID. */ __('Unavailable item #%d','customfields-com'),$id); } if ($field['relationship_object'] === 'post') { return get_the_title($id) ?: sprintf(/* translators: %d: Object ID. */ __('Untitled #%d','customfields-com'),$id); } if ($field['relationship_object'] === 'user') { return get_userdata($id)->display_name; } return get_term($id)->name; } public static function render($field,$value,$name) { if ($field['type'] !== 'relationship') { return; } self::assets(); $value=is_array($value)?$value:array(); echo '