PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.0
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / my-wordpress / integrations / woocommerce-relations.php

woocommerce-relations.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.0.0, at includes/my-wordpress/integrations/woocommerce-relations.php

1,090 lines 35.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — My WordPress: WooCommerce × the relations layer.
4 *
5 * An order is the most connected object in WordPress and the least
6 * connected screen. It names a customer, some products and maybe a
7 * coupon, and every one of those is a dead end: WooCommerce prints the
8 * customer's name as text, the line items as text, the coupon as a
9 * token. To go from an order to the product it sold you go back to the
10 * catalogue and search for it.
11 *
12 * The shell already knows how to express that. Two surfaces, both
13 * public, neither WooCommerce-specific:
14 *
15 * 1. **Content identity** (`openstation_window_content_identity`) —
16 * what a window is showing, plus the objects it refers to. Two
17 * open windows whose identities meet get a drawn tie on the
18 * desktop. Open an order beside the product it sold and the line
19 * between them is the shell telling you they are the same story.
20 *
21 * 2. **Related entities** (`openstation_window_related_entities`) —
22 * the title bar's "Related" menu. One click from the order to the
23 * customer's profile, to any product on it, to the coupon that
24 * discounted it, each opening as its own window rather than
25 * navigating away from what you were reading.
26 *
27 * Both run inside the chromeless iframe — real admin context — so the
28 * relations resolve against live WooCommerce objects rather than
29 * against a URL we guessed at.
30 *
31 * Screens covered:
32 *
33 * - Order edit, both storages. High-Performance Order Storage moves
34 * the screen to `admin.php?page=wc-orders&action=edit&id=N`, where
35 * the built-in `post.php` detection can never see it; legacy
36 * storage lands on `post.php` and gets an identity already, but
37 * one with no links on it.
38 * - Product edit — categories, tags, reviews, and the media the
39 * built-in extractor already finds.
40 * - Coupon edit — the products and categories it is restricted to,
41 * which WooCommerce shows as bare token fields you have to click
42 * into to read.
43 * - User edit — a customer's orders, when the viewer may see them.
44 *
45 * Everything here is inert without WooCommerce.
46 *
47 * @package OpenStation
48 */
49
50 defined( 'ABSPATH' ) || exit;
51
52 /**
53 * How many line items / coupons one order announces.
54 *
55 * The relations engine caps a ref's whole `links` array at 64 and its
56 * `related` list at 64; a 200-line wholesale order would spend the
57 * entire budget on products and silently drop the customer. Bounded
58 * here so the trailing groups always survive.
59 */
60 const OPENSTATION_WOO_RELATION_ITEM_CAP = 20;
61
62 /**
63 * Query flag marking a person-URL as a request for a *particular*
64 * view of that person rather than for the profile editor.
65 *
66 * Must stay equal to `OS_PERSON_VIEW_PARAM` in
67 * `src/native-url-remap.ts` — the URL is built here and read there,
68 * so the two ends have to agree on the literal.
69 */
70 const OPENSTATION_PERSON_VIEW_PARAM = 'os_person_view';
71
72 /*
73 -------------------------------------------------------------------
74 * Screen resolution
75 * ----------------------------------------------------------------
76 */
77
78 /**
79 * The order the current admin screen is editing, whichever storage
80 * the store uses.
81 *
82 * @return WC_Abstract_Order|null
83 */
84 function openstation_my_wordpress_woo_current_order() {
85 if ( ! openstation_my_wordpress_woo_active() ) {
86 return null;
87 }
88
89 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
90
91 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
92 $id = 0;
93 if ( 'admin.php' === $pagenow ) {
94 // HPOS. `wc-orders` for shop orders, `wc-orders--{type}` for
95 // custom order types (subscriptions and friends) — both are
96 // orders as far as the relations layer cares.
97 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
98 if ( 0 !== strpos( $page, 'wc-orders' ) ) {
99 return null;
100 }
101 $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
102 if ( 'edit' !== $action ) {
103 return null;
104 }
105 $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
106 } elseif ( 'post.php' === $pagenow ) {
107 // Legacy storage: orders are posts.
108 $id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;
109 if ( $id > 0 && 'shop_order' !== get_post_type( $id ) ) {
110 return null;
111 }
112 }
113 // phpcs:enable WordPress.Security.NonceVerification.Recommended
114
115 if ( $id <= 0 ) {
116 return null;
117 }
118
119 $order = wc_get_order( $id );
120
121 return $order instanceof WC_Abstract_Order ? $order : null;
122 }
123
124 /**
125 * Whether the viewer may see this order at all.
126 *
127 * @return bool
128 */
129 function openstation_my_wordpress_woo_can_read_orders() {
130 return true === openstation_my_wordpress_woo_orders_permission();
131 }
132
133 /**
134 * The content identity for WooCommerce's product-reviews screen when
135 * it is filtered to a single product.
136 *
137 * `edit.php?post_type=product&page=product-reviews&product_id=N`.
138 * The unfiltered all-reviews list stays identity-less, the same way
139 * core leaves the unfiltered comments list alone: a window showing
140 * everything belongs to nothing in particular.
141 *
142 * @return array|null
143 */
144 function openstation_my_wordpress_woo_reviews_identity() {
145 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
146 if ( 'edit.php' !== $pagenow ) {
147 return null;
148 }
149
150 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
151 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
152 if ( 'product-reviews' !== $page ) {
153 return null;
154 }
155 $product_id = isset( $_GET['product_id'] ) ? absint( $_GET['product_id'] ) : 0;
156 // phpcs:enable WordPress.Security.NonceVerification.Recommended
157
158 if ( $product_id <= 0 || 'product' !== get_post_type( $product_id ) ) {
159 return null;
160 }
161 if ( ! current_user_can( 'edit_post', $product_id ) ) {
162 return null;
163 }
164
165 return array(
166 'type' => 'reviews',
167 'id' => $product_id,
168 /* translators: %s: product name. */
169 'label' => sprintf( __( 'Reviews of %s', 'desktop-mode' ), get_the_title( $product_id ) ),
170 'root' => array(
171 'type' => 'product',
172 'id' => $product_id,
173 ),
174 );
175 }
176
177 /*
178 -------------------------------------------------------------------
179 * Content identity
180 * ----------------------------------------------------------------
181 */
182
183 /**
184 * The objects an order refers to — its customer, its products, its
185 * coupons — as relation refs.
186 *
187 * Direction is `references` throughout: the order points at them. A
188 * product does not belong to an order (it outlives it), and a customer
189 * certainly doesn't, so `child` would be a lie the arrowheads would
190 * then tell on screen.
191 *
192 * @param WC_Abstract_Order $order Order.
193 * @return array[] Ref entries for the identity's `links` array.
194 */
195 function openstation_my_wordpress_woo_order_refs( $order ) {
196 $links = array();
197 $seen = array();
198
199 $push = static function ( $type, $id ) use ( &$links, &$seen ) {
200 $id = (int) $id;
201 $key = $type . ':' . $id;
202 if ( $id <= 0 || isset( $seen[ $key ] ) || count( $links ) >= 64 ) {
203 return;
204 }
205 $seen[ $key ] = true;
206 $links[] = array(
207 'type' => $type,
208 'id' => $id,
209 );
210 };
211
212 // The customer. `user` is the type the shell's own user-edit
213 // screens announce, so the tie forms against a profile window
214 // opened from anywhere — not just from the shop.
215 $customer_id = method_exists( $order, 'get_customer_id' ) ? (int) $order->get_customer_id() : 0;
216 if ( $customer_id > 0 ) {
217 $push( 'user', $customer_id );
218 }
219
220 $items = 0;
221 foreach ( $order->get_items() as $item ) {
222 if ( $items >= OPENSTATION_WOO_RELATION_ITEM_CAP ) {
223 break;
224 }
225 $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
226 if ( $product_id > 0 && 'product' === get_post_type( $product_id ) ) {
227 $push( 'product', $product_id );
228 ++$items;
229 }
230 }
231
232 $coupons = 0;
233 foreach ( $order->get_items( 'coupon' ) as $line ) {
234 if ( $coupons >= OPENSTATION_WOO_RELATION_ITEM_CAP ) {
235 break;
236 }
237 $coupon = new WC_Coupon( $line->get_code() );
238 if ( $coupon->get_id() ) {
239 $push( 'shop_coupon', $coupon->get_id() );
240 ++$coupons;
241 }
242 }
243
244 return $links;
245 }
246
247 /**
248 * The objects a coupon refers to — the products and categories it is
249 * restricted to.
250 *
251 * @param WC_Coupon $coupon Coupon.
252 * @return array[]
253 */
254 function openstation_my_wordpress_woo_coupon_refs( $coupon ) {
255 $links = array();
256
257 foreach ( array_slice( (array) $coupon->get_product_ids(), 0, OPENSTATION_WOO_RELATION_ITEM_CAP ) as $product_id ) {
258 $product_id = (int) $product_id;
259 if ( $product_id > 0 && 'product' === get_post_type( $product_id ) ) {
260 $links[] = array(
261 'type' => 'product',
262 'id' => $product_id,
263 );
264 }
265 }
266
267 foreach ( array_slice( (array) $coupon->get_product_categories(), 0, OPENSTATION_WOO_RELATION_ITEM_CAP ) as $term_id ) {
268 $term_id = (int) $term_id;
269 $term = $term_id ? get_term( $term_id, 'product_cat' ) : null;
270 if ( $term instanceof WP_Term ) {
271 $links[] = array(
272 'type' => 'term/product_cat',
273 'id' => $term_id,
274 );
275 }
276 }
277
278 return $links;
279 }
280
281 /**
282 * Announce an identity for WooCommerce's own screens, and hang the
283 * shop's links off the identities the built-in detection already
284 * produces.
285 *
286 * @param array|null $identity Identity so far.
287 * @param WP_Screen|null $screen Current screen, when available.
288 * @return array|null
289 */
290 function openstation_my_wordpress_woo_content_identity( $identity, $screen ) {
291 unset( $screen );
292 if ( ! openstation_my_wordpress_woo_active() ) {
293 return $identity;
294 }
295
296 // 1. Order edit. Under HPOS there is no identity yet at all —
297 // `post.php` never runs — so this is the only place it can come
298 // from. Under legacy storage there IS one (the generic post
299 // branch), and it arrives with no links: an order's content is
300 // empty, so the hyperlink/media/term extractor finds nothing.
301 $order = openstation_my_wordpress_woo_current_order();
302 if ( $order && openstation_my_wordpress_woo_can_read_orders() ) {
303 $name = method_exists( $order, 'get_formatted_billing_full_name' )
304 ? trim( $order->get_formatted_billing_full_name() )
305 : '';
306
307 $identity = array(
308 'type' => 'shop_order',
309 'id' => (int) $order->get_id(),
310 'label' => '' !== $name
311 ? sprintf(
312 /* translators: 1: order number, 2: customer name. */
313 __( 'Order #%1$s · %2$s', 'desktop-mode' ),
314 $order->get_order_number(),
315 $name
316 )
317 : sprintf(
318 /* translators: %s: order number. */
319 __( 'Order #%s', 'desktop-mode' ),
320 $order->get_order_number()
321 ),
322 );
323
324 $links = openstation_my_wordpress_woo_order_refs( $order );
325 if ( ! empty( $links ) ) {
326 $identity['links'] = $links;
327 }
328
329 return $identity;
330 }
331
332 // 2. The product-reviews screen, filtered to one product —
333 // `edit.php?post_type=product&page=product-reviews&product_id=N`,
334 // the target the Related menu's "Reviews" item opens.
335 //
336 // Without this the item opened a window that drew no tie to the
337 // product it came from, while every other item in the same menu
338 // did. The reason is structural rather than a bug in the menu:
339 // a tie needs BOTH windows to have an identity, and WooCommerce
340 // moved reviews off `edit-comments.php` onto its own admin page,
341 // which the built-in detection has no reason to know about. (The
342 // older `edit-comments.php?p=N` route is already covered by core
343 // detection, which is why a post's comments window ties.)
344 //
345 // Rooted at the product, exactly like the built-in comments
346 // identity is rooted at its post: reviews belong to the thing
347 // they review.
348 $reviews_identity = openstation_my_wordpress_woo_reviews_identity();
349 if ( $reviews_identity ) {
350 return $reviews_identity;
351 }
352
353 // 3. Coupon edit — the built-in post branch gives the identity;
354 // the restrictions are what make it interesting.
355 if ( is_array( $identity ) && 'shop_coupon' === ( $identity['type'] ?? '' ) ) {
356 $coupon = new WC_Coupon( (int) $identity['id'] );
357 if ( $coupon->get_id() ) {
358 $links = openstation_my_wordpress_woo_coupon_refs( $coupon );
359 if ( ! empty( $links ) ) {
360 $identity['links'] = array_merge(
361 (array) ( $identity['links'] ?? array() ),
362 $links
363 );
364 }
365 }
366 }
367
368 return $identity;
369 }
370
371 /*
372 -------------------------------------------------------------------
373 * Related entities — the title bar's "Related" menu
374 * ----------------------------------------------------------------
375 */
376
377 /**
378 * A related-entity item, with the fields the sanitizer requires.
379 *
380 * @param string $id Unique id in the list.
381 * @param string $group Section key.
382 * @param string $group_label Section header.
383 * @param string $label Item label.
384 * @param string $icon Dashicon class.
385 * @param string $url Admin URL to open.
386 * @param int $count Optional count badge; 0 omits it.
387 * @return array
388 */
389 function openstation_my_wordpress_woo_related_item( $id, $group, $group_label, $label, $icon, $url, $count = 0 ) {
390 $item = array(
391 'id' => $id,
392 'group' => $group,
393 'groupLabel' => $group_label,
394 'label' => $label,
395 'icon' => $icon,
396 'url' => $url,
397 );
398 if ( $count > 0 ) {
399 $item['count'] = (int) $count;
400 }
401
402 return $item;
403 }
404
405 /**
406 * Related items for an order: the customer, every product on it, and
407 * every coupon it used.
408 *
409 * @param WC_Abstract_Order $order Order.
410 * @return array[]
411 */
412 function openstation_my_wordpress_woo_order_related( $order ) {
413 $related = array();
414
415 $customer_id = method_exists( $order, 'get_customer_id' ) ? (int) $order->get_customer_id() : 0;
416 if ( $customer_id > 0 ) {
417 $user = get_userdata( $customer_id );
418 if ( $user instanceof WP_User ) {
419 $label = $user->display_name ? $user->display_name : $user->user_login;
420
421 if ( current_user_can( 'edit_user', $customer_id ) ) {
422 // The person, as a customer. From an order, "customer"
423 // means *this is who bought it* — not *change their
424 // role* — so this opens the Customer window rather
425 // than the profile editor.
426 //
427 // The Related menu can only express a destination as
428 // a URL, and the only URL WordPress has for a person
429 // is their profile editor. The marker is what lets a
430 // specific view claim that URL: the shell's built-in
431 // profile remap stands down on any person-URL carrying
432 // it, so the claim doesn't depend on winning a
433 // registration-order race.
434 $related[] = openstation_my_wordpress_woo_related_item(
435 'wc-customer-' . $customer_id,
436 'wc-customer',
437 __( 'Customer', 'desktop-mode' ),
438 $label,
439 'dashicons-businessperson',
440 add_query_arg(
441 OPENSTATION_PERSON_VIEW_PARAM,
442 'wc-customer',
443 (string) get_edit_user_link( $customer_id )
444 )
445 );
446
447 // The profile editor is still one item away, unmarked
448 // — it is a real destination, just not the one
449 // "customer" means from an order.
450 $related[] = openstation_my_wordpress_woo_related_item(
451 'wc-customer-profile-' . $customer_id,
452 'wc-customer',
453 __( 'Customer', 'desktop-mode' ),
454 __( 'Edit profile', 'desktop-mode' ),
455 'dashicons-admin-users',
456 (string) get_edit_user_link( $customer_id )
457 );
458 }
459
460 // Their other orders. The count comes off the cached
461 // aggregate the Customers section already builds, so this
462 // is free — and an item that opens a list the merchant
463 // then has to filter by hand is not worth the click.
464 $map = function_exists( 'openstation_my_wordpress_woo_customer_spend_map' )
465 ? openstation_my_wordpress_woo_customer_spend_map()
466 : array();
467 $orders = (int) ( $map[ $customer_id ]['orders'] ?? 0 );
468 if ( $orders > 1 && function_exists( 'openstation_my_wordpress_woo_customer_orders_url' ) ) {
469 $related[] = openstation_my_wordpress_woo_related_item(
470 'wc-customer-orders-' . $customer_id,
471 'wc-customer',
472 __( 'Customer', 'desktop-mode' ),
473 __( 'All orders by this customer', 'desktop-mode' ),
474 'dashicons-cart',
475 openstation_my_wordpress_woo_customer_orders_url( $customer_id ),
476 $orders
477 );
478 }
479 }
480 }
481
482 $items = 0;
483 foreach ( $order->get_items() as $item ) {
484 if ( $items >= OPENSTATION_WOO_RELATION_ITEM_CAP ) {
485 break;
486 }
487 $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
488 if ( $product_id <= 0 || ! get_post( $product_id ) ) {
489 // A line item whose product has since been deleted has no
490 // screen to open. It still reads correctly as text on the
491 // order itself; it just isn't navigation.
492 continue;
493 }
494 if ( ! current_user_can( 'edit_post', $product_id ) ) {
495 continue;
496 }
497 $related[] = openstation_my_wordpress_woo_related_item(
498 'wc-product-' . $product_id,
499 'wc-products',
500 __( 'Products', 'desktop-mode' ),
501 $item->get_name(),
502 'dashicons-products',
503 (string) get_edit_post_link( $product_id, 'raw' ),
504 (int) $item->get_quantity()
505 );
506 ++$items;
507 }
508
509 $coupons = 0;
510 foreach ( $order->get_items( 'coupon' ) as $line ) {
511 if ( $coupons >= OPENSTATION_WOO_RELATION_ITEM_CAP ) {
512 break;
513 }
514 $coupon = new WC_Coupon( $line->get_code() );
515 if ( ! $coupon->get_id() || ! current_user_can( 'edit_post', $coupon->get_id() ) ) {
516 continue;
517 }
518 $related[] = openstation_my_wordpress_woo_related_item(
519 'wc-coupon-' . $coupon->get_id(),
520 'wc-coupons',
521 __( 'Coupons', 'desktop-mode' ),
522 $coupon->get_code(),
523 'dashicons-tickets-alt',
524 (string) get_edit_post_link( $coupon->get_id(), 'raw' )
525 );
526 ++$coupons;
527 }
528
529 return $related;
530 }
531
532 /**
533 * Order ids containing a given product, newest first.
534 *
535 * Read from the order-items tables rather than through
536 * `wc_get_orders()`, because there is no "orders containing product X"
537 * query in the WooCommerce API and walking orders to find one would
538 * mean loading every order on the store. Those two tables are the
539 * right index and they are populated under BOTH storages — High
540 * Performance Order Storage moves the order rows, not the line items.
541 *
542 * Matches `_variation_id` as well as `_product_id`: a variation is
543 * sold as its own line, and a merchant asking "who bought this
544 * product" means the variable product too.
545 *
546 * @param int $product_id Product id.
547 * @param int $limit How many orders.
548 * @return int[] Order ids.
549 */
550 function openstation_my_wordpress_woo_orders_with_product( $product_id, $limit = 10 ) {
551 global $wpdb;
552
553 $product_id = (int) $product_id;
554 $limit = max( 1, (int) $limit );
555 // The order-items tables are WooCommerce's, not core's. Without
556 // the plugin they don't exist, and the query would print a
557 // "table doesn't exist" notice into whatever page called it.
558 if ( $product_id <= 0 || ! openstation_my_wordpress_woo_active() ) {
559 return array();
560 }
561
562 $items = $wpdb->prefix . 'woocommerce_order_items';
563 $itemmeta = $wpdb->prefix . 'woocommerce_order_itemmeta';
564
565 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are structural; every value is prepared.
566 $sql = $wpdb->prepare(
567 "SELECT DISTINCT oi.order_id
568 FROM {$items} oi
569 INNER JOIN {$itemmeta} oim ON oim.order_item_id = oi.order_item_id
570 WHERE oi.order_item_type = 'line_item'
571 AND oim.meta_key IN ( '_product_id', '_variation_id' )
572 AND oim.meta_value = %d
573 ORDER BY oi.order_id DESC
574 LIMIT %d",
575 $product_id,
576 $limit
577 );
578
579 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- no core API answers "orders containing product X"; $sql came out of prepare() above, and the result feeds one menu render.
580 $ids = $wpdb->get_col( $sql );
581
582 return array_map( 'intval', (array) $ids );
583 }
584
585 /**
586 * Coupons restricted to a given product (or to one of its categories).
587 *
588 * WooCommerce stores both restrictions as comma-separated id strings
589 * in postmeta, which no meta query can search reliably — `LIKE
590 * '%12%'` matches 112 and 121. So the rows are read and split in PHP.
591 * Bounded: a store with more coupons than this has a coupon strategy,
592 * not a coupon, and the menu is not the place to enumerate it.
593 *
594 * @param int $product_id Product id.
595 * @param int $limit How many coupons.
596 * @return int[] Coupon post ids.
597 */
598 function openstation_my_wordpress_woo_coupons_for_product( $product_id, $limit = 8 ) {
599 global $wpdb;
600
601 $product_id = (int) $product_id;
602 if ( $product_id <= 0 ) {
603 return array();
604 }
605
606 $category_ids = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) );
607 $category_ids = is_wp_error( $category_ids ) ? array() : array_map( 'intval', $category_ids );
608
609 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are structural.
610 $sql = "SELECT pm.post_id, pm.meta_key, pm.meta_value
611 FROM {$wpdb->postmeta} pm
612 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
613 WHERE p.post_type = 'shop_coupon'
614 AND p.post_status = 'publish'
615 AND pm.meta_key IN ( 'product_ids', 'product_categories' )
616 LIMIT 400";
617
618 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- comma-joined id strings can't be searched with a meta query; bounded scan feeding one menu render.
619 $rows = $wpdb->get_results( $sql );
620
621 $matched = array();
622 foreach ( (array) $rows as $row ) {
623 if ( count( $matched ) >= $limit ) {
624 break;
625 }
626 $values = array_filter( array_map( 'intval', explode( ',', (string) $row->meta_value ) ) );
627 if ( empty( $values ) ) {
628 continue;
629 }
630 $hit = 'product_ids' === $row->meta_key
631 ? in_array( $product_id, $values, true )
632 : ( ! empty( array_intersect( $category_ids, $values ) ) );
633 if ( $hit ) {
634 $matched[ (int) $row->post_id ] = true;
635 }
636 }
637
638 return array_map( 'intval', array_keys( $matched ) );
639 }
640
641 /**
642 * Related items for a product: everything the catalogue screen knows
643 * about it and can't take you to.
644 *
645 * The built-in related pass covers `post` and `page` only, so a
646 * product gets none of this for free — its taxonomies are exactly as
647 * navigable as its order history, which is to say not at all.
648 *
649 * Budgets are per group and add up deliberately. The engine hard-caps
650 * the whole `related` list at 64, and an unbudgeted group would push
651 * the trailing ones silently over — losing the orders because a
652 * product happened to carry thirty tags. Worst case here is
653 * 10 + 10 + 1 + 1 + 10 + 8 + 8 = 48.
654 *
655 * @param int $product_id Product id.
656 * @return array[]
657 */
658 function openstation_my_wordpress_woo_product_related( $product_id ) {
659 $related = array();
660 $product = wc_get_product( $product_id );
661 if ( ! $product ) {
662 return $related;
663 }
664
665 foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) {
666 $tax = get_taxonomy( $taxonomy );
667 if ( ! $tax ) {
668 continue;
669 }
670 $terms = get_the_terms( $product_id, $taxonomy );
671 if ( ! is_array( $terms ) ) {
672 continue;
673 }
674 foreach ( array_slice( $terms, 0, 10 ) as $term ) {
675 $related[] = openstation_my_wordpress_woo_related_item(
676 'wc-term-' . $taxonomy . '-' . (int) $term->term_id,
677 'terms/' . $taxonomy,
678 (string) $tax->labels->name,
679 $term->name,
680 'product_cat' === $taxonomy ? 'dashicons-category' : 'dashicons-tag',
681 admin_url(
682 'term.php?taxonomy=' . rawurlencode( $taxonomy ) . '&tag_ID=' . (int) $term->term_id . '&post_type=product'
683 )
684 );
685 }
686 }
687
688 // Reviews. WooCommerce files them as comments of type `review`,
689 // and its own Reviews screen is the comment list with that filter
690 // pre-applied — which is exactly the URL worth linking.
691 $reviews = (int) $product->get_review_count();
692 if ( $reviews > 0 && current_user_can( 'moderate_comments' ) ) {
693 $related[] = openstation_my_wordpress_woo_related_item(
694 'wc-reviews-' . $product_id,
695 'wc-reviews',
696 __( 'Reviews', 'desktop-mode' ),
697 __( 'Reviews', 'desktop-mode' ),
698 'dashicons-star-filled',
699 admin_url( 'edit.php?post_type=product&page=product-reviews&product_id=' . (int) $product_id ),
700 $reviews
701 );
702 }
703
704 // Variations edit through their parent's screen, but a variable
705 // product's children are the thing a merchant actually adjusts —
706 // surface the parent screen's variations tab as one jump.
707 if ( $product->is_type( 'variable' ) ) {
708 $children = count( $product->get_children() );
709 if ( $children > 0 ) {
710 $related[] = openstation_my_wordpress_woo_related_item(
711 'wc-variations-' . $product_id,
712 'wc-products',
713 __( 'Product', 'desktop-mode' ),
714 __( 'Variations', 'desktop-mode' ),
715 'dashicons-networking',
716 (string) get_edit_post_link( $product_id, 'raw' ) . '#variable_product_options',
717 $children
718 );
719 }
720 }
721
722 // The other half of the story: who bought it. An order names its
723 // products, so the order → product jump has always worked; the
724 // reverse is the one a merchant actually asks for ("is this
725 // selling? who to?") and the catalogue screen has no answer at
726 // all.
727 //
728 // Gated on order access rather than on `edit_post`: this is order
729 // data reached from a product screen, and a shop editor who may
730 // not read orders must not read them sideways.
731 if ( openstation_my_wordpress_woo_can_read_orders() ) {
732 $customers = array();
733 foreach ( openstation_my_wordpress_woo_orders_with_product( $product_id, 10 ) as $order_id ) {
734 $order = wc_get_order( $order_id );
735 if ( ! $order instanceof WC_Abstract_Order ) {
736 continue;
737 }
738
739 $name = method_exists( $order, 'get_formatted_billing_full_name' )
740 ? trim( $order->get_formatted_billing_full_name() )
741 : '';
742 $total = openstation_my_wordpress_woo_price(
743 $order->get_total(),
744 $order->get_currency()
745 );
746
747 $related[] = openstation_my_wordpress_woo_related_item(
748 'wc-order-' . $order_id,
749 'wc-orders',
750 __( 'Orders', 'desktop-mode' ),
751 '' !== $name
752 ? sprintf(
753 /* translators: 1: order number, 2: customer name, 3: order total. */
754 __( '#%1$s · %2$s · %3$s', 'desktop-mode' ),
755 $order->get_order_number(),
756 $name,
757 $total
758 )
759 : sprintf(
760 /* translators: 1: order number, 2: order total. */
761 __( '#%1$s · %2$s', 'desktop-mode' ),
762 $order->get_order_number(),
763 $total
764 ),
765 'dashicons-cart',
766 method_exists( $order, 'get_edit_order_url' )
767 ? (string) $order->get_edit_order_url()
768 : ''
769 );
770
771 // Harvested from the same orders rather than queried
772 // again — the buyers of a product ARE the customers on
773 // its orders, and a second query would only say so more
774 // slowly.
775 $customer_id = method_exists( $order, 'get_customer_id' )
776 ? (int) $order->get_customer_id()
777 : 0;
778 if ( $customer_id > 0 && ! isset( $customers[ $customer_id ] ) ) {
779 $customers[ $customer_id ] = true;
780 }
781 }
782
783 $shown = 0;
784 foreach ( array_keys( $customers ) as $customer_id ) {
785 if ( $shown >= 8 ) {
786 break;
787 }
788 $user = get_userdata( (int) $customer_id );
789 if ( ! $user instanceof WP_User || ! current_user_can( 'edit_user', $user->ID ) ) {
790 continue;
791 }
792 $related[] = openstation_my_wordpress_woo_related_item(
793 'wc-buyer-' . (int) $customer_id,
794 'wc-customer',
795 __( 'Customers', 'desktop-mode' ),
796 $user->display_name ? $user->display_name : $user->user_login,
797 'dashicons-businessperson',
798 // The Customer window, not the profile editor — from
799 // a product or a coupon, a person is a buyer.
800 add_query_arg(
801 OPENSTATION_PERSON_VIEW_PARAM,
802 'wc-customer',
803 (string) get_edit_user_link( $user->ID )
804 )
805 );
806 ++$shown;
807 }
808 }
809
810 // Coupons that discount it — WooCommerce shows the relationship
811 // only from the coupon's side, as a token field, so from the
812 // product there is currently no way to learn it is on offer.
813 foreach ( openstation_my_wordpress_woo_coupons_for_product( $product_id, 8 ) as $coupon_id ) {
814 if ( ! current_user_can( 'edit_post', $coupon_id ) ) {
815 continue;
816 }
817 $coupon = new WC_Coupon( $coupon_id );
818 if ( ! $coupon->get_id() ) {
819 continue;
820 }
821 $related[] = openstation_my_wordpress_woo_related_item(
822 'wc-product-coupon-' . $coupon_id,
823 'wc-coupons',
824 __( 'Coupons', 'desktop-mode' ),
825 $coupon->get_code(),
826 'dashicons-tickets-alt',
827 (string) get_edit_post_link( $coupon_id, 'raw' )
828 );
829 }
830
831 return $related;
832 }
833
834 /**
835 * Order ids that used a given coupon code, newest first.
836 *
837 * Coupon usage is a line item like any other, so it lives in the same
838 * always-populated order-items tables. `order_item_name` holds the
839 * code, lowercased by WooCommerce on apply.
840 *
841 * @param string $code Coupon code.
842 * @param int $limit How many orders.
843 * @return int[] Order ids.
844 */
845 function openstation_my_wordpress_woo_orders_with_coupon( $code, $limit = 10 ) {
846 global $wpdb;
847
848 $code = strtolower( trim( (string) $code ) );
849 // Same table-ownership caveat as the product lookup above.
850 if ( '' === $code || ! openstation_my_wordpress_woo_active() ) {
851 return array();
852 }
853
854 $items = $wpdb->prefix . 'woocommerce_order_items';
855
856 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is structural; every value is prepared.
857 $sql = $wpdb->prepare(
858 "SELECT DISTINCT order_id
859 FROM {$items}
860 WHERE order_item_type = 'coupon' AND LOWER( order_item_name ) = %s
861 ORDER BY order_id DESC
862 LIMIT %d",
863 $code,
864 max( 1, (int) $limit )
865 );
866
867 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- no core API answers "orders that used coupon X"; $sql came out of prepare() above, and the result feeds one menu render.
868 $ids = $wpdb->get_col( $sql );
869
870 return array_map( 'intval', (array) $ids );
871 }
872
873 /**
874 * Related items for a coupon: what it is restricted to, and who
875 * actually used it.
876 *
877 * WooCommerce renders the restrictions as select2 token fields —
878 * readable only by clicking into each token, and not links to
879 * anywhere. The usage count it does show is a bare number with
880 * nothing behind it.
881 *
882 * Budgets: 20 + 20 + 10 + 8 = 58, inside the engine's 64-item cap.
883 *
884 * @param int $coupon_id Coupon id.
885 * @return array[]
886 */
887 function openstation_my_wordpress_woo_coupon_related( $coupon_id ) {
888 $related = array();
889 $coupon = new WC_Coupon( (int) $coupon_id );
890 if ( ! $coupon->get_id() ) {
891 return $related;
892 }
893
894 foreach ( array_slice( (array) $coupon->get_product_ids(), 0, OPENSTATION_WOO_RELATION_ITEM_CAP ) as $product_id ) {
895 $product = wc_get_product( (int) $product_id );
896 if ( ! $product || ! current_user_can( 'edit_post', (int) $product_id ) ) {
897 continue;
898 }
899 $related[] = openstation_my_wordpress_woo_related_item(
900 'wc-coupon-product-' . (int) $product_id,
901 'wc-products',
902 __( 'Applies to', 'desktop-mode' ),
903 $product->get_name(),
904 'dashicons-products',
905 (string) get_edit_post_link( (int) $product_id, 'raw' )
906 );
907 }
908
909 foreach ( array_slice( (array) $coupon->get_product_categories(), 0, OPENSTATION_WOO_RELATION_ITEM_CAP ) as $term_id ) {
910 $term = get_term( (int) $term_id, 'product_cat' );
911 if ( ! $term instanceof WP_Term ) {
912 continue;
913 }
914 $related[] = openstation_my_wordpress_woo_related_item(
915 'wc-coupon-cat-' . (int) $term_id,
916 'terms/product_cat',
917 __( 'Applies to', 'desktop-mode' ),
918 $term->name,
919 'dashicons-category',
920 admin_url( 'term.php?taxonomy=product_cat&tag_ID=' . (int) $term_id . '&post_type=product' )
921 );
922 }
923
924 // Who redeemed it. The coupon screen shows a usage count and
925 // nothing behind it, so "did this campaign work, and for whom" is
926 // a question you currently answer by exporting orders.
927 if ( openstation_my_wordpress_woo_can_read_orders() ) {
928 $customers = array();
929 foreach ( openstation_my_wordpress_woo_orders_with_coupon( $coupon->get_code(), 10 ) as $order_id ) {
930 $order = wc_get_order( $order_id );
931 if ( ! $order instanceof WC_Abstract_Order ) {
932 continue;
933 }
934
935 $name = method_exists( $order, 'get_formatted_billing_full_name' )
936 ? trim( $order->get_formatted_billing_full_name() )
937 : '';
938 $total = openstation_my_wordpress_woo_price(
939 $order->get_total(),
940 $order->get_currency()
941 );
942
943 $related[] = openstation_my_wordpress_woo_related_item(
944 'wc-coupon-order-' . $order_id,
945 'wc-orders',
946 __( 'Used on', 'desktop-mode' ),
947 '' !== $name
948 ? sprintf(
949 /* translators: 1: order number, 2: customer name, 3: order total. */
950 __( '#%1$s · %2$s · %3$s', 'desktop-mode' ),
951 $order->get_order_number(),
952 $name,
953 $total
954 )
955 : sprintf(
956 /* translators: 1: order number, 2: order total. */
957 __( '#%1$s · %2$s', 'desktop-mode' ),
958 $order->get_order_number(),
959 $total
960 ),
961 'dashicons-cart',
962 method_exists( $order, 'get_edit_order_url' )
963 ? (string) $order->get_edit_order_url()
964 : ''
965 );
966
967 $customer_id = method_exists( $order, 'get_customer_id' )
968 ? (int) $order->get_customer_id()
969 : 0;
970 if ( $customer_id > 0 ) {
971 $customers[ $customer_id ] = true;
972 }
973 }
974
975 $shown = 0;
976 foreach ( array_keys( $customers ) as $customer_id ) {
977 if ( $shown >= 8 ) {
978 break;
979 }
980 $user = get_userdata( (int) $customer_id );
981 if ( ! $user instanceof WP_User || ! current_user_can( 'edit_user', $user->ID ) ) {
982 continue;
983 }
984 $related[] = openstation_my_wordpress_woo_related_item(
985 'wc-coupon-buyer-' . (int) $customer_id,
986 'wc-customer',
987 __( 'Customers', 'desktop-mode' ),
988 $user->display_name ? $user->display_name : $user->user_login,
989 'dashicons-businessperson',
990 // The Customer window, not the profile editor — from
991 // a product or a coupon, a person is a buyer.
992 add_query_arg(
993 OPENSTATION_PERSON_VIEW_PARAM,
994 'wc-customer',
995 (string) get_edit_user_link( $user->ID )
996 )
997 );
998 ++$shown;
999 }
1000 }
1001
1002 return $related;
1003 }
1004
1005 /**
1006 * Related items for a user identity: their orders, when they have any
1007 * and the viewer may see them.
1008 *
1009 * @param int $user_id User id.
1010 * @return array[]
1011 */
1012 function openstation_my_wordpress_woo_user_related( $user_id ) {
1013 if (
1014 ! function_exists( 'openstation_my_wordpress_woo_customer_spend_map' )
1015 || true !== openstation_my_wordpress_woo_customers_permission()
1016 ) {
1017 return array();
1018 }
1019
1020 $map = openstation_my_wordpress_woo_customer_spend_map();
1021 $stats = $map[ (int) $user_id ] ?? null;
1022 $orders = $stats ? (int) $stats['orders'] : 0;
1023 if ( $orders <= 0 ) {
1024 return array();
1025 }
1026
1027 return array(
1028 openstation_my_wordpress_woo_related_item(
1029 'wc-user-orders-' . (int) $user_id,
1030 'wc-orders',
1031 __( 'Store', 'desktop-mode' ),
1032 __( 'Orders', 'desktop-mode' ),
1033 'dashicons-cart',
1034 openstation_my_wordpress_woo_customer_orders_url( (int) $user_id ),
1035 $orders
1036 ),
1037 );
1038 }
1039
1040 /**
1041 * Hang WooCommerce's relations off whatever identity the screen
1042 * resolved to.
1043 *
1044 * @param array[] $related Related items so far.
1045 * @param array $identity The resolved content identity.
1046 * @param WP_Screen|null $screen Current screen, when available.
1047 * @return array[]
1048 */
1049 function openstation_my_wordpress_woo_related_entities( $related, $identity, $screen ) {
1050 unset( $screen );
1051 if ( ! openstation_my_wordpress_woo_active() || ! is_array( $identity ) ) {
1052 return $related;
1053 }
1054
1055 $type = (string) ( $identity['type'] ?? '' );
1056 $id = (int) ( $identity['id'] ?? 0 );
1057 if ( $id <= 0 ) {
1058 return $related;
1059 }
1060
1061 if ( 'shop_order' === $type && openstation_my_wordpress_woo_can_read_orders() ) {
1062 $order = wc_get_order( $id );
1063 if ( $order instanceof WC_Abstract_Order ) {
1064 $related = array_merge( (array) $related, openstation_my_wordpress_woo_order_related( $order ) );
1065 }
1066 } elseif ( 'product' === $type ) {
1067 $related = array_merge( (array) $related, openstation_my_wordpress_woo_product_related( $id ) );
1068 } elseif ( 'shop_coupon' === $type ) {
1069 $related = array_merge( (array) $related, openstation_my_wordpress_woo_coupon_related( $id ) );
1070 } elseif ( 'user' === $type ) {
1071 $related = array_merge( (array) $related, openstation_my_wordpress_woo_user_related( $id ) );
1072 }
1073
1074 return $related;
1075 }
1076
1077 /**
1078 * Boot the relations wiring.
1079 *
1080 * Priority 20 on the identity filter so a site that overrides the
1081 * order identity for its own reasons still wins.
1082 *
1083 * @return void
1084 */
1085 function openstation_my_wordpress_woo_relations_boot() {
1086 add_filter( 'openstation_window_content_identity', 'openstation_my_wordpress_woo_content_identity', 20, 2 );
1087 add_filter( 'openstation_window_related_entities', 'openstation_my_wordpress_woo_related_entities', 20, 3 );
1088 }
1089 openstation_my_wordpress_woo_relations_boot();
1090