From 0afb2c0046b55c123eafb4ab9ee77efa68d12463 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sat, 06 Jun 2026 17:15:31 +0000
Subject: [PATCH] =Starting the Favourites.js setup, converting previous Northeh stuff to new Registrar, fixing up Square.php integration to match

---
 inc/meta/Storage.php |  539 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 513 insertions(+), 26 deletions(-)

diff --git a/inc/meta/Storage.php b/inc/meta/Storage.php
index b2ea8ac..386b7b4 100644
--- a/inc/meta/Storage.php
+++ b/inc/meta/Storage.php
@@ -2,7 +2,7 @@
 namespace JVBase\meta;
 
 use Exception;
-use wpdb;
+use JVBase\managers\Cache;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -14,7 +14,7 @@
  */
 class Storage
 {
-	protected wpdb $wpdb;
+	protected \wpdb $wpdb;
 
 	public function __construct()
 	{
@@ -22,6 +22,10 @@
 		$this->wpdb = $wpdb;
 	}
 
+	// ─────────────────────────────────────────────────────────────
+	// Single Item Operations
+	// ─────────────────────────────────────────────────────────────
+
 	/**
 	 * Load a single field value from database
 	 */
@@ -31,6 +35,16 @@
 			return $this->getWpDefault($item, $name);
 		}
 
+		// Taxonomy fields are stored in term_relationships, not meta
+		$config = $item->getFieldConfig($name);
+		if ($config
+			&& (
+				($config['type'] ?? '') === 'taxonomy'
+				|| (($config['type']??'') === 'selector' && ($config['subtype']??'') === 'taxonomy')
+			) && !isset($config['taxonomy_type'])) {
+			return $this->getTaxonomyField($item, $config);
+		}
+
 		$metaKey = BASE . $name;
 
 		return match ($item->objectType) {
@@ -43,33 +57,62 @@
 	}
 
 	/**
-	 * Load multiple field values in a single query
+	 * Load multiple field values for single item
 	 */
 	public function getAll(Item $item, array $fieldNames): array
 	{
-		if (empty($fieldNames) || !$item->id) {
+		if (empty($fieldNames) || (!$item->id && $item->objectType !== 'options')) {
 			return [];
 		}
 
 		$defaults = Item::WP_DEFAULTS[$item->objectType] ?? [];
 		$wpFields = array_intersect($defaults, $fieldNames);
-		$metaFields = array_diff($fieldNames, $wpFields);
+
+		// Separate taxonomy fields from regular meta fields
+		$taxonomyFields = [];
+		$metaFields = [];
+		foreach (array_diff($fieldNames, $wpFields) as $name) {
+			$config = $item->getFieldConfig($name);
+			if ($config
+				&& (
+					($config['type'] ?? '') === 'taxonomy'
+					|| (($config['type']??'') === 'selector' && ($config['subtype']??'') === 'taxonomy')
+				) && (!isset($config['taxonomy_type']) || !isset($config['isReference']))) {
+				$taxonomyFields[$name] = $config;
+			} else {
+				$metaFields[] = $name;
+			}
+		}
 
 		$values = [];
 
-		// Get meta fields in bulk
 		if (!empty($metaFields)) {
 			$values = $this->bulkGetMeta($item, $metaFields);
 		}
 
-		// Get WP default fields
 		foreach ($wpFields as $name) {
 			$values[$name] = $this->getWpDefault($item, $name);
 		}
 
+		foreach ($taxonomyFields as $name => $config) {
+			$values[$name] = $this->getTaxonomyField($item, $config);
+		}
+
 		return $values;
 	}
 
+	protected function getTaxonomyField(Item $item, array $config): string
+	{
+		$taxonomy = jvbCheckBase($config['taxonomy']);
+		$terms = wp_get_object_terms($item->id, $taxonomy, ['fields' => 'ids']);
+
+		if (is_wp_error($terms) || empty($terms)) {
+			return '';
+		}
+
+		return implode(',', $terms);
+	}
+
 	/**
 	 * Save a single field
 	 */
@@ -80,22 +123,25 @@
 		}
 
 		if ($field->isTaxonomy()) {
+			error_log('Saving Taxonomy field with set_object_terms');
 			return $this->saveTaxonomyField($item, $field);
 		}
 
 		$metaKey = BASE . $field->name;
