PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / API / V1 / Templates_Controller.php

Templates_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/API/V1/Templates_Controller.php

1,566 lines 50.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Templates_Controller.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V1;
9
10 use Ramsey\Uuid\Uuid;
11 use WCPOS\WooCommercePOS\Logger;
12 use WCPOS\WooCommercePOS\Services\Preview_Receipt_Builder;
13 use WCPOS\WooCommercePOS\Services\Receipt_Preview_Fixture_Loader;
14 use WCPOS\WooCommercePOS\Services\Receipt_Data_Builder;
15 use WCPOS\WooCommercePOS\Services\Receipt_Data_Schema;
16 use WCPOS\WooCommercePOS\Templates as TemplatesManager;
17 use WP_Error;
18 use WP_Query;
19 use WP_REST_Controller;
20 use WP_REST_Request;
21 use WP_REST_Response;
22 use WP_REST_Server;
23
24 use const WCPOS\WooCommercePOS\SHORT_NAME;
25
26 /**
27 * Class Templates REST API Controller.
28 *
29 * Returns both virtual (filesystem) templates and custom (database) templates.
30 */
31 class Templates_Controller extends WP_REST_Controller {
32 /**
33 * Endpoint namespace.
34 *
35 * @var string
36 */
37 protected $namespace = SHORT_NAME . '/v1';
38
39 /**
40 * Route base.
41 *
42 * @var string
43 */
44 protected $rest_base = 'templates';
45
46 /**
47 * Register routes.
48 *
49 * Fixed paths must be registered before regex patterns to avoid
50 * the wildcard (?P<id>[\w-]+) capturing "active", "gallery", etc.
51 *
52 * @return void
53 */
54 public function register_routes(): void {
55 // 1. GET /templates (collection).
56 register_rest_route(
57 $this->namespace,
58 '/' . $this->rest_base,
59 array(
60 'methods' => WP_REST_Server::READABLE,
61 'callback' => array( $this, 'get_items' ),
62 'permission_callback' => array( $this, 'get_items_permissions_check' ),
63 'args' => $this->get_collection_params(),
64 )
65 );
66
67 // 2. GET /templates/active (fixed path).
68 register_rest_route(
69 $this->namespace,
70 '/' . $this->rest_base . '/active',
71 array(
72 'methods' => WP_REST_Server::READABLE,
73 'callback' => array( $this, 'get_active' ),
74 'permission_callback' => array( $this, 'get_item_permissions_check' ),
75 'args' => array(
76 'type' => array(
77 'description' => /* translators: REST API schema field label or error message. */ __( 'Template type.', 'woocommerce-pos' ),
78 'type' => 'string',
79 'default' => 'receipt',
80 'enum' => array( 'receipt', 'report' ),
81 ),
82 ),
83 )
84 );
85
86 // 3. GET /templates/gallery (fixed path).
87 register_rest_route(
88 $this->namespace,
89 '/' . $this->rest_base . '/gallery',
90 array(
91 'methods' => WP_REST_Server::READABLE,
92 'callback' => array( $this, 'get_gallery_items' ),
93 'permission_callback' => array( $this, 'get_items_permissions_check' ),
94 'args' => array(
95 'type' => array(
96 'description' => __( 'Filter by template type.', 'woocommerce-pos' ),
97 'type' => 'string',
98 'sanitize_callback' => 'sanitize_text_field',
99 'validate_callback' => 'rest_validate_request_arg',
100 ),
101 'category' => array(
102 'description' => __( 'Filter by template category slug.', 'woocommerce-pos' ),
103 'type' => 'string',
104 'sanitize_callback' => 'sanitize_text_field',
105 'validate_callback' => 'rest_validate_request_arg',
106 ),
107 ),
108 )
109 );
110
111 // 4. POST /templates/batch (fixed path).
112 register_rest_route(
113 $this->namespace,
114 '/' . $this->rest_base . '/batch',
115 array(
116 'methods' => WP_REST_Server::CREATABLE,
117 'callback' => array( $this, 'batch_items' ),
118 'permission_callback' => array( $this, 'update_item_permissions_check' ),
119 'args' => array(
120 'type' => array(
121 'description' => /* translators: REST API schema field label or error message. */ __( 'Template type for ordering.', 'woocommerce-pos' ),
122 'type' => 'string',
123 'default' => 'receipt',
124 'enum' => array( 'receipt', 'report' ),
125 'sanitize_callback' => 'sanitize_text_field',
126 'validate_callback' => 'rest_validate_request_arg',
127 ),
128 'update' => array(
129 'description' => __( 'Array of templates to update.', 'woocommerce-pos' ),
130 'type' => 'array',
131 'required' => false,
132 'items' => array(
133 'type' => 'object',
134 'properties' => array(
135 'id' => array(
136 'type' => 'integer',
137 'required' => true,
138 ),
139 'status' => array(
140 'type' => 'string',
141 'enum' => array( 'publish', 'draft' ),
142 ),
143 'menu_order' => array( 'type' => 'integer' ),
144 'tax_display' => array(
145 'type' => 'string',
146 'enum' => array( 'default', 'incl', 'excl' ),
147 ),
148 ),
149 ),
150 ),
151 'order' => array(
152 'description' => /* translators: REST API schema field label or error message. */ __( 'Ordered array of all template IDs (int for database, string for virtual).', 'woocommerce-pos' ),
153 'type' => 'array',
154 'items' => array(
155 'type' => array( 'integer', 'string' ),
156 ),
157 ),
158 'disable_virtual' => array(
159 'description' => __( 'Array of virtual template IDs to disable.', 'woocommerce-pos' ),
160 'type' => 'array',
161 'items' => array( 'type' => 'string' ),
162 ),
163 'enable_virtual' => array(
164 'description' => __( 'Array of virtual template IDs to enable.', 'woocommerce-pos' ),
165 'type' => 'array',
166 'items' => array( 'type' => 'string' ),
167 ),
168 ),
169 )
170 );
171
172 // 5. POST /templates/install (fixed path).
173 register_rest_route(
174 $this->namespace,
175 '/' . $this->rest_base . '/install',
176 array(
177 'methods' => WP_REST_Server::CREATABLE,
178 'callback' => array( $this, 'install_gallery_item' ),
179 'permission_callback' => array( $this, 'update_item_permissions_check' ),
180 'args' => array(
181 'gallery_key' => array(
182 'description' => __( 'Gallery template key to install.', 'woocommerce-pos' ),
183 'type' => 'string',
184 'required' => true,
185 'sanitize_callback' => 'sanitize_text_field',
186 'validate_callback' => 'rest_validate_request_arg',
187 ),
188 ),
189 )
190 );
191
192 // 6. GET /templates/{id} (regex). Must come after all fixed-path routes.
193 register_rest_route(
194 $this->namespace,
195 '/' . $this->rest_base . '/(?P<id>[\w-]+)',
196 array(
197 'methods' => WP_REST_Server::READABLE,
198 'callback' => array( $this, 'get_item' ),
199 'permission_callback' => array( $this, 'get_item_permissions_check' ),
200 'args' => array(
201 'id' => array(
202 'description' => /* translators: REST API schema field label or error message. */ __( 'Unique identifier for the template (numeric for database, string for virtual).', 'woocommerce-pos' ),
203 'type' => 'string',
204 'required' => true,
205 ),
206 'type' => array(
207 'description' => /* translators: REST API schema field label or error message. */ __( 'Template type.', 'woocommerce-pos' ),
208 'type' => 'string',
209 'default' => 'receipt',
210 'enum' => array( 'receipt', 'report' ),
211 'sanitize_callback' => 'sanitize_key',
212 'validate_callback' => 'rest_validate_request_arg',
213 ),
214 ),
215 )
216 );
217
218 // 8. PATCH /templates/{id} (regex).
219 register_rest_route(
220 $this->namespace,
221 '/' . $this->rest_base . '/(?P<id>[\d]+)',
222 array(
223 'methods' => WP_REST_Server::EDITABLE,
224 'callback' => array( $this, 'update_item' ),
225 'permission_callback' => array( $this, 'update_item_permissions_check' ),
226 'args' => array(
227 'id' => array(
228 'type' => 'integer',
229 'required' => true,
230 ),
231 'status' => array(
232 'type' => 'string',
233 'enum' => array( 'publish', 'draft' ),
234 ),
235 'menu_order' => array( 'type' => 'integer' ),
236 'tax_display' => array(
237 'type' => 'string',
238 'enum' => array( 'default', 'incl', 'excl' ),
239 ),
240 ),
241 )
242 );
243
244 // 9. POST /templates/{id}/copy (regex).
245 register_rest_route(
246 $this->namespace,
247 '/' . $this->rest_base . '/(?P<id>[\d]+)/copy',
248 array(
249 'methods' => WP_REST_Server::CREATABLE,
250 'callback' => array( $this, 'copy_item' ),
251 'permission_callback' => array( $this, 'update_item_permissions_check' ),
252 'args' => array(
253 'id' => array(
254 'description' => /* translators: REST API schema field label or error message. */ __( 'Template ID to copy.', 'woocommerce-pos' ),
255 'type' => 'integer',
256 'required' => true,
257 ),
258 ),
259 )
260 );
261
262 // 10. GET /templates/{id}/preview (regex).
263 register_rest_route(
264 $this->namespace,
265 '/' . $this->rest_base . '/(?P<id>[\w-]+)/preview',
266 array(
267 'methods' => WP_REST_Server::READABLE,
268 'callback' => array( $this, 'preview_item' ),
269 'permission_callback' => array( $this, 'preview_item_permissions_check' ),
270 'args' => array(
271 'id' => array(
272 'description' => /* translators: REST API schema field label or error message. */ __( 'Template ID to preview.', 'woocommerce-pos' ),
273 'type' => 'string',
274 'required' => true,
275 ),
276 'order_id' => array(
277 'description' => __( 'Order ID or "latest" for most recent POS order. Omit for sample data.', 'woocommerce-pos' ),
278 'type' => array( 'integer', 'string' ),
279 'required' => false,
280 'default' => 0,
281 ),
282 'store_id' => array(
283 'description' => __( 'POS store ID for store-specific data.', 'woocommerce-pos' ),
284 'type' => 'integer',
285 'required' => false,
286 'default' => 0,
287 ),
288 'include_legacy_html' => array(
289 'description' => __( 'Include temporary PHP-rendered HTML diagnostics for logicless previews.', 'woocommerce-pos' ),
290 'type' => 'boolean',
291 'required' => false,
292 'default' => false,
293 'sanitize_callback' => 'rest_sanitize_boolean',
294 'validate_callback' => 'rest_validate_request_arg',
295 ),
296 ),
297 )
298 );
299
300 // 11. DELETE /templates/{id} (regex). Must come after all {id}/sub-path routes.
301 register_rest_route(
302 $this->namespace,
303 '/' . $this->rest_base . '/(?P<id>[\d]+)',
304 array(
305 'methods' => WP_REST_Server::DELETABLE,
306 'callback' => array( $this, 'delete_item' ),
307 'permission_callback' => array( $this, 'update_item_permissions_check' ),
308 'args' => array(
309 'id' => array(
310 'description' => /* translators: REST API schema field label or error message. */ __( 'Template ID to delete.', 'woocommerce-pos' ),
311 'type' => 'integer',
312 'required' => true,
313 'validate_callback' => function ( $value ) {
314 return is_numeric( $value );
315 },
316 ),
317 ),
318 )
319 );
320 }
321
322 /**
323 * Get a collection of templates.
324 * Returns virtual templates first, then database templates.
325 *
326 * @param WP_REST_Request $request Full details about the request.
327 *
328 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
329 */
330 public function get_items( $request ) {
331 $type = $request->get_param( 'type' ) ?? 'receipt';
332 $store_id = $request->get_param( 'store_id' );
333 $search = $request->get_param( 'search' );
334 $category = $request->get_param( 'category' );
335 $modified_after = $request->get_param( 'modified_after' );
336 $per_page = (int) ( $request->get_param( 'per_page' ) ?? -1 );
337 $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
338 $has_filters = ( null !== $search && '' !== $search ) || ( null !== $category && '' !== $category ) || ( null !== $modified_after && '' !== $modified_after );
339
340 // Step 1: Resolve which template is active. Store-specific requests keep the
341 // POS runtime behavior of returning enabled templates only. The admin list
342 // only includes inactive templates for managers so POS/runtime callers with
343 // access_woocommerce_pos do not receive disabled or draft templates.
344 $is_manager = current_user_can( 'manage_woocommerce_pos' );
345 $enabled = $store_id ? TemplatesManager::resolve_templates( (int) $store_id, $type ) : array();
346 $active_template_id = $store_id
347 ? ( ! empty( $enabled ) ? $enabled[0]['id'] : null )
348 : TemplatesManager::get_active_template_id( $type );
349
350 // Step 2: Build full template list.
351 // When store_id is set, use the resolved enabled list directly.
352 // Managers without filters get the full admin list (including inactive).
353 // Non-manager callers without store_id keep the previous enabled-only list.
354 // When filters are active, query database templates with filters.
355 if ( $store_id || ! $has_filters ) {
356 if ( $store_id ) {
357 $source_templates = $enabled;
358 } elseif ( $is_manager ) {
359 $source_templates = $this->get_admin_template_list( $type );
360 } else {
361 $source_templates = TemplatesManager::get_enabled_templates( $type );
362 }
363 $templates = array();
364 foreach ( $source_templates as $template ) {
365 $template['is_active'] = ( null !== $active_template_id && (string) $template['id'] === (string) $active_template_id );
366 $templates[] = $this->prepare_item_for_response( $template, $request );
367 }
368
369 $total_items = \count( $templates );
370
371 if ( $per_page > 0 ) {
372 $offset = ( $page - 1 ) * $per_page;
373 $templates = \array_slice( $templates, $offset, $per_page );
374 $total_pages = $total_items > 0 ? (int) \ceil( $total_items / $per_page ) : 1;
375 } else {
376 $total_pages = 1;
377 }
378 } else {
379 // Filtered DB query (search, category, modified_after).
380 $args = array(
381 'post_type' => 'wcpos_template',
382 'post_status' => $is_manager ? array( 'publish', 'draft' ) : 'publish',
383 'posts_per_page' => $per_page,
384 'paged' => $page,
385 'orderby' => 'menu_order',
386 'order' => 'ASC',
387 );
388
389 $tax_query = array();
390 if ( $type ) {
391 $tax_query[] = array(
392 'taxonomy' => 'wcpos_template_type',
393 'field' => 'slug',
394 'terms' => $type,
395 );
396 }
397 if ( $category ) {
398 $tax_query[] = array(
399 'taxonomy' => 'wcpos_template_category',
400 'field' => 'slug',
401 'terms' => $category,
402 );
403 }
404 if ( ! empty( $tax_query ) ) {
405 if ( \count( $tax_query ) > 1 ) {
406 $tax_query['relation'] = 'AND';
407 }
408 $args['tax_query'] = $tax_query;
409 }
410 if ( null !== $search && '' !== $search ) {
411 $matching_ids = $this->get_search_matching_template_ids( $search, $args );
412 $args['post__in'] = empty( $matching_ids ) ? array( 0 ) : $matching_ids;
413 }
414 if ( $modified_after ) {
415 $args['date_query'] = array(
416 array(
417 'column' => 'post_modified',
418 'after' => $modified_after,
419 ),
420 );
421 }
422
423 $query = new WP_Query( $args );
424 $templates = array();
425 foreach ( $query->posts as $post ) {
426 $template = TemplatesManager::get_template( $post->ID );
427 if ( $template ) {
428 $template['is_active'] = ( null !== $active_template_id && (string) $template['id'] === (string) $active_template_id );
429 $templates[] = $this->prepare_item_for_response( $template, $request );
430 }
431 }
432
433 $total_items = (int) $query->found_posts;
434 $total_pages = (int) max( 1, $query->max_num_pages );
435 }
436
437 $response = rest_ensure_response( $templates );
438 $response->header( 'X-WP-Total', (string) $total_items );
439 $response->header( 'X-WP-TotalPages', (string) max( 1, $total_pages ) );
440
441 return $response;
442 }
443
444 /**
445 * Get a single template.
446 * Supports both numeric IDs (database) and string IDs (virtual).
447 *
448 * @param WP_REST_Request $request Full details about the request.
449 *
450 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
451 */
452 public function get_item( $request ) {
453 $id = $request['id'];
454 $type = $request->get_param( 'type' ) ?? 'receipt';
455
456 // Check if it's a numeric ID (database template).
457 if ( is_numeric( $id ) ) {
458 $template = TemplatesManager::get_template( (int) $id );
459 } else {
460 // It's a virtual template ID.
461 $template = TemplatesManager::get_virtual_template( $id, $type );
462 }
463
464 if ( ! $template ) {
465 return new WP_Error(
466 'wcpos_template_invalid_id',
467 /* translators: REST API schema field label or error message. */
468 __( 'Invalid template ID.', 'woocommerce-pos' ),
469 array( 'status' => 404 )
470 );
471 }
472
473 $enabled = TemplatesManager::get_enabled_templates( $template['type'] ?? 'receipt' );
474 $active_id = ! empty( $enabled ) ? $enabled[0]['id'] : null;
475 $template['is_active'] = ( null !== $active_id && (string) $template['id'] === (string) $active_id );
476
477 return rest_ensure_response( $this->prepare_item_for_response( $template, $request ) );
478 }
479
480 /**
481 * Get the active template for a type.
482 *
483 * @param WP_REST_Request $request Full details about the request.
484 *
485 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
486 */
487 public function get_active( $request ) {
488 $type = $request->get_param( 'type' ) ?? 'receipt';
489 $template = TemplatesManager::get_active_template( $type );
490
491 if ( ! $template ) {
492 return new WP_Error(
493 'wcpos_no_active_template',
494 __( 'No active template found.', 'woocommerce-pos' ),
495 array( 'status' => 404 )
496 );
497 }
498
499 $template['is_active'] = true;
500
501 return rest_ensure_response( $this->prepare_item_for_response( $template, $request ) );
502 }
503
504 /**
505 * Update a single template.
506 *
507 * @param WP_REST_Request $request Full details about the request.
508 *
509 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
510 */
511 public function update_item( $request ) {
512 $id = (int) $request['id'];
513 $post = get_post( $id );
514
515 if ( ! $post || 'wcpos_template' !== $post->post_type ) {
516 return new WP_Error(
517 'wcpos_template_invalid_id',
518 /* translators: REST API schema field label or error message. */
519 __( 'Invalid template ID.', 'woocommerce-pos' ),
520 array( 'status' => 404 )
521 );
522 }
523
524 $update_args = array( 'ID' => $id );
525 $needs_update = false;
526
527 // Update status.
528 $status = $request->get_param( 'status' );
529 if ( null !== $status ) {
530 $update_args['post_status'] = $status;
531 $needs_update = true;
532 }
533
534 // Update menu_order.
535 $menu_order = $request->get_param( 'menu_order' );
536 if ( null !== $menu_order ) {
537 $update_args['menu_order'] = (int) $menu_order;
538 $needs_update = true;
539 }
540
541 if ( $needs_update ) {
542 $result = wp_update_post( $update_args, true );
543 if ( is_wp_error( $result ) ) {
544 return $result;
545 }
546 }
547
548 // Update tax_display meta.
549 $tax_display = $request->get_param( 'tax_display' );
550 if ( null !== $tax_display ) {
551 update_post_meta( $id, '_template_tax_display', $tax_display );
552 }
553
554 $template = TemplatesManager::get_template( $id );
555 if ( ! $template ) {
556 return new WP_Error(
557 'wcpos_template_not_found',
558 /* translators: REST API schema field label or error message. */
559 __( 'Template not found after update.', 'woocommerce-pos' ),
560 array( 'status' => 500 )
561 );
562 }
563
564 $enabled = TemplatesManager::get_enabled_templates( $template['type'] ?? 'receipt' );
565 $active_id = ! empty( $enabled ) ? $enabled[0]['id'] : null;
566 $template['is_active'] = ( null !== $active_id && (string) $template['id'] === (string) $active_id );
567
568 return rest_ensure_response( $this->prepare_item_for_response( $template, $request ) );
569 }
570
571 /**
572 * Batch update templates.
573 *
574 * @param WP_REST_Request $request Full details about the request.
575 *
576 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
577 */
578 public function batch_items( $request ) {
579 $type = $request->get_param( 'type' ) ?? 'receipt';
580
581 // Handle order.
582 $order = $request->get_param( 'order' );
583 if ( \is_array( $order ) ) {
584 TemplatesManager::save_template_order( $order, $type );
585 }
586
587 // Handle disable_virtual.
588 $disable_virtual = $request->get_param( 'disable_virtual' );
589 if ( \is_array( $disable_virtual ) ) {
590 foreach ( $disable_virtual as $vid ) {
591 if ( \is_string( $vid ) ) {
592 TemplatesManager::set_virtual_template_disabled( $vid, true, $type );
593 }
594 }
595 }
596
597 // Handle enable_virtual.
598 $enable_virtual = $request->get_param( 'enable_virtual' );
599 if ( \is_array( $enable_virtual ) ) {
600 foreach ( $enable_virtual as $vid ) {
601 if ( \is_string( $vid ) ) {
602 TemplatesManager::set_virtual_template_disabled( $vid, false, $type );
603 }
604 }
605 }
606
607 // Handle update (existing logic for database templates).
608 $updates = $request->get_param( 'update' );
609 $results = array();
610
611 if ( \is_array( $updates ) ) {
612 foreach ( $updates as $index => $item ) {
613 if ( ! \is_array( $item ) || empty( $item['id'] ) || ! \is_numeric( $item['id'] ) ) {
614 $results[] = array(
615 'id' => \is_array( $item ) ? ( $item['id'] ?? null ) : null,
616 'error' => array(
617 'code' => 'wcpos_template_missing_id',
618 /* translators: %d: batch item index. */
619 'message' => sprintf( __( 'Batch item %d must include a numeric id.', 'woocommerce-pos' ), $index + 1 ),
620 ),
621 );
622 continue;
623 }
624
625 $item_id = (int) $item['id'];
626
627 $item_request = new WP_REST_Request( 'PATCH' );
628 $item_request->set_body_params( $item );
629 $item_request->set_url_params( array( 'id' => $item_id ) );
630
631 $result = $this->update_item( $item_request );
632
633 if ( is_wp_error( $result ) ) {
634 $results[] = array(
635 'id' => $item_id,
636 'error' => array(
637 'code' => $result->get_error_code(),
638 'message' => $result->get_error_message(),
639 ),
640 );
641 } else {
642 $results[] = $result->get_data();
643 }
644 }
645 }
646
647 // Build response.
648 $response_data = array();
649 if ( ! empty( $results ) ) {
650 $response_data['update'] = $results;
651 }
652 if ( \is_array( $order ) ) {
653 $response_data['order'] = TemplatesManager::get_template_order( $type );
654 }
655 if ( \is_array( $disable_virtual ) || \is_array( $enable_virtual ) ) {
656 $response_data['disabled_virtual'] = TemplatesManager::get_disabled_virtual_templates( $type );
657 }
658
659 $response = rest_ensure_response( $response_data );
660
661 // Return 400 only when the request contained nothing but update items and every one failed.
662 $has_non_update_ops = \is_array( $order ) || \is_array( $disable_virtual ) || \is_array( $enable_virtual );
663 if ( ! empty( $results ) && ! $has_non_update_ops ) {
664 $has_success = false;
665 foreach ( $results as $result_item ) {
666 if ( ! isset( $result_item['error'] ) ) {
667 $has_success = true;
668 break;
669 }
670 }
671
672 if ( ! $has_success ) {
673 $response->set_status( 400 );
674 }
675 }
676
677 return $response;
678 }
679
680 /**
681 * Copy a template.
682 *
683 * @param WP_REST_Request $request Full details about the request.
684 *
685 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
686 */
687 public function copy_item( $request ) {
688 $id = (int) $request['id'];
689 $template = TemplatesManager::get_template( $id );
690
691 if ( ! $template ) {
692 return new WP_Error(
693 'wcpos_template_invalid_id',
694 /* translators: REST API schema field label or error message. */
695 __( 'Invalid template ID.', 'woocommerce-pos' ),
696 array( 'status' => 404 )
697 );
698 }
699
700 $source_post = get_post( $id );
701
702 // Create the copy.
703 $new_post_id = wp_insert_post(
704 array(
705 /* translators: %s: original template title */
706 'post_title' => sprintf( __( 'Copy of %s', 'woocommerce-pos' ), $source_post->post_title ),
707 'post_content' => $source_post->post_content,
708 'post_status' => 'draft',
709 'post_type' => 'wcpos_template',
710 'menu_order' => $source_post->menu_order,
711 ),
712 true
713 );
714
715 if ( is_wp_error( $new_post_id ) ) {
716 return $new_post_id;
717 }
718
719 // Copy taxonomies.
720 $taxonomies = get_object_taxonomies( 'wcpos_template' );
721 foreach ( $taxonomies as $taxonomy ) {
722 $terms = wp_get_object_terms( $id, $taxonomy, array( 'fields' => 'slugs' ) );
723 if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
724 wp_set_object_terms( $new_post_id, $terms, $taxonomy );
725 }
726 }
727
728 // Copy meta fields.
729 $meta_keys = array(
730 '_template_description',
731 '_template_language',
732 '_template_engine',
733 '_template_output_type',
734 '_template_tax_display',
735 '_template_paper_width',
736 );
737
738 foreach ( $meta_keys as $meta_key ) {
739 $value = get_post_meta( $id, $meta_key, true );
740 if ( '' !== $value ) {
741 update_post_meta( $new_post_id, $meta_key, $value );
742 }
743 }
744
745 // Bypass wp_kses for offline-capable engines — it strips unknown HTML/XML tags.
746 $engine = $template['engine'] ?? 'legacy-php';
747 if ( \in_array( $engine, TemplatesManager::OFFLINE_CAPABLE_ENGINES, true ) ) {
748 if ( ! TemplatesManager::save_raw_post_content( $new_post_id, $source_post->post_content ) ) {
749 wp_delete_post( $new_post_id, true );
750
751 return new WP_Error(
752 'wcpos_template_copy_failed',
753 __( 'Failed to save copied template content.', 'woocommerce-pos' ),
754 array( 'status' => 500 )
755 );
756 }
757 }
758
759 $new_template = TemplatesManager::get_template( $new_post_id );
760 if ( ! $new_template ) {
761 return new WP_Error(
762 'wcpos_template_copy_failed',
763 __( 'Failed to retrieve copied template.', 'woocommerce-pos' ),
764 array( 'status' => 500 )
765 );
766 }
767
768 $new_template['is_active'] = false;
769
770 $response = rest_ensure_response( $this->prepare_item_for_response( $new_template, $request ) );
771 $response->set_status( 201 );
772
773 return $response;
774 }
775
776 /**
777 * Delete a custom template.
778 *
779 * Virtual (filesystem) templates cannot be deleted.
780 *
781 * @param WP_REST_Request $request Full details about the request.
782 *
783 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
784 */
785 public function delete_item( $request ) {
786 $id = (int) $request['id'];
787 $template = TemplatesManager::get_template( $id );
788
789 if ( ! $template ) {
790 return new WP_Error(
791 'wcpos_template_invalid_id',
792 /* translators: REST API schema field label or error message. */
793 __( 'Invalid template ID.', 'woocommerce-pos' ),
794 array( 'status' => 404 )
795 );
796 }
797
798 if ( ! empty( $template['is_virtual'] ) ) {
799 return new WP_Error(
800 'wcpos_template_cannot_delete',
801 __( 'Built-in templates cannot be deleted.', 'woocommerce-pos' ),
802 array( 'status' => 403 )
803 );
804 }
805
806 $deleted = wp_delete_post( $id, true );
807
808 if ( ! $deleted ) {
809 return new WP_Error(
810 'wcpos_template_delete_failed',
811 __( 'Failed to delete template.', 'woocommerce-pos' ),
812 array( 'status' => 500 )
813 );
814 }
815
816 return rest_ensure_response(
817 array(
818 'deleted' => true,
819 'id' => $id,
820 )
821 );
822 }
823
824 /**
825 * Install a gallery template.
826 *
827 * @param WP_REST_Request $request Full details about the request.
828 *
829 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
830 */
831 public function install_gallery_item( $request ) {
832 $gallery_key = $request->get_param( 'gallery_key' );
833 $result = TemplatesManager::install_gallery_template( $gallery_key );
834
835 if ( is_wp_error( $result ) ) {
836 $status = $this->get_wp_error_status( $result, 400 );
837 $result->add_data( array( 'status' => $status ) );
838 return $result;
839 }
840
841 $template = TemplatesManager::get_template( $result );
842 if ( ! $template ) {
843 return new WP_Error(
844 'wcpos_template_install_failed',
845 __( 'Template was installed but could not be retrieved.', 'woocommerce-pos' ),
846 array( 'status' => 500 )
847 );
848 }
849
850 $template['is_active'] = false;
851
852 $response = rest_ensure_response( $this->prepare_item_for_response( $template, $request ) );
853 $response->set_status( 201 );
854
855 return $response;
856 }
857
858 /**
859 * Preview a template.
860 *
861 * Returns a preview URL for the template rendered with a sample POS order.
862 *
863 * @param WP_REST_Request $request Full details about the request.
864 *
865 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
866 */
867 public function preview_item( $request ) {
868 $id = $request['id'];
869 $type = $request->get_param( 'type' ) ?? 'receipt';
870
871 // Validate the template exists (database, virtual, or gallery).
872 if ( is_numeric( $id ) ) {
873 $template = TemplatesManager::get_template( (int) $id );
874 } else {
875 $template = TemplatesManager::get_virtual_template( $id, $type );
876 if ( ! $template ) {
877 $template = TemplatesManager::get_gallery_template_by_key( $id );
878 }
879 }
880
881 if ( ! $template ) {
882 return new WP_Error(
883 'wcpos_template_invalid_id',
884 /* translators: REST API schema field label or error message. */
885 __( 'Invalid template ID.', 'woocommerce-pos' ),
886 array( 'status' => 404 )
887 );
888 }
889
890 // Build receipt data: real order if order_id provided, otherwise sample data.
891 $raw_order_id = $request->get_param( 'order_id' );
892 $order = null;
893 $order_id = 0;
894
895 if ( 'latest' === $raw_order_id ) {
896 $latest = wc_get_orders(
897 array(
898 'limit' => 1,
899 'orderby' => 'date',
900 'order' => 'DESC',
901 'status' => array( 'completed', 'processing', 'on-hold', 'pending' ),
902 'created_via' => 'woocommerce-pos',
903 )
904 );
905 if ( ! empty( $latest ) ) {
906 $order = $latest[0];
907 $order_id = $order->get_id();
908 }
909 } elseif ( (int) $raw_order_id > 0 ) {
910 $order_id = (int) $raw_order_id;
911 $order = wc_get_order( $order_id );
912 if ( ! $order || ! \wcpos_is_pos_order( $order ) ) {
913 return new WP_Error(
914 'wcpos_invalid_order',
915 __( 'Order not found or is not a POS order.', 'woocommerce-pos' ),
916 array( 'status' => 404 )
917 );
918 }
919 }
920
921 $store_id = (int) $request->get_param( 'store_id' );
922 $request_pos_store = null;
923 if ( $store_id > 0 ) {
924 $request_pos_store = wcpos_get_store( $store_id );
925 if ( ! \is_object( $request_pos_store ) ) {
926 return new WP_Error(
927 'wcpos_invalid_store',
928 /* translators: REST API schema field label or error message. */
929 __( 'Store not found.', 'woocommerce-pos' ),
930 array( 'status' => 404 )
931 );
932 }
933 }
934
935 if ( $order ) {
936 $receipt_data = ( new Receipt_Data_Builder() )->build( $order, 'live', $request_pos_store );
937 } else {
938 $pos_store = null === $request_pos_store ? wcpos_get_store() : $request_pos_store;
939 $preview_data_profile = isset( $template['preview_data'] ) && is_string( $template['preview_data'] )
940 ? $template['preview_data']
941 : null;
942 $receipt_data = null !== $preview_data_profile
943 ? ( new Receipt_Preview_Fixture_Loader() )->build( $preview_data_profile, $pos_store )
944 : ( new Preview_Receipt_Builder() )->build( $pos_store );
945 }
946
947 $currency = $receipt_data['order']['currency'] ?? 'USD';
948 $formatted_data = Receipt_Data_Schema::format_money_fields( $receipt_data, $currency );
949
950 // Determine engine.
951 $engine = $template['engine'] ?? 'legacy-php';
952
953 // Thermal (ESC/POS) templates are rendered by the client using template_content + receipt_data.
954 if ( 'thermal' === $engine ) {
955 return rest_ensure_response( $this->prepare_non_legacy_preview_response( $template, $formatted_data, $order_id, $id ) );
956 }
957
958 // Non-thermal with a real order.
959 if ( $order ) {
960 if ( 'logicless' === $engine ) {
961 // Server-render HTML for gallery/store-edit, keep receipt_data for the editor.
962 $formatted_data['t'] = true;
963
964 $response = $this->prepare_non_legacy_preview_response( $template, $formatted_data, $order_id, $id );
965
966 try {
967 // Keep preview_html for backward compatibility and diagnostics only.
968 // It is passed through wp_kses_post(), which can strip layout-critical CSS
969 // such as display:flex/display:grid. Template Studio and Template Gallery
970 // must render visual previews from template_content + receipt_data instead.
971 $response['preview_html'] = $this->render_logicless_preview( $template, $formatted_data );
972 } catch ( \Mustache\Exception\SyntaxException $e ) {
973 // Malformed template — still return receipt_data so the editor can render.
974 unset( $e );
975 }
976
977 return rest_ensure_response( $response );
978 }
979
980 // Legacy-php needs a server-side iframe URL.
981 $order_key = $order->get_order_key();
982 $preview_params = array(
983 'key' => $order_key,
984 'wcpos_preview_template' => $id,
985 );
986 if ( $store_id > 0 ) {
987 $preview_params['store_id'] = $store_id;
988 }
989 $preview_url = add_query_arg(
990 $preview_params,
991 wcpos_checkout_url( 'wcpos-receipt/' . $order_id )
992 );
993
994 return rest_ensure_response(
995 array(
996 'engine' => 'legacy-php',
997 'preview_url' => $preview_url,
998 'order_id' => $order_id,
999 'template_id' => $id,
1000 )
1001 );
1002 }
1003
1004 // Sample data (no real order): render server-side for all engines.
1005
1006 if ( 'logicless' === $engine ) {
1007 $response = $this->prepare_non_legacy_preview_response( $template, $formatted_data, 0, $id );
1008
1009 try {
1010 // Keep preview_html for backward compatibility and diagnostics only.
1011 // It is passed through wp_kses_post(), which can strip layout-critical CSS
1012 // such as display:flex/display:grid. Template Studio and Template Gallery
1013 // must render visual previews from template_content + receipt_data instead.
1014 $response['preview_html'] = $this->render_logicless_preview( $template, $formatted_data );
1015 } catch ( \Mustache\Exception\SyntaxException $e ) {
1016 $response['preview_html'] = '<div style="padding:40px;text-align:center;font-family:sans-serif;color:#c00;">'
1017 . esc_html__( 'Mustache template syntax error. Check your template.', 'woocommerce-pos' )
1018 . '</div>';
1019 }
1020
1021 return rest_ensure_response( $response );
1022 }
1023
1024 // Legacy-php templates execute arbitrary PHP that expects a real WC_Order
1025 // in scope. Sample receipt data cannot stand in for one, so signal the
1026 // editor to prompt for a POS order instead of rendering with a null order.
1027 return rest_ensure_response(
1028 array(
1029 'engine' => 'legacy-php',
1030 'requires_order' => true,
1031 'order_id' => 0,
1032 'template_id' => $id,
1033 )
1034 );
1035 }
1036
1037 /**
1038 * Prepare the normalized preview payload for non-legacy renderers.
1039 *
1040 * JS consumers render from template_content + receipt_data. Temporary diagnostic
1041 * fields such as preview_html may be added by callers during the migration.
1042 *
1043 * @param array $template Template metadata.
1044 * @param array $receipt_data Formatted receipt data.
1045 * @param int $order_id Order ID, or 0 for sample data.
1046 * @param int|string $template_id Numeric database ID or virtual/gallery key.
1047 *
1048 * @return array<string, mixed>
1049 */
1050 private function prepare_non_legacy_preview_response( array $template, array $receipt_data, int $order_id, $template_id ): array {
1051 return array(
1052 'engine' => $template['engine'] ?? 'logicless',
1053 'template_content' => isset( $template['content'] ) && \is_string( $template['content'] ) ? $template['content'] : '',
1054 'receipt_data' => $receipt_data,
1055 'paper_width' => $template['paper_width'] ?? null,
1056 'order_id' => $order_id,
1057 'template_id' => is_numeric( $template_id ) ? (int) $template_id : (string) $template_id,
1058 );
1059 }
1060
1061
1062 /**
1063 * Render a logicless template with preview data and return the HTML string.
1064 *
1065 * Uses output buffering to capture the Logicless_Renderer output
1066 * without requiring a real WC_Abstract_Order.
1067 *
1068 * @param array $template Template metadata including content.
1069 * @param array $formatted_data Receipt data with money fields pre-formatted.
1070 *
1071 * @return string Rendered HTML.
1072 */
1073 private function render_logicless_preview( array $template, array $formatted_data ): string {
1074 $content = isset( $template['content'] ) && \is_string( $template['content'] ) ? $template['content'] : '';
1075
1076 if ( '' === $content ) {
1077 return '<!-- Empty logicless receipt template -->';
1078 }
1079
1080 // Strip HTML comments — wp_kses_post removes the delimiters but leaves the text.
1081 $content = preg_replace( '/<!--.*?-->/s', '', $content );
1082
1083 // Safety net for unresolved {{#t}}...{{/t}} markers in gallery templates.
1084 $formatted_data['t'] = true;
1085
1086 $flags = ENT_QUOTES | ENT_SUBSTITUTE;
1087 $mustache = new \Mustache\Engine(
1088 array(
1089 'entity_flags' => $flags,
1090 'escape' => function ( $value ) use ( $flags ) {
1091 if ( \is_array( $value ) ) {
1092 return '';
1093 }
1094
1095 return htmlspecialchars( (string) $value, $flags, 'UTF-8' );
1096 },
1097 )
1098 );
1099
1100 $output = $mustache->render( $content, $formatted_data );
1101
1102 return wp_kses_post( $output );
1103 }
1104
1105 /**
1106 * Get gallery templates.
1107 *
1108 * @param WP_REST_Request $request Full details about the request.
1109 *
1110 * @return WP_REST_Response Response object on success.
1111 */
1112 public function get_gallery_items( $request ) {
1113 $type = $request->get_param( 'type' );
1114 $category = $request->get_param( 'category' );
1115
1116 $templates = TemplatesManager::get_gallery_templates( $type, $category );
1117
1118 // Strip internal content_file and add UUID to gallery templates.
1119 $templates = array_map(
1120 function ( $template ) {
1121 unset( $template['content_file'] );
1122 $type = $template['type'] ?? 'receipt';
1123 $id = $template['key'] ?? $template['id'] ?? '';
1124 $template['uuid'] = $this->get_virtual_template_uuid( (string) $id, $type );
1125 return $template;
1126 },
1127 $templates
1128 );
1129
1130 return rest_ensure_response( $templates );
1131 }
1132
1133 /**
1134 * Prepare template for response.
1135 *
1136 * @param array $template Template data.
1137 * @param WP_REST_Request $request Request object.
1138 *
1139 * @return array|WP_REST_Response Prepared template data.
1140 */
1141 public function prepare_item_for_response( $template, $request ) {
1142 $context = $request->get_param( 'context' ) ?? 'view';
1143 $engine = $template['engine'] ?? 'legacy-php';
1144
1145 // Add UUID.
1146 $template['uuid'] = $this->get_template_uuid( $template );
1147
1148 // Add computed fields.
1149 $template['offline_capable'] = in_array( $engine, TemplatesManager::OFFLINE_CAPABLE_ENGINES, true );
1150 $template['menu_order'] = isset( $template['menu_order'] ) ? (int) $template['menu_order'] : 0;
1151
1152 // Normalize date_modified_gmt to ISO-like format (Y-m-d\TH:i:s) for consistency with other endpoints.
1153 if ( isset( $template['date_modified_gmt'] ) && preg_match( '/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $template['date_modified_gmt'] ) ) {
1154 $template['date_modified_gmt'] = preg_replace( '/(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})/', '$1T$2', $template['date_modified_gmt'] );
1155 }
1156
1157 // Content handling:
1158 // - In 'edit' context: always include content (for admin editor)
1159 // - In 'view' context: include content for offline-capable engines (logicless, thermal)
1160 // - PHP templates: strip content in view context (can't be rendered client-side).
1161 if ( 'edit' !== $context ) {
1162 if ( ! in_array( $engine, TemplatesManager::OFFLINE_CAPABLE_ENGINES, true ) ) {
1163 unset( $template['content'] );
1164 }
1165 }
1166
1167 // Add is_disabled for virtual templates (scoped by type).
1168 if ( ! empty( $template['is_virtual'] ) ) {
1169 $type = $request->get_param( 'type' ) ?? 'receipt';
1170 $template['is_disabled'] = TemplatesManager::is_virtual_template_disabled( (string) $template['id'], $type );
1171 }
1172
1173 return $template;
1174 }
1175
1176 /**
1177 * Get or create a UUID for a template.
1178 *
1179 * Database templates store a random UUID v4 in postmeta.
1180 * Virtual templates use a deterministic UUID v5 derived from the template ID.
1181 *
1182 * @param array $template Template data.
1183 *
1184 * @return string UUID string.
1185 */
1186 private function get_template_uuid( array $template ): string {
1187 if ( ! empty( $template['is_virtual'] ) ) {
1188 $type = $template['type'] ?? 'receipt';
1189 return $this->get_virtual_template_uuid( (string) $template['id'], $type );
1190 }
1191
1192 return $this->get_database_template_uuid( (int) $template['id'] );
1193 }
1194
1195 /**
1196 * Generate a deterministic UUID v5 for a virtual template.
1197 *
1198 * Uses the URL namespace with a wcpos-specific prefix so the same
1199 * template ID + type always produces the same UUID across installations.
1200 * Type is included because the same ID (e.g. 'plugin-core') can exist
1201 * for both 'receipt' and 'report' template types.
1202 *
1203 * @param string $template_id Virtual template ID (e.g. 'plugin-core').
1204 * @param string $type Template type (e.g. 'receipt', 'report').
1205 *
1206 * @return string UUID v5 string.
1207 */
1208 private function get_virtual_template_uuid( string $template_id, string $type ): string {
1209 try {
1210 return Uuid::uuid5( Uuid::NAMESPACE_URL, 'https://wcpos.com/template/' . $type . '/' . $template_id )->toString();
1211 } catch ( \Exception $e ) {
1212 Logger::error( 'Virtual template UUID generation failed: ' . $e->getMessage() );
1213 return '';
1214 }
1215 }
1216
1217 /**
1218 * Get or create a UUID v4 for a database template.
1219 *
1220 * Stores the UUID in postmeta with the standard _woocommerce_pos_uuid key.
1221 *
1222 * @param int $post_id Template post ID.
1223 *
1224 * @return string UUID v4 string.
1225 */
1226 private function get_database_template_uuid( int $post_id ): string {
1227 $uuid = get_post_meta( $post_id, '_woocommerce_pos_uuid', true );
1228
1229 if ( is_string( $uuid ) && '' !== $uuid && Uuid::isValid( $uuid ) ) {
1230 return $uuid;
1231 }
1232
1233 try {
1234 $uuid = Uuid::uuid4()->toString();
1235 } catch ( \Exception $e ) {
1236 Logger::error( 'Database template UUID generation failed: ' . $e->getMessage() );
1237 return '';
1238 }
1239
1240 if ( add_post_meta( $post_id, '_woocommerce_pos_uuid', $uuid, true ) ) {
1241 return $uuid;
1242 }
1243
1244 // Another request may have written a UUID concurrently — use it.
1245 $persisted_uuid = get_post_meta( $post_id, '_woocommerce_pos_uuid', true );
1246 if ( is_string( $persisted_uuid ) && '' !== $persisted_uuid && Uuid::isValid( $persisted_uuid ) ) {
1247 return $persisted_uuid;
1248 }
1249
1250 Logger::error( 'Database template UUID persistence failed.', array( 'post_id' => $post_id ) );
1251 return '';
1252 }
1253
1254 /**
1255
1256 /**
1257 * Get collection parameters.
1258 *
1259 * @return array Collection parameters.
1260 */
1261 public function get_collection_params() {
1262 return array(
1263 'page' => array(
1264 'description' => __( 'Current page of the collection.', 'woocommerce-pos' ),
1265 'type' => 'integer',
1266 'default' => 1,
1267 'sanitize_callback' => 'absint',
1268 'validate_callback' => 'rest_validate_request_arg',
1269 ),
1270 'per_page' => array(
1271 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce-pos' ),
1272 'type' => 'integer',
1273 'default' => -1,
1274 'sanitize_callback' => array( $this, 'sanitize_per_page_param' ),
1275 'validate_callback' => array( $this, 'validate_per_page_param' ),
1276 ),
1277 'type' => array(
1278 'description' => __( 'Filter by template type.', 'woocommerce-pos' ),
1279 'type' => 'string',
1280 'default' => 'receipt',
1281 'enum' => array( 'receipt', 'report' ),
1282 'sanitize_callback' => 'sanitize_text_field',
1283 'validate_callback' => 'rest_validate_request_arg',
1284 ),
1285 'context' => array(
1286 'description' => __( 'Scope under which the request is made.', 'woocommerce-pos' ),
1287 'type' => 'string',
1288 'default' => 'view',
1289 'enum' => array( 'view', 'edit' ),
1290 'sanitize_callback' => 'sanitize_text_field',
1291 'validate_callback' => 'rest_validate_request_arg',
1292 ),
1293 'search' => array(
1294 'description' => __( 'Search templates by title or description.', 'woocommerce-pos' ),
1295 'type' => 'string',
1296 'sanitize_callback' => 'sanitize_text_field',
1297 'validate_callback' => 'rest_validate_request_arg',
1298 ),
1299 'category' => array(
1300 'description' => __( 'Filter by template category slug.', 'woocommerce-pos' ),
1301 'type' => 'string',
1302 'sanitize_callback' => 'sanitize_text_field',
1303 'validate_callback' => 'rest_validate_request_arg',
1304 ),
1305 'modified_after' => array(
1306 'description' => __( 'Limit to templates modified after this ISO 8601 date.', 'woocommerce-pos' ),
1307 'type' => 'string',
1308 'sanitize_callback' => 'sanitize_text_field',
1309 'validate_callback' => 'rest_validate_request_arg',
1310 ),
1311 'store_id' => array(
1312 'description' => __( 'Limit results to templates resolved for a specific store.', 'woocommerce-pos' ),
1313 'type' => 'integer',
1314 'default' => 0,
1315 'sanitize_callback' => 'absint',
1316 'validate_callback' => 'rest_validate_request_arg',
1317 ),
1318 );
1319 }
1320
1321 /**
1322 * Get all templates for the admin list, including inactive templates.
1323 *
1324 * Runtime/store contexts use TemplatesManager::get_enabled_templates(); the
1325 * admin gallery needs the broader list so draft posts and disabled virtual
1326 * templates remain visible with their Active switch off.
1327 *
1328 * @param string $type Template type.
1329 *
1330 * @return array<int,array<string,mixed>> Template data arrays.
1331 */
1332 private function get_admin_template_list( string $type ): array {
1333 $templates = TemplatesManager::detect_filesystem_templates( $type );
1334
1335 $posts = get_posts(
1336 array(
1337 'post_type' => 'wcpos_template',
1338 'post_status' => array( 'publish', 'draft' ),
1339 'posts_per_page' => -1,
1340 'orderby' => 'menu_order',
1341 'order' => 'ASC',
1342 'tax_query' => array(
1343 array(
1344 'taxonomy' => 'wcpos_template_type',
1345 'field' => 'slug',
1346 'terms' => $type,
1347 ),
1348 ),
1349 )
1350 );
1351
1352 foreach ( $posts as $post ) {
1353 $template = TemplatesManager::get_template( $post->ID );
1354 if ( $template ) {
1355 $templates[] = $template;
1356 }
1357 }
1358
1359 $order = TemplatesManager::get_template_order( $type );
1360 if ( ! empty( $order ) ) {
1361 $order_map = array_flip( array_map( 'strval', $order ) );
1362 $original_positions = array();
1363 foreach ( $templates as $index => $template ) {
1364 $original_positions[ (string) $template['id'] ] = $index;
1365 }
1366
1367 usort(
1368 $templates,
1369 function ( $a, $b ) use ( $order_map, $original_positions ) {
1370 $pos_a = $order_map[ (string) $a['id'] ] ?? PHP_INT_MAX;
1371 $pos_b = $order_map[ (string) $b['id'] ] ?? PHP_INT_MAX;
1372
1373 if ( $pos_a === $pos_b ) {
1374 $idx_a = $original_positions[ (string) $a['id'] ] ?? PHP_INT_MAX;
1375 $idx_b = $original_positions[ (string) $b['id'] ] ?? PHP_INT_MAX;
1376 return $idx_a <=> $idx_b;
1377 }
1378
1379 return $pos_a <=> $pos_b;
1380 }
1381 );
1382 }
1383
1384 return $templates;
1385 }
1386
1387 /**
1388 * Get template IDs matching title/content search OR description meta search.
1389 *
1390 * @param string $search Search term.
1391 * @param array $args Base query args (without search constraints).
1392 *
1393 * @return int[] Matching template IDs.
1394 */
1395 private function get_search_matching_template_ids( string $search, array $args ): array {
1396 $base_query_args = $args;
1397 unset( $base_query_args['post__in'] );
1398 $base_query_args['fields'] = 'ids';
1399 $base_query_args['posts_per_page'] = -1;
1400 $base_query_args['paged'] = 1;
1401 $base_query_args['no_found_rows'] = true;
1402
1403 $title_query_args = $base_query_args;
1404 $title_query_args['s'] = $search;
1405 $title_query = new WP_Query( $title_query_args );
1406
1407 $description_query_args = $base_query_args;
1408 $description_query_args['meta_query'] = array(
1409 array(
1410 'key' => '_template_description',
1411 'value' => $search,
1412 'compare' => 'LIKE',
1413 ),
1414 );
1415 $description_query = new WP_Query( $description_query_args );
1416
1417 return array_values( array_unique( array_merge( $title_query->posts, $description_query->posts ) ) );
1418 }
1419
1420 /**
1421 * Preserve -1 for "all items", otherwise sanitize as a positive integer.
1422 *
1423 * @param mixed $value Requested per_page value.
1424 *
1425 * @return int
1426 */
1427 public function sanitize_per_page_param( $value ): int {
1428 $value = (int) $value;
1429 return -1 === $value ? -1 : absint( $value );
1430 }
1431
1432 /**
1433 * Validate per_page as either -1 or a positive integer.
1434 *
1435 * @param mixed $value Requested per_page value.
1436 *
1437 * @return bool
1438 */
1439 public function validate_per_page_param( $value ): bool {
1440 $value = (int) $value;
1441 return -1 === $value || $value > 0;
1442 }
1443
1444 /**
1445 * Get a valid HTTP status code from WP_Error data.
1446 *
1447 * @param WP_Error $error Error object.
1448 * @param int $fallback_code Fallback status code.
1449 *
1450 * @return int
1451 */
1452 private function get_wp_error_status( WP_Error $error, int $fallback_code = 400 ): int {
1453 $error_data = $error->get_error_data();
1454 $status = null;
1455
1456 if ( \is_array( $error_data ) && isset( $error_data['status'] ) ) {
1457 $status = $error_data['status'];
1458 } elseif ( \is_numeric( $error_data ) ) {
1459 $status = $error_data;
1460 }
1461
1462 if ( null === $status ) {
1463 return $fallback_code;
1464 }
1465
1466 $status = (int) $status;
1467 return ( $status >= 100 && $status <= 599 ) ? $status : $fallback_code;
1468 }
1469
1470 /**
1471 * Check if a given request has access to read templates.
1472 *
1473 * @param WP_REST_Request $request Full details about the request.
1474 *
1475 * @return bool|WP_Error True if the request has access, WP_Error object otherwise.
1476 */
1477 public function get_items_permissions_check( $request ) {
1478 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
1479 return new WP_Error(
1480 'wcpos_rest_cannot_view',
1481 __( 'Sorry, you cannot list templates.', 'woocommerce-pos' ),
1482 array( 'status' => rest_authorization_required_code() )
1483 );
1484 }
1485
1486 // The 'edit' context exposes full template content (including PHP); require manage capability.
1487 if ( 'edit' === $request->get_param( 'context' ) && ! current_user_can( 'manage_woocommerce_pos' ) ) {
1488 return new WP_Error(
1489 'wcpos_rest_cannot_edit',
1490 __( 'Sorry, you are not allowed to edit templates.', 'woocommerce-pos' ),
1491 array( 'status' => rest_authorization_required_code() )
1492 );
1493 }
1494
1495 return true;
1496 }
1497
1498 /**
1499 * Check if a given request has access to read a specific template.
1500 *
1501 * @param WP_REST_Request $request Full details about the request.
1502 *
1503 * @return bool|WP_Error True if the request has access, WP_Error object otherwise.
1504 */
1505 public function get_item_permissions_check( $request ) {
1506 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
1507 return new WP_Error(
1508 'wcpos_rest_cannot_view',
1509 __( 'Sorry, you cannot view this template.', 'woocommerce-pos' ),
1510 array( 'status' => rest_authorization_required_code() )
1511 );
1512 }
1513
1514 // The 'edit' context exposes full template content (including PHP); require manage capability.
1515 if ( 'edit' === $request->get_param( 'context' ) && ! current_user_can( 'manage_woocommerce_pos' ) ) {
1516 return new WP_Error(
1517 'wcpos_rest_cannot_edit',
1518 __( 'Sorry, you are not allowed to edit this template.', 'woocommerce-pos' ),
1519 array( 'status' => rest_authorization_required_code() )
1520 );
1521 }
1522
1523 return true;
1524 }
1525
1526 /**
1527 * Check if a given request has access to preview a template.
1528 *
1529 * Preview returns order data, so it requires the stricter manage capability.
1530 *
1531 * @param WP_REST_Request $_request Full details about the request.
1532 *
1533 * @return bool|WP_Error True if the request has access, WP_Error object otherwise.
1534 */
1535 public function preview_item_permissions_check( $_request ) {
1536 if ( ! current_user_can( 'manage_woocommerce_pos' ) ) {
1537 return new WP_Error(
1538 'wcpos_rest_cannot_view',
1539 __( 'Sorry, you cannot preview this template.', 'woocommerce-pos' ),
1540 array( 'status' => rest_authorization_required_code() )
1541 );
1542 }
1543
1544 return true;
1545 }
1546
1547 /**
1548 * Check if a given request has access to update templates.
1549 *
1550 * @param WP_REST_Request $request Full details about the request.
1551 *
1552 * @return bool|WP_Error True if the request has access, WP_Error object otherwise.
1553 */
1554 public function update_item_permissions_check( $request ) {
1555 if ( ! current_user_can( 'manage_woocommerce_pos' ) ) {
1556 return new WP_Error(
1557 'wcpos_rest_cannot_update',
1558 __( 'Sorry, you cannot update templates.', 'woocommerce-pos' ),
1559 array( 'status' => rest_authorization_required_code() )
1560 );
1561 }
1562
1563 return true;
1564 }
1565 }
1566