WIP integration for com-form

This commit is contained in:
2026-03-16 13:14:45 +01:00
parent 89b7b5e2cb
commit 4f75604fa0
8 changed files with 197 additions and 102 deletions

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
defined('ABSPATH') or die;
class Crypto {
private const VERSION = '1.1';
// --- Configuration ---
private const CIPHER = 'aes-256-gcm';
private const NONCE_LEN = 12;
private const TAG_LEN = 16;
private static function getKey(): string {
$material = (defined('CRYPTO_SALT_1') ? CRYPTO_SALT_1 : AUTH_SALT) .
(defined('CRYPTO_SALT_2') ? CRYPTO_SALT_2 : SECURE_AUTH_SALT) .
'crypto-v1';
return hash('sha256', $material, true); // 32 bytes for AES-256
}
public static function encryptSecret(?string $plaintext): ?string {
if (!is_string($plaintext) || $plaintext === '') {
return null;
}
$key = self::getKey();
$nonce = random_bytes(self::NONCE_LEN);
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER,
$key,
OPENSSL_RAW_DATA,
$nonce,
$tag,
'',
self::TAG_LEN
);
if ($ciphertext === false) {
return null;
}
// Package as: [nonce | tag | ciphertext], then base64-encode for storage
return base64_encode($nonce . $tag . $ciphertext);
}
public static function decryptSecret(?string $encoded): ?string {
if (!is_string($encoded) || $encoded === '') {
return null;
}
$blob = base64_decode($encoded, true);
if ($blob === false) {
return null;
}
if (strlen($blob) < self::NONCE_LEN + self::TAG_LEN) {
return null;
}
$nonce = substr($blob, 0, self::NONCE_LEN);
$tag = substr($blob, self::NONCE_LEN, self::TAG_LEN);
$ciphertext = substr($blob, self::NONCE_LEN + self::TAG_LEN);
$key = self::getKey();
$plaintext = openssl_decrypt(
$ciphertext,
self::CIPHER,
$key,
OPENSSL_RAW_DATA,
$nonce,
$tag,
''
);
return ($plaintext === false) ? null : $plaintext;
}
public static function makeKey(bool $random = false, string $prefix = ''): string {
if ($random) {
return $prefix . bin2hex(random_bytes(16));
}
return $prefix . self::uuid7();
}
private static function uuid7(): string {
static $lastUnixMs = null;
static $sequence = 0;
$unixMs = (int) floor(microtime(true) * 1000);
if ($unixMs === $lastUnixMs) {
$sequence = ($sequence + 1) & 0x3FFF;
if ($sequence === 0) {
$unixMs++;
}
} else {
$sequence = random_int(0, 0x3FFF);
$lastUnixMs = $unixMs;
}
$time_high = ($unixMs >> 16) & 0xFFFFFFFF;
$time_low = $unixMs & 0xFFFF;
$time_hi_and_version = ($time_low & 0x0FFF) | (0x7 << 12);
$clock_seq_hi_and_reserved = ($sequence & 0x3FFF) | 0x8000;
$randHex = bin2hex(random_bytes(6));
return sprintf(
'%08x-%04x-%04x-%04x-%012s',
$time_high,
$time_low,
$time_hi_and_version,
$clock_seq_hi_and_reserved,
$randHex
);
}
}

View File

@@ -0,0 +1,135 @@
<?php
defined('ABSPATH') or die;
/**
* Centralized logging and admin notices.
*/
class Logger {
static $source = "plugin";
static $clear_text = "Dismiss";
static $namespace = "logger";
// Prevents hooks from being registered multiple times
private static $initialized = false;
const MAX_ERRORS = 5;
public static function init($source, $clear_text, $namespace) {
self::$source = $source;
self::$clear_text = $clear_text;
self::$namespace = $namespace;
if ( ! self::$initialized ) {
add_action( 'admin_notices', [ self::class, 'render_admin_notice' ] );
add_action( 'admin_init', [ self::class, 'handle_admin_notice_dismiss' ] );
self::$initialized = true;
}
}
/**
* Log an error and optionally raise an admin notice.
*/
public static function log($message, array $context = [], $raise_notice = true) {
if ( $raise_notice ) {
$errors = get_option( self::$namespace . '_errors', [] );
if ( ! is_array( $errors ) ) {
$errors = [];
}
$errors[] = [
'message' => $message,
'context' => $context,
'time' => wp_date( 'Y-m-d H:i:s' )
];
if ( count( $errors ) > self::MAX_ERRORS ) {
$errors = array_slice( $errors, -self::MAX_ERRORS );
}
update_option( self::$namespace . '_errors', $errors, false );
}
$context_str = '';
if ( ! empty($context) ) {
$context_str = ' | ' . wp_json_encode($context);
}
error_log('['. self::$namespace .'] ' . $message . $context_str);
}
/**
* Show latest errors in the admin UI.
*/
public static function render_admin_notice() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$errors = get_option( self::$namespace . '_errors' );
if ( empty( $errors ) || ! is_array( $errors ) ) {
return;
}
$nonce_action = self::$namespace . '_clear_error';
$nonce_name = self::$namespace . '_error_nonce';
$dismiss_url = wp_nonce_url(
add_query_arg( $nonce_action, '1' ),
$nonce_action,
$nonce_name
);
echo '<div class="notice notice-error">';
printf( '<p><strong>%s:</strong></p>', esc_html( self::$source ) );
echo '<ul style="list-style-type: disc; margin-left: 20px;">';
foreach ( $errors as $error ) {
$message = esc_html( $error['message'] ?? 'Unknown error' );
$time = ! empty( $error['time'] ) ? esc_html( $error['time'] ) : '';
$context = '';
if ( ! empty( $error['context'] ) ) {
$context = '<pre style="white-space:pre-wrap;word-break:break-word;margin-top:4px;background:rgba(0,0,0,0.05);padding:8px;">' .
esc_html( wp_json_encode( $error['context'], JSON_PARTIAL_OUTPUT_ON_ERROR ) ) .
'</pre>';
}
printf(
'<li>%s%s%s</li>',
$message,
$time ? ' <em>(' . $time . ')</em>' : '',
$context
);
}
echo '</ul>';
printf(
'<p><a href="%s" class="button">%s</a></p>',
esc_url( $dismiss_url ),
esc_html( self::$clear_text )
);
echo '</div>';
}
/**
* Clear error notice when user dismisses via link.
*/
public static function handle_admin_notice_dismiss() {
$nonce_action = self::$namespace . '_clear_error';
$nonce_name = self::$namespace . '_error_nonce';
if ( ! isset( $_GET[$nonce_action], $_GET[$nonce_name] ) ) {
return;
}
if ( ! wp_verify_nonce( $_GET[$nonce_name], $nonce_action ) ) {
return;
}
// Delete the entire array of errors
delete_option( self::$namespace . '_errors' );
wp_safe_redirect( remove_query_arg( [ $nonce_action, $nonce_name ] ) );
exit;
}
}

View File

@@ -0,0 +1,130 @@
<?php
//
// a WP_Query subclass which adds a Relevance score and sorts by it
// https://github.com/GreenInfo-Network/WP_Query_WithRelevance
//
if (! defined( 'WPINC')) die;
class WP_Query_WithRelevance extends WP_Query {
//
// search field DEFAULT weights
// the $args passed to this Query may/should specify weightings for specific taxonomies, meta keys, etc.
// but these act as defaults
//
var $DEFAULT_WEIGHTING_TITLE_KEYWORD = 1.0;
var $DEFAULT_WEIGHTING_CONTENT_KEYWORD = 0.25;
var $DEFAULT_WEIGHTING_TAXONOMY_RATIO = 10.0;
//
// constructor
// performs a standard WP_Query but then postprocesses to add relevance, then sort by that relevance
//
public function __construct($args = array()) {
// stow and unset the orderby param
// cuz it's not a real DB field that can be used
if ($args['orderby'] === 'relevance') {
$this->orderby = $args['orderby'];
$this->order = 'DESC';
unset($args['orderby']);
unset($args['order']);
}
// perform a typical WP_Query
// then if we weren't using a relevance sorting, we're actually done
$this->process_args($args);
parent::__construct($args);
if (! $this->orderby) return;
// okay, we're doing relevance postprocessing
$this->initialize_relevance_scores();
$this->score_keyword_relevance();
$this->score_taxonomy_relevance();
$this->orderby_relevance();
// debugging; you can display this at any time to just dump the list of results
//$this->display_results_so_far();
}
// initializing all posts' relevance scores to 0
private function initialize_relevance_scores() {
foreach ($this->posts as $post) {
$post->relevance = 0;
}
}
private function score_keyword_relevance() {
if (! $this->query_vars['s']) return; // no keyword string = this is a noop
$weight_title = @$this->query_vars['relevance_scoring']['title_keyword'];
$weight_content = @$this->query_vars['relevance_scoring']['content_keyword'];
if ($weight_title == NULL) $weight_title = $this->DEFAULT_WEIGHTING_TITLE_KEYWORD;
if ($weight_content == NULL) $weight_content = $this->DEFAULT_WEIGHTING_CONTENT_KEYWORD;
// print "score_keyword_relevance() Title keyword weight {$weight_title}\n";
// print "score_keyword_relevance() Content keyword weight {$weight_content}\n";
$words = strtoupper(trim($this->query_vars['s']));
$words = preg_split('/\s+/', $words);
foreach ($this->posts as $post) {
$title = strtoupper($post->post_title);
$content = strtoupper($post->post_content);
foreach ($words as $thisword) {
$post->relevance += substr_count($title, $thisword) * $weight_title;
$post->relevance += substr_count($content, $thisword) * $weight_content;
}
}
}
private function score_taxonomy_relevance() {
if (!isset($this->query_vars['tax_query']) || !is_array($this->query_vars['tax_query'])) return; // no taxo query = skip it
// taxonomy relevance is only calculated for IN-list operations
// for other types of queries, all posts match that value and further scoring would be pointless
// go over each taxo and each post
// increase the post relevance, based on number of terms it has in common with the terms we asked about
// this is done one taxo at a time, so we can match terms by ID, by slug, or by name ... and so we can apply individual weighting by that taxo
foreach ($this->query_vars['tax_query'] as $taxo) {
if (strtoupper($taxo['operator']) !== 'IN' or ! is_array($taxo['terms'])) continue; // not a IN-list query, so relevance scoring is not useful for this taxo
$taxoslug = $taxo['taxonomy'];
$whichfield = $taxo['field'];
$wantterms = $taxo['terms'];
$taxo_weighting = @$this->query_vars['relevance_scoring']['tax_query'][$taxoslug];
if ($taxo_weighting === NULL) $taxo_weighting = $this->DEFAULT_WEIGHTING_TAXONOMY_RATIO;
// print "score_taxonomy_relevance() Taxo {$taxoslug} weight {$taxo_weighting}\n";
foreach ($this->posts as $post) {
// find number of terms in common between this post and this taxo's list
$terms_in_common = 0;
$thispostterms = get_the_terms($post->ID, $taxo['taxonomy']);
foreach ($thispostterms as $hasthisterm) {
if (in_array($hasthisterm->{$whichfield}, $wantterms)) $terms_in_common += 1;
}
// express that terms-in-common as a percentage, and add to this post's relevance score
$ratio = (float) $terms_in_common / sizeof($wantterms);
$post->relevance += ($ratio * $ratio * $taxo_weighting);
}
}
}
private function orderby_relevance() {
usort($this->posts, array($this, 'usort_sorting'));
}
private function display_results_so_far () { // for debugging
foreach ($this->posts as $post) {
printf('%d %s = %.1f' . "\n", $post->ID, $post->post_title, $post->relevance) . "\n";
}
}
private function usort_sorting ($p, $q) {
// we force DESC and only trigger if orderby==='relevance' so we can keep this simple
if ($p->relevance == $q->relevance) return 0;
return $p->relevance > $q->relevance ? -1 : 1;
}
}