-
-		return match ($item->objectType) {
-			'post' => update_post_meta($item->id, $metaKey, $field->value) !== false,
-			'term' => update_term_meta($item->id, $metaKey, $field->value) !== false,
-			'user', 'integrations' => update_user_meta($item->id, $metaKey, $field->value) !== false,
+		$result =  match ($item->objectType) {
+			'post' => (bool)update_post_meta($item->id, $metaKey, $field->value),
+			'term' => (bool)update_term_meta($item->id, $metaKey, $field->value),
+			'user', 'integrations' => (bool)update_user_meta($item->id, $metaKey, $field->value),
 			'options' => $this->saveOption($item, $field),
 			default => false
 		};
+
+		error_log('Result: '.print_r($result, true));
+		return $result;
 	}
 
 	/**
-	 * Save all dirty fields on an item
+	 * Save all dirty fields on a single item
 	 */
 	public function save(Item $item, bool $updateTimestamp = true): bool
 	{
@@ -106,10 +152,10 @@
 		}
 
 		$this->wpdb->query('START TRANSACTION');
-
 		try {
 			foreach ($dirty as $field) {
 				if (!$this->saveField($item, $field)) {
+					error_log("Could not save field: {$field->name}");
 					throw new Exception("Failed to save field: {$field->name}");
 				}
 				$field->markClean();
@@ -118,10 +164,7 @@
 			$this->wpdb->query('COMMIT');
 
 			// Update post modified timestamp
-			if ($updateTimestamp && $item->objectType === 'post' && $item->id) {
-				wp_update_post(['ID' => $item->id]);
-			}
-
+			Cache::invalidateItem($item->objectType, $item->id);
 			$this->clearCache($item);
 
 			return true;
@@ -142,6 +185,14 @@
 	 */
 	public function delete(Item $item, string $name): bool
 	{
+		// Handle taxonomy fields
+		$config = $item->getFieldConfig($name);
+		if ($config && ($config['type'] ?? '') === 'taxonomy' && !isset($config['taxonomy_type'])) {
+			$taxonomy = jvbCheckBase($config['taxonomy']);
+			wp_set_object_terms($item->id, [], $taxonomy, false);
+			return true;
+		}
+
 		$metaKey = BASE . $name;
 
 		return match ($item->objectType) {
@@ -154,7 +205,215 @@
 	}
 
 	// ─────────────────────────────────────────────────────────────
-	// Protected helpers
+	// Bulk Operations
+	// ─────────────────────────────────────────────────────────────
+
+	/**
+	 * Save multiple Meta instances in optimized transaction
+	 * @param Meta[] $metas Array of Meta instances
+	 * @return array<int, bool> Results keyed by item ID
+	 */
+	public static function saveBulk(array $metas, bool $updateTimestamp = true): array
+	{
+		global $wpdb;
+
+		$results = [];
+		$postIdsToUpdate = [];
+
+		$wpdb->query('START TRANSACTION');
+
+		try {
+			// Group by object type for efficient processing
+			$grouped = [];
+			foreach ($metas as $meta) {
+				$item = $meta->item();
+				$type = $item->objectType;
+				$grouped[$type][] = ['meta' => $meta, 'item' => $item];
+			}
+
+			foreach ($grouped as $objectType => $group) {
+				$storage = new self();
+				[$table, $idColumn] = $storage->getTableInfo($objectType);
+
+				if (!$table && $objectType !== 'options') {
+					continue;
+				}
+
+				// Collect all operations
+				$metaInserts = [];
+				$wpDefaultUpdates = [];
+				$taxonomyUpdates = [];
+				$optionUpdates = [];
+
+				foreach ($group as $entry) {
+					$item = $entry['item'];
+					$dirty = $item->getDirtyFields();
+
+					if (empty($dirty)) {
+						$results[$item->id] = true;
+						continue;
+					}
+
+					foreach ($dirty as $field) {
+						if ($objectType === 'options') {
+							$optionUpdates[] = [
+								'key' => $storage->optionKey($item, $field->name),
+								'value' => $field->value
+							];
+						} elseif ($field->isWpDefault()) {
+							$wpDefaultUpdates[$item->id][$field->name] = $field->value;
+						} elseif ($field->isTaxonomy()) {
+							$taxonomyUpdates[] = [
+								'object_id' => $item->id,
+								'taxonomy' => jvbCheckBase($field->config['taxonomy']),
+								'value' => $field->value
+							];
+						} else {
+							$metaInserts[] = [
+								'id' => $item->id,
+								'key' => BASE . $field->name,
+								'value' => maybe_serialize($field->value)
+							];
+						}
+					}
+
+					if ($updateTimestamp && $objectType === 'post') {
+						$postIdsToUpdate[] = $item->id;
+					}
+				}
+
+				// Execute bulk operations
+				if (!empty($metaInserts)) {
+					self::bulkUpsertMeta($table, $idColumn, $metaInserts);
+				}
+
+				if (!empty($wpDefaultUpdates)) {
+					self::batchUpdateWpDefaults($objectType, $wpDefaultUpdates);
+				}
+
+				if (!empty($taxonomyUpdates)) {
+					self::batchUpdateTaxonomies($taxonomyUpdates);
+				}
+
+				if (!empty($optionUpdates)) {
+					self::batchUpdateOptions($optionUpdates);
+				}
+
+				// Mark all fields clean
+				foreach ($group as $entry) {
+					$entry['item']->markAllClean();
+					$results[$entry['item']->id ?? 'options'] = true;
+				}
+			}
+
+			$wpdb->query('COMMIT');
+
+			// Update post timestamps in single query
+			if (!empty($postIdsToUpdate)) {
+				self::batchTouchPosts(array_unique($postIdsToUpdate));
+			}
+
+			// Clear caches
+			foreach ($metas as $meta) {
+				(new self())->clearCache($meta->item());
+			}
+
+			return $results;
+
+		} catch (Exception $e) {
+			$wpdb->query('ROLLBACK');
+
+			foreach ($metas as $meta) {
+				$results[$meta->item()->id ?? 'options'] = false;
+			}
+
+			JVB()->error()->log('meta_storage', 'Bulk save failed: ' . $e->getMessage(), [], 'error');
+
+			return $results;
+		}
+	}
+
+	/**
+	 * Bulk load meta for multiple items
+	 * @param array $ids Object IDs
+	 * @param string $objectType post, term, user
+	 * @param array $fields Field names to load
+	 * @return array<int, array<string, mixed>> Values keyed by ID then field name
+	 */
+	public static function getBulkValues(array $ids, string $objectType, array $fields): array
+	{
+		if (empty($ids) || empty($fields)) {
+			return [];
+		}
+
+		global $wpdb;
+		$storage = new self();
+
+		[$table, $idColumn] = $storage->getTableInfo($objectType);
+
+		if (!$table) {
+			return [];
+		}
+
+		// Separate WP defaults from custom meta
+		$defaults = Item::WP_DEFAULTS[$objectType] ?? [];
+		$wpFields = array_intersect($defaults, $fields);
+		$metaFields = array_diff($fields, $wpFields);
+
+		// Initialize results
+		$values = [];
+		foreach ($ids as $id) {
+			$values[$id] = array_fill_keys($fields, '');
+		}
+
+		// Bulk get custom meta
+		if (!empty($metaFields)) {
+			$metaKeys = array_map(fn($f) => BASE . $f, $metaFields);
+
+			$idPlaceholders = implode(',', array_fill(0, count($ids), '%d'));
+			$keyPlaceholders = implode(',', array_fill(0, count($metaKeys), '%s'));
+
+			$query = $wpdb->prepare(
+				"SELECT {$idColumn} as object_id, meta_key, meta_value
+                 FROM {$table}
+                 WHERE {$idColumn} IN ({$idPlaceholders})
+                 AND meta_key IN ({$keyPlaceholders})",
+				array_merge($ids, $metaKeys)
+			);
+
+			$results = $wpdb->get_results($query, ARRAY_A);
+
+			foreach ($results as $row) {
+				$objectId = (int)$row['object_id'];
+				$fieldName = str_replace(BASE, '', $row['meta_key']);
+				$values[$objectId][$fieldName] = maybe_unserialize($row['meta_value']);
+			}
+		}
+
+		// Get WP default fields (requires individual lookups unfortunately)
+		if (!empty($wpFields)) {
+			foreach ($ids as $id) {
+				$tempItem = new Item($id, $objectType);
+
+				// Load WP object for defaults
+				$tempItem->wpObject = match ($objectType) {
+					'post' => get_post($id),
+					'term' => get_term($id),
+					'user' => get_user_by('id', $id),
+					default => null
+				};
+
+				foreach ($wpFields as $field) {
+					$values[$id][$field] = $storage->getWpDefault($tempItem, $field);
+				}
+			}
+		}
+
+		return $values;
+	}
+
+	// ─────────────────────────────────────────────────────────────
+	// Protected Helpers - Single Item
 	// ─────────────────────────────────────────────────────────────
 
 	protected function getWpDefault(Item $item, string $name): mixed
@@ -175,7 +434,7 @@
 	{
 		return match ($name) {
 			'post_title' => get_the_title($item->id),
-			'post_excerpt' => get_the_excerpt($item->id),
+			'post_excerpt' => has_excerpt($item->id) ? get_the_excerpt($item->id):'',
 			'post_content' => get_post_field('post_content', $item->id),
 			default => $item->wpObject->$name ?? ''
 		};
@@ -184,7 +443,7 @@
 	protected function getTermField(Item $item, string $name): mixed
 	{
 		return match ($name) {
-			'term_name' => get_term_field('name', $item->id),
+			'name' => get_term_field('name', $item->id),
 			'description' => get_term_field('description', $item->id),
 			default => ''
 		};
@@ -207,9 +466,17 @@
 		$value = $field->value;
 
 		if (in_array($name, ['featured_image', 'post_thumbnail'])) {
+			if (empty($value)) {
+				return delete_post_thumbnail($item->id);
+			}
 			return set_post_thumbnail($item->id, $value) !== false;
 		}
 
+		// Special handling for post_status (trash/delete require specific functions)
+		if ($item->objectType === 'post' && $name === 'post_status') {
+			return $this->updatePostStatus($item->id, $value);
+		}
+
 		return match ($item->objectType) {
 			'post' => wp_update_post(['ID' => $item->id, $name => $value]) !== 0,
 			'term' => !is_wp_error(wp_update_term($item->id, $item->wpObject->taxonomy, [
@@ -221,12 +488,60 @@
 		};
 	}
 
+	/**
+	 * Update post status with proper WordPress functions
+	 *
+	 * WordPress doesn't handle trash/delete via wp_update_post():
+	 * - wp_trash_post() required for trashing
+	 * - wp_delete_post() required for deletion
+	 * - 'delete' is not even a valid post_status value
+	 *
+	 * @param int $postId Post ID
+	 * @param string $status New status (trash, delete, publish, draft, etc.)
+	 * @return bool Success
+	 */
+	protected function updatePostStatus(int $postId, string $status): bool
+	{
+		// Handle trash status
+		if ($status === 'trash') {
+			$result = wp_trash_post($postId);
+			if ($result === false || $result === null) {
+				error_log("[Storage] Failed to trash post {$postId}");
+				return false;
+			}
+			return true;
+		}
+
+		// Handle permanent deletion
+		if ($status === 'delete') {
+			$result = wp_delete_post($postId, true); // true = force delete, bypass trash
+			if ($result === false || $result === null) {
+				error_log("[Storage] Failed to delete post {$postId}");
+				return false;
+			}
+			return true;
+		}
+
+		// Handle all other statuses (publish, draft, pending, private, future)
+		$result = wp_update_post([
+			'ID' => $postId,
+			'post_status' => $status
+		]);
+
+		if ($result === 0 || is_wp_error($result)) {
+			$error = is_wp_error($result) ? $result->get_error_message() : 'Unknown error';
+			error_log("[Storage] Failed to update post {$postId} status to {$status}: {$error}");
+			return false;
+		}
+
+		return true;
+	}
+
 	protected function saveTaxonomyField(Item $item, Field $field): bool
 	{
 		$taxonomy = jvbCheckBase($field->config['taxonomy']);
 		$value = $field->value;
-
-		if (empty(trim($value))) {
+		if (empty(trim((string)$value))) {
 			wp_set_object_terms($item->id, [], $taxonomy, false);
 			return true;
 		}
@@ -266,7 +581,7 @@
 		return $values;
 	}
 
-	protected function getTableInfo(string $objectType): array
+	public function getTableInfo(string $objectType): array
 	{
 		return match ($objectType) {
 			'post' => [$this->wpdb->postmeta, 'post_id'],
@@ -286,14 +601,14 @@
 		return update_option($this->optionKey($item, $field->name), $field->value);
 	}
 
-	protected function optionKey(Item $item, string $name): string
+	public function optionKey(Item $item, string $name): string
 	{
 		return $item->baseKey
 			? BASE . $item->baseKey . '_' . $name
 			: BASE . $name;
 	}
 
-	protected function clearCache(Item $item): void
+	public function clearCache(Item $item): void
 	{
 		match ($item->objectType) {
 			'post' => clean_post_cache($item->id),
@@ -302,4 +617,176 @@
 			default => null
 		};
 	}
+
+	// ─────────────────────────────────────────────────────────────
+	// Protected Helpers - Bulk Operations
+	// ─────────────────────────────────────────────────────────────
+
+	/**
+	 * Bulk upsert meta using INSERT ... ON DUPLICATE KEY UPDATE
+	 */
+	protected static function bulkUpsertMeta(string $table, string $idColumn, array $inserts): void
+	{
+		global $wpdb;
+
+		if (empty($inserts)) {
+			return;
+		}
+
+		// MySQL's ON DUPLICATE KEY requires a unique index
+		// For meta tables, we need to check existing and do update/insert
+		$existing = [];
+		$toInsert = [];
+		$toUpdate = [];
+
+		// Check which meta keys exist
+		$checks = [];
+		foreach ($inserts as $row) {
+			$checks[] = $wpdb->prepare("(%d, %s)", $row['id'], $row['key']);
+		}
+
+		$existingQuery = "SELECT {$idColumn}, meta_key FROM {$table}
+                          WHERE ({$idColumn}, meta_key) IN (" . implode(',', $checks) . ")";
+		$existingRows = $wpdb->get_results($existingQuery, ARRAY_A);
+
+		foreach ($existingRows as $row) {
+			$existing[$row[$idColumn] . '_' . $row['meta_key']] = true;
+		}
+
+		// Separate inserts and updates
+		foreach ($inserts as $row) {
+			$key = $row['id'] . '_' . $row['key'];
+			if (isset($existing[$key])) {
+				$toUpdate[] = $row;
+			} else {
+				$toInsert[] = $row;
+			}
+		}
+
+		// Batch insert new records
+		if (!empty($toInsert)) {
+			$values = [];
+			$placeholders = [];
+
+			foreach ($toInsert as $row) {
+				$placeholders[] = "(%d, %s, %s)";
+				$values[] = $row['id'];
+				$values[] = $row['key'];
+				$values[] = $row['value'];
+			}
+
+			$sql = "INSERT INTO {$table} ({$idColumn}, meta_key, meta_value) VALUES " . implode(', ', $placeholders);
+			$wpdb->query($wpdb->prepare($sql, $values));
+		}
+
+		// Batch update existing records
+		if (!empty($toUpdate)) {
+			foreach ($toUpdate as $row) {
+				$wpdb->update(
+					$table,
+					['meta_value' => $row['value']],
+					[$idColumn => $row['id'], 'meta_key' => $row['key']],
+					['%s'],
+					['%d', '%s']
+				);
+			}
+		}
+	}
+
+	/**
+	 * Batch update post timestamps
+	 */
+	protected static function batchTouchPosts(array $postIds): void
+	{
+		global $wpdb;
+
+		if (empty($postIds)) {
+			return;
+		}
+
+		$now = current_time('mysql');
+		$nowGmt = current_time('mysql', true);
+		$ids = implode(',', array_map('intval', $postIds));
+
+		$wpdb->query(
+			"UPDATE {$wpdb->posts}
+             SET post_modified = '{$now}', post_modified_gmt = '{$nowGmt}'
+             WHERE ID IN ({$ids})"
+		);
+	}
+
+	/**
+	 * Batch update taxonomy relationships
+	 */
+	protected static function batchUpdateTaxonomies(array $updates): void
+	{
+		foreach ($updates as $update) {
+			$termIds = empty(trim((string)$update['value']))
+				? []
+				: array_map('intval', array_filter(explode(',', $update['value'])));
+
+			wp_set_object_terms($update['object_id'], $termIds, $update['taxonomy'], false);
+		}
+	}
+
+	/**
+	 * Batch update WordPress default fields
+	 */
+	protected static function batchUpdateWpDefaults(string $objectType, array $updates): void
+	{
+		foreach ($updates as $id => $fields) {
+			// Handle post_thumbnail separately
+			if (isset($fields['post_thumbnail'])) {
+				set_post_thumbnail($id, $fields['post_thumbnail']);
+				unset($fields['post_thumbnail']);
+			}
+			if (isset($fields['post_thumbnail'])) {
+				if (empty($fields['post_thumbnail'])) {
+					delete_post_thumbnail($id);
+				} else {
+					set_post_thumbnail($id, $fields['post_thumbnail']);
+				}
+				unset($fields['post_thumbnail']);
+			}
+			if (isset($fields['featured_image'])) {
+				if (empty($fields['featured_image'])) {
+					delete_post_thumbnail($id);
+				} else {
+					set_post_thumbnail($id, $fields['featured_image']);
+				}
+				unset($fields['featured_image']);
+			}
+
+			if (empty($fields)) {
+				continue;
+			}
+
+			// Handle post_date conversion
+			if (isset($fields['post_date'])) {
+				$datetime = strtotime($fields['post_date']);
+				if ($datetime !== false) {
+					$fields['post_date'] = date('Y-m-d H:i:s', $datetime);
+					$fields['post_date_gmt'] = get_gmt_from_date($fields['post_date']);
+					$fields['edit_date'] = true;
+				}
+			}
+
+			match ($objectType) {
+				'post' => wp_update_post(array_merge(['ID' => $id], $fields)),
+				'user' => wp_update_user(array_merge(['ID' => $id], $fields)),
+				'term' => null, // Terms need taxonomy, handled separately
+				default => null
+			};
+		}
+	}
+
+	/**
+	 * Batch update options
+	 */
+	protected static function batchUpdateOptions(array $updates): void
+	{
+		foreach ($updates as $update) {
+			update_option($update['key'], $update['value']);
+		}
+	}
 }

--
Gitblit v1.10.0