PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.6
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.6
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 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 All 163 releases
woocommerce-pos / includes / Templates.php

Templates.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.6, at includes/Templates.php

1,396 lines 49.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Templates Class.
4 *
5 * Handles registration and management of templates.
6 * Plugin and theme templates are detected from filesystem (virtual).
7 * Custom templates are stored in database as wcpos_template posts.
8 *
9 * @author Paul Kilmurray <paul@kilbot.com>
10 *
11 * @see http://wcpos.com
12 * @package WCPOS\WooCommercePOS
13 */
14
15 namespace WCPOS\WooCommercePOS;
16
17 use WP_Query;
18 use WCPOS\WooCommercePOS\Services\Receipt_I18n_Labels;
19 use WCPOS\WooCommercePOS\Templates\Gallery_Registry;
20
21 /**
22 * Templates class.
23 */
24 class Templates {
25 /**
26 * Virtual template ID constants.
27 */
28 const TEMPLATE_THEME = 'theme';
29 const TEMPLATE_PLUGIN_PRO = 'plugin-pro';
30 const TEMPLATE_PLUGIN_CORE = 'plugin-core';
31 const TEMPLATE_WP_OVERNIGHT_INVOICE = 'wp-overnight-invoice';
32 const TEMPLATE_WP_OVERNIGHT_PACKING_SLIP = 'wp-overnight-packing-slip';
33
34 /**
35 * Supported template types.
36 */
37 const SUPPORTED_TYPES = array( 'receipt', 'report' );
38
39 /**
40 * Engines that support offline (client-side) rendering.
41 */
42 const OFFLINE_CAPABLE_ENGINES = array( 'logicless', 'thermal' );
43
44 /**
45 * File extensions a bundled gallery template's content file can use.
46 */
47 const GALLERY_CONTENT_EXTENSIONS = array( 'html', 'php', 'xml' );
48
49 /**
50 * Per-request cache of installed gallery template preview data profiles.
51 *
52 * @var array<string,string|null>
53 */
54 private static $gallery_preview_data_cache = array();
55
56 /**
57 * Constructor.
58 */
59 public function __construct() {
60 // Register immediately since this is already being called during 'init'.
61 $this->register_post_type();
62 $this->register_taxonomy();
63 }
64
65 /**
66 * Register the custom post type for templates.
67 * Only custom user-created templates are stored in the database.
68 *
69 * @return void
70 */
71 public function register_post_type(): void {
72 $labels = array(
73 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Templates', 'Post Type General Name', 'woocommerce-pos' ),
74 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template', 'Post Type Singular Name', 'woocommerce-pos' ),
75 'menu_name' => /* translators: Receipt template post type or template option label. */ __( 'Templates', 'woocommerce-pos' ),
76 'name_admin_bar' => /* translators: Receipt template post type or template option label. */ __( 'Template', 'woocommerce-pos' ),
77 'archives' => /* translators: Receipt template post type or template option label. */ __( 'Template Archives', 'woocommerce-pos' ),
78 'attributes' => /* translators: Receipt template post type or template option label. */ __( 'Template Attributes', 'woocommerce-pos' ),
79 'parent_item_colon' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template:', 'woocommerce-pos' ),
80 'all_items' => /* translators: Receipt template post type or template option label. */ __( 'Templates', 'woocommerce-pos' ),
81 'add_new_item' => /* translators: Receipt template post type or template option label. */ __( 'Add New Template', 'woocommerce-pos' ),
82 'add_new' => /* translators: Receipt template post type or template option label. */ __( 'Add New', 'woocommerce-pos' ),
83 'new_item' => /* translators: Receipt template post type or template option label. */ __( 'New Template', 'woocommerce-pos' ),
84 'edit_item' => /* translators: Receipt template post type or template option label. */ __( 'Edit Template', 'woocommerce-pos' ),
85 'update_item' => /* translators: Receipt template post type or template option label. */ __( 'Update Template', 'woocommerce-pos' ),
86 'view_item' => /* translators: Receipt template post type or template option label. */ __( 'View Template', 'woocommerce-pos' ),
87 'view_items' => /* translators: Receipt template post type or template option label. */ __( 'View Templates', 'woocommerce-pos' ),
88 'search_items' => /* translators: Receipt template post type or template option label. */ __( 'Search Template', 'woocommerce-pos' ),
89 'not_found' => /* translators: Receipt template post type or template option label. */ __( 'Not found', 'woocommerce-pos' ),
90 'not_found_in_trash' => __( 'Not found in Trash', 'woocommerce-pos' ),
91 'featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Featured Image', 'woocommerce-pos' ),
92 'set_featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Set featured image', 'woocommerce-pos' ),
93 'remove_featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Remove featured image', 'woocommerce-pos' ),
94 'use_featured_image' => __( 'Use as featured image', 'woocommerce-pos' ),
95 'insert_into_item' => /* translators: Receipt template post type or template option label. */ __( 'Insert into template', 'woocommerce-pos' ),
96 'uploaded_to_this_item' => __( 'Uploaded to this template', 'woocommerce-pos' ),
97 'items_list' => /* translators: Receipt template post type or template option label. */ __( 'Templates list', 'woocommerce-pos' ),
98 'items_list_navigation' => /* translators: Receipt template post type or template option label. */ __( 'Templates list navigation', 'woocommerce-pos' ),
99 'filter_items_list' => /* translators: Receipt template post type or template option label. */ __( 'Filter templates list', 'woocommerce-pos' ),
100 );
101
102 $args = array(
103 'label' => /* translators: Receipt template post type or template option label. */ __( 'Template', 'woocommerce-pos' ),
104 'description' => /* translators: Receipt template post type or template option label. */ __( 'POS Templates', 'woocommerce-pos' ),
105 'labels' => $labels,
106 'supports' => array( 'title', 'editor', 'revisions' ),
107 'taxonomies' => array( 'wcpos_template_type', 'wcpos_template_category' ),
108 'hierarchical' => false,
109 'public' => false,
110 'show_ui' => true,
111 'show_in_menu' => false, // Hidden from menu; Gallery SPA provides the submenu entry.
112 'menu_position' => 5,
113 'show_in_admin_bar' => true,
114 'show_in_nav_menus' => false,
115 'can_export' => true,
116 'has_archive' => false,
117 'exclude_from_search' => true,
118 'publicly_queryable' => false,
119 'capability_type' => 'post',
120 'capabilities' => array(
121 'edit_post' => 'manage_woocommerce_pos',
122 'read_post' => 'manage_woocommerce_pos',
123 'delete_post' => 'manage_woocommerce_pos',
124 'edit_posts' => 'manage_woocommerce_pos',
125 'edit_others_posts' => 'manage_woocommerce_pos',
126 'delete_posts' => 'manage_woocommerce_pos',
127 'publish_posts' => 'manage_woocommerce_pos',
128 'read_private_posts' => 'manage_woocommerce_pos',
129 ),
130 'show_in_rest' => false, // Disable Gutenberg.
131 'rest_base' => 'wcpos_templates',
132 );
133
134 register_post_type( 'wcpos_template', $args );
135 }
136
137 /**
138 * Register the taxonomy for template types.
139 *
140 * @return void
141 */
142 public function register_taxonomy(): void {
143 $labels = array(
144 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Types', 'Taxonomy General Name', 'woocommerce-pos' ),
145 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Type', 'Taxonomy Singular Name', 'woocommerce-pos' ),
146 'menu_name' => /* translators: Receipt template post type or template option label. */ __( 'Template Types', 'woocommerce-pos' ),
147 'all_items' => /* translators: Receipt template post type or template option label. */ __( 'All Template Types', 'woocommerce-pos' ),
148 'parent_item' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template Type', 'woocommerce-pos' ),
149 'parent_item_colon' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template Type:', 'woocommerce-pos' ),
150 'new_item_name' => /* translators: Receipt template post type or template option label. */ __( 'New Template Type Name', 'woocommerce-pos' ),
151 'add_new_item' => /* translators: Receipt template post type or template option label. */ __( 'Add New Template Type', 'woocommerce-pos' ),
152 'edit_item' => /* translators: Receipt template post type or template option label. */ __( 'Edit Template Type', 'woocommerce-pos' ),
153 'update_item' => /* translators: Receipt template post type or template option label. */ __( 'Update Template Type', 'woocommerce-pos' ),
154 'view_item' => /* translators: Receipt template post type or template option label. */ __( 'View Template Type', 'woocommerce-pos' ),
155 'separate_items_with_commas' => __( 'Separate template types with commas', 'woocommerce-pos' ),
156 'add_or_remove_items' => __( 'Add or remove template types', 'woocommerce-pos' ),
157 'choose_from_most_used' => __( 'Choose from the most used', 'woocommerce-pos' ),
158 'popular_items' => /* translators: Receipt template post type or template option label. */ __( 'Popular Template Types', 'woocommerce-pos' ),
159 'search_items' => /* translators: Receipt template post type or template option label. */ __( 'Search Template Types', 'woocommerce-pos' ),
160 'not_found' => /* translators: Receipt template post type or template option label. */ __( 'Not Found', 'woocommerce-pos' ),
161 'no_terms' => /* translators: Receipt template post type or template option label. */ __( 'No template types', 'woocommerce-pos' ),
162 'items_list' => /* translators: Receipt template post type or template option label. */ __( 'Template types list', 'woocommerce-pos' ),
163 'items_list_navigation' => /* translators: Receipt template post type or template option label. */ __( 'Template types list navigation', 'woocommerce-pos' ),
164 );
165
166 $args = array(
167 'labels' => $labels,
168 'hierarchical' => false,
169 'public' => false,
170 'show_ui' => true,
171 'show_admin_column' => true,
172 'show_in_nav_menus' => false,
173 'show_tagcloud' => false,
174 'show_in_rest' => true,
175 'meta_box_cb' => array( $this, 'template_type_metabox' ),
176 'capabilities' => array(
177 'manage_terms' => 'manage_woocommerce_pos',
178 'edit_terms' => 'manage_woocommerce_pos',
179 'delete_terms' => 'manage_woocommerce_pos',
180 'assign_terms' => 'manage_woocommerce_pos',
181 ),
182 );
183
184 register_taxonomy( 'wcpos_template_type', array( 'wcpos_template' ), $args );
185
186 // Register default template types.
187 $this->register_default_template_types();
188
189 // Register category taxonomy for gallery filtering.
190 register_taxonomy(
191 'wcpos_template_category',
192 array( 'wcpos_template' ),
193 array(
194 'labels' => array(
195 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Categories', 'Taxonomy General Name', 'woocommerce-pos' ),
196 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Category', 'Taxonomy Singular Name', 'woocommerce-pos' ),
197 ),
198 'hierarchical' => false,
199 'public' => false,
200 'show_ui' => true,
201 'show_admin_column' => true,
202 'show_in_nav_menus' => false,
203 'show_tagcloud' => false,
204 'show_in_rest' => true,
205 'capabilities' => array(
206 'manage_terms' => 'manage_woocommerce_pos',
207 'edit_terms' => 'manage_woocommerce_pos',
208 'delete_terms' => 'manage_woocommerce_pos',
209 'assign_terms' => 'manage_woocommerce_pos',
210 ),
211 )
212 );
213
214 $this->register_default_template_categories();
215 }
216
217 /**
218 * Save raw post content directly to the database, bypassing wp_kses.
219 *
220 * WordPress applies wp_kses and other content filters during wp_insert_post()
221 * that strip unknown HTML/XML tags from template markup. This method writes
222 * raw content via $wpdb->update() to preserve the original markup.
223 *
224 * SECURITY: this bypass is self-defending. It refuses to write unless:
225 *
226 * 1. The target post type is `wcpos_template` — prevents accidental use on
227 * any other post type (post, page, etc.) where raw HTML would become a
228 * stored-XSS vector.
229 * 2. The post's `_template_engine` meta is in OFFLINE_CAPABLE_ENGINES
230 * (logicless, thermal). legacy-php templates are executed via include
231 * and MUST stay kses-filtered to prevent code injection.
232 *
233 * Callers are still responsible for capability checks; this function only
234 * enforces the structural invariants needed for safe storage. Reads from the
235 * stored content remain responsible for sanitisation at render time (see
236 * Logicless_Renderer::render() which applies wp_kses_post to output).
237 *
238 * @param int $post_id Post ID. Must reference an existing `wcpos_template` post.
239 * @param string $content Raw template content to save.
240 *
241 * @return bool True on success, false if the guards rejected the call or the DB write failed.
242 */
243 public static function save_raw_post_content( int $post_id, string $content ): bool {
244 // Refuse anything that isn't a WCPOS template — the bypass is only safe
245 // for content that flows through the WCPOS render pipeline.
246 if ( 'wcpos_template' !== get_post_type( $post_id ) ) {
247 return false;
248 }
249
250 // Refuse engines whose render path executes code (legacy-php) or that
251 // haven't been classified yet (missing meta). OFFLINE_CAPABLE_ENGINES is
252 // the explicit allowlist of engines whose output is re-sanitised at render.
253 $engine = get_post_meta( $post_id, '_template_engine', true );
254 if ( ! \in_array( $engine, self::OFFLINE_CAPABLE_ENGINES, true ) ) {
255 return false;
256 }
257
258 global $wpdb;
259
260 $result = $wpdb->update(
261 $wpdb->posts,
262 array( 'post_content' => $content ),
263 array( 'ID' => $post_id ),
264 array( '%s' ),
265 array( '%d' )
266 );
267
268 if ( false === $result ) {
269 return false;
270 }
271
272 clean_post_cache( $post_id );
273
274 return true;
275 }
276
277 /**
278 * Generate a readable fallback title for templates saved without a name.
279 *
280 * @param int $template_id Template post ID.
281 * @param string $type Template type slug.
282 * @param string $engine Template engine slug.
283 * @param string $paper_width Thermal paper width, when available.
284 *
285 * @return string Fallback template title.
286 */
287 public static function get_fallback_template_title( int $template_id, string $type = 'receipt', string $engine = '', string $paper_width = '' ): string {
288 if ( 'report' === $type ) {
289 /* translators: %d: template post ID. */
290 return sprintf( __( 'Report Template #%d', 'woocommerce-pos' ), $template_id );
291 }
292
293 if ( 'thermal' === $engine ) {
294 if ( '' !== $paper_width ) {
295 /* translators: 1: thermal paper width, 2: template post ID. */
296 return sprintf( __( 'Thermal Receipt Template (%1$s) #%2$d', 'woocommerce-pos' ), $paper_width, $template_id );
297 }
298
299 /* translators: %d: template post ID. */
300 return sprintf( __( 'Thermal Receipt Template #%d', 'woocommerce-pos' ), $template_id );
301 }
302
303 if ( 'logicless' === $engine ) {
304 /* translators: %d: template post ID. */
305 return sprintf( __( 'HTML Receipt Template #%d', 'woocommerce-pos' ), $template_id );
306 }
307
308 if ( 'legacy-php' === $engine ) {
309 /* translators: %d: template post ID. */
310 return sprintf( __( 'PHP Receipt Template #%d', 'woocommerce-pos' ), $template_id );
311 }
312
313 /* translators: %d: template post ID. */
314 return sprintf( __( 'Receipt Template #%d', 'woocommerce-pos' ), $template_id );
315 }
316
317 /**
318 * Get a database template by ID.
319 *
320 * @param int $template_id Template post ID.
321 *
322 * @return null|array Template data or null if not found.
323 */
324 public static function get_template( int $template_id ): ?array {
325 $post = get_post( $template_id );
326
327 if ( ! $post || 'wcpos_template' !== $post->post_type ) {
328 return null;
329 }
330
331 $terms = wp_get_post_terms( $template_id, 'wcpos_template_type' );
332 $type = ! empty( $terms ) && ! is_wp_error( $terms ) ? $terms[0]->slug : 'receipt';
333
334 $description = get_post_meta( $template_id, '_template_description', true );
335 $language = get_post_meta( $template_id, '_template_language', true );
336 $engine = get_post_meta( $template_id, '_template_engine', true );
337 $output_type = get_post_meta( $template_id, '_template_output_type', true );
338 $tax_display = get_post_meta( $template_id, '_template_tax_display', true );
339 $gallery_key = get_post_meta( $template_id, '_template_gallery_key', true );
340 $preview_data = null;
341 if ( \is_string( $gallery_key ) && '' !== $gallery_key ) {
342 if ( ! array_key_exists( $gallery_key, self::$gallery_preview_data_cache ) ) {
343 $gallery_metadata = self::get_gallery_template_metadata( $gallery_key );
344 self::$gallery_preview_data_cache[ $gallery_key ] = $gallery_metadata['preview_data'] ?? null;
345 }
346
347 $preview_data = self::$gallery_preview_data_cache[ $gallery_key ];
348 }
349 $paper_width = get_post_meta( $template_id, '_template_paper_width', true );
350 $category = self::get_template_category( $template_id );
351 $title = trim( $post->post_title );
352 if ( '' === $title ) {
353 $title = self::get_fallback_template_title(
354 $template_id,
355 $type,
356 $engine ? $engine : 'legacy-php',
357 $paper_width ? $paper_width : ''
358 );
359 }
360
361 return array(
362 'id' => $post->ID,
363 'title' => $title,
364 'description' => $description ? $description : '',
365 'content' => $post->post_content,
366 'type' => $type,
367 'category' => '' !== $category ? $category : ( 'receipt' === $type ? 'receipt' : '' ),
368 'language' => $language ? $language : 'php',
369 'file_path' => get_post_meta( $template_id, '_template_file_path', true ),
370 'engine' => $engine ? $engine : 'legacy-php',
371 'output_type' => $output_type ? $output_type : 'html',
372 'paper_width' => $paper_width ? $paper_width : null,
373 'tax_display' => $tax_display ? $tax_display : 'default',
374 'is_virtual' => false,
375 'is_premade' => (bool) get_post_meta( $template_id, '_template_is_premade', true ),
376 'gallery_key' => $gallery_key ? $gallery_key : null,
377 'preview_data' => $preview_data,
378 'gallery_version' => (int) get_post_meta( $template_id, '_template_gallery_version', true ),
379 'status' => $post->post_status,
380 'source' => 'custom',
381 'menu_order' => $post->menu_order,
382 'date_created' => $post->post_date,
383 'date_modified' => $post->post_modified,
384 'date_modified_gmt' => $post->post_modified_gmt,
385 );
386 }
387
388 /**
389 * Get the category slug for a template.
390 *
391 * @param int $template_id Template post ID.
392 *
393 * @return string Category slug or empty string.
394 */
395 private static function get_template_category( int $template_id ): string {
396 $terms = wp_get_post_terms( $template_id, 'wcpos_template_category' );
397 if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
398 return $terms[0]->slug;
399 }
400 return '';
401 }
402
403 /**
404 * Get a virtual (filesystem) template by ID.
405 *
406 * @param string $template_id Virtual template ID (theme, plugin-pro, plugin-core).
407 * @param string $type Template type (receipt, report).
408 *
409 * @return null|array Template data or null if not found.
410 */
411 public static function get_virtual_template( string $template_id, string $type = 'receipt' ): ?array {
412 $file_path = self::get_virtual_template_path( $template_id, $type );
413
414 if ( ! $file_path || ! file_exists( $file_path ) ) {
415 return null;
416 }
417
418 $metadata = array(
419 self::TEMPLATE_THEME => array(
420 'title' => /* translators: Receipt template post type or template option label. */ __( 'Theme Receipt Template', 'woocommerce-pos' ),
421 'description' => '',
422 'category' => 'receipt',
423 ),
424 self::TEMPLATE_PLUGIN_PRO => array(
425 'title' => /* translators: Receipt template post type or template option label. */ __( 'Pro Receipt Template', 'woocommerce-pos' ),
426 'description' => '',
427 'category' => 'receipt',
428 ),
429 self::TEMPLATE_PLUGIN_CORE => array(
430 'title' => /* translators: Receipt template post type or template option label. */ __( 'Legacy PHP Template', 'woocommerce-pos' ),
431 'description' => '',
432 'category' => 'receipt',
433 ),
434 self::TEMPLATE_WP_OVERNIGHT_INVOICE => array(
435 'title' => __( 'Invoice (WP Overnight)', 'woocommerce-pos' ),
436 'description' => __( 'Renders the PDF Invoices & Packing Slips invoice HTML through the WP Overnight document API.', 'woocommerce-pos' ),
437 'category' => 'invoice',
438 ),
439 self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP => array(
440 'title' => __( 'Packing Slip (WP Overnight)', 'woocommerce-pos' ),
441 'description' => __( 'Renders the PDF Invoices & Packing Slips packing slip HTML through the WP Overnight document API.', 'woocommerce-pos' ),
442 'category' => 'receipt',
443 ),
444 );
445
446 return array(
447 'id' => $template_id,
448 'title' => $metadata[ $template_id ]['title'] ?? $template_id,
449 'description' => $metadata[ $template_id ]['description'] ?? '',
450 'content' => file_get_contents( $file_path ),
451 'type' => $type,
452 'category' => 'receipt' === $type ? ( $metadata[ $template_id ]['category'] ?? 'receipt' ) : '',
453 'language' => 'php',
454 'file_path' => $file_path,
455 'engine' => 'legacy-php',
456 'output_type' => 'html',
457 'paper_width' => null,
458 'is_virtual' => true,
459 'source' => self::TEMPLATE_THEME === $template_id ? 'theme' : 'plugin',
460 'menu_order' => 0,
461 'date_modified_gmt' => gmdate( 'Y-m-d H:i:s', filemtime( $file_path ) ),
462 );
463 }
464
465 /**
466 * Check if the Pro license is active.
467 *
468 * @return bool True if Pro license is active.
469 */
470 public static function is_pro_license_active(): bool {
471 if ( \function_exists( 'woocommerce_pos_pro_activated' ) ) {
472 return (bool) woocommerce_pos_pro_activated();
473 }
474 return false;
475 }
476
477 /**
478 * Get the file path for a virtual template.
479 *
480 * @param string $template_id Virtual template ID.
481 * @param string $type Template type.
482 *
483 * @return null|string File path or null if not found.
484 */
485 public static function get_virtual_template_path( string $template_id, string $type = 'receipt' ): ?string {
486 if ( ! in_array( $type, self::SUPPORTED_TYPES, true ) ) {
487 return null;
488 }
489
490 $file_name = $type . '.php';
491 $directory = null;
492 $path = null;
493
494 switch ( $template_id ) {
495 case self::TEMPLATE_THEME:
496 $directory = get_stylesheet_directory() . '/woocommerce-pos/';
497 $path = $directory . $file_name;
498 break;
499
500 case self::TEMPLATE_PLUGIN_PRO:
501 // Pro template requires both the plugin AND an active license.
502 if ( \defined( 'WCPOS\WooCommercePOSPro\PLUGIN_PATH' ) && self::is_pro_license_active() ) {
503 $directory = \WCPOS\WooCommercePOSPro\PLUGIN_PATH . 'templates/';
504 $path = $directory . $file_name;
505 break;
506 }
507 return null;
508
509 case self::TEMPLATE_PLUGIN_CORE:
510 $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/';
511 $path = $directory . $file_name;
512 break;
513
514 case self::TEMPLATE_WP_OVERNIGHT_INVOICE:
515 if ( 'receipt' !== $type || ! self::is_wp_overnight_pdf_templates_available() ) {
516 return null;
517 }
518 $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/';
519 $path = $directory . 'wp-overnight-invoice.php';
520 break;
521
522 case self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP:
523 if ( 'receipt' !== $type || ! self::is_wp_overnight_pdf_templates_available() ) {
524 return null;
525 }
526 $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/';
527 $path = $directory . 'wp-overnight-packing-slip.php';
528 break;
529
530 default:
531 return null;
532 }
533
534 if ( null === $directory || null === $path ) {
535 return null;
536 }
537
538 $real_directory = realpath( $directory );
539 $real_path = realpath( $path );
540
541 if ( false === $real_directory || false === $real_path || ! is_file( $real_path ) ) {
542 return null;
543 }
544
545 $trusted_prefix = trailingslashit( wp_normalize_path( $real_directory ) );
546 $real_path = wp_normalize_path( $real_path );
547
548 return 0 === strpos( $real_path, $trusted_prefix ) ? $real_path : null;
549 }
550
551 /**
552 * Check whether WP Overnight PDF Invoices & Packing Slips APIs are available.
553 *
554 * @return bool True when the integration templates can be exposed.
555 */
556 private static function is_wp_overnight_pdf_templates_available(): bool {
557 $available = \function_exists( 'wcpdf_get_document' );
558
559 /**
560 * Filters WP Overnight PDF Invoices & Packing Slips template availability.
561 *
562 * @param bool $available Whether the third-party document API appears available.
563 *
564 * @returns bool Whether to expose the WP Overnight virtual receipt templates.
565 *
566 * @since 1.9.2
567 *
568 * @hook woocommerce_pos_wp_overnight_pdf_templates_enabled
569 */
570 return (bool) apply_filters( 'woocommerce_pos_wp_overnight_pdf_templates_enabled', $available );
571 }
572
573 /**
574 * Detect all available filesystem templates for a type.
575 * Returns templates in priority order: Theme > Pro > Core.
576 *
577 * @param string $type Template type (receipt, report).
578 *
579 * @return array Array of available virtual templates.
580 */
581 public static function detect_filesystem_templates( string $type = 'receipt' ): array {
582 $templates = array();
583
584 // Check in priority order: Theme > Pro > Core.
585 $priority_order = array(
586 self::TEMPLATE_THEME,
587 self::TEMPLATE_PLUGIN_PRO,
588 self::TEMPLATE_PLUGIN_CORE,
589 self::TEMPLATE_WP_OVERNIGHT_INVOICE,
590 self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP,
591 );
592
593 foreach ( $priority_order as $template_id ) {
594 $template = self::get_virtual_template( $template_id, $type );
595 if ( $template ) {
596 $templates[] = $template;
597 }
598 }
599
600 return $templates;
601 }
602
603 /**
604 * Get the default (highest priority) filesystem template for a type.
605 *
606 * @param string $type Template type (receipt, report).
607 *
608 * @return null|array Default template data or null if none found.
609 */
610 public static function get_default_template( string $type = 'receipt' ): ?array {
611 $templates = self::detect_filesystem_templates( $type );
612 return ! empty( $templates ) ? $templates[0] : null;
613 }
614
615 /**
616 * Get the ID of the active template for a type.
617 *
618 * @param string $type Template type (receipt, report).
619 *
620 * @return null|int|string Active template ID (int for database, string for virtual), or null.
621 */
622 public static function get_active_template_id( string $type = 'receipt' ) {
623 $active_id = get_option( 'wcpos_active_template_' . $type, null );
624 $enabled = self::get_enabled_templates( $type );
625 $enabled_ids = array_map(
626 static function ( $template ) {
627 return (string) $template['id'];
628 },
629 $enabled
630 );
631
632 // If no explicit active template, use first from enabled list.
633 if ( null === $active_id || '' === $active_id ) {
634 return ! empty( $enabled ) ? $enabled[0]['id'] : null;
635 }
636
637 // Validate that the stored active template is still enabled.
638 if ( ! \in_array( (string) $active_id, $enabled_ids, true ) ) {
639 delete_option( 'wcpos_active_template_' . $type );
640 return ! empty( $enabled ) ? $enabled[0]['id'] : null;
641 }
642
643 return is_numeric( $active_id ) ? (int) $active_id : $active_id;
644 }
645
646 /**
647 * Get active template for a specific type.
648 * Returns the full template data.
649 *
650 * @param string $type Template type (receipt, report).
651 *
652 * @return null|array Active template data or null if not found.
653 */
654 public static function get_active_template( string $type = 'receipt' ): ?array {
655 $active_id = self::get_active_template_id( $type );
656
657 if ( null === $active_id ) {
658 return null;
659 }
660
661 // Check if it's a database template (numeric ID).
662 if ( is_numeric( $active_id ) ) {
663 return self::get_template( (int) $active_id );
664 }
665
666 // It's a virtual template.
667 return self::get_virtual_template( $active_id, $type );
668 }
669
670 /**
671 * Set the active template by ID.
672 *
673 * @param int|string $template_id Template ID (int for database, string for virtual).
674 * @param string $type Template type (receipt, report).
675 *
676 * @return bool True on success, false on failure.
677 */
678 public static function set_active_template_id( $template_id, string $type = 'receipt' ): bool {
679 // Validate the template exists.
680 if ( is_numeric( $template_id ) ) {
681 $template = self::get_template( (int) $template_id );
682 if ( ! $template ) {
683 return false;
684 }
685 } else {
686 $template = self::get_virtual_template( $template_id, $type );
687 if ( ! $template ) {
688 return false;
689 }
690 }
691
692 return update_option( 'wcpos_active_template_' . $type, $template_id );
693 }
694
695 /**
696 * Set template as active (legacy method for backwards compatibility).
697 *
698 * @param int $template_id Template post ID.
699 *
700 * @return bool True on success, false on failure.
701 */
702 public static function set_active_template( int $template_id ): bool {
703 $template = self::get_template( $template_id );
704 if ( ! $template ) {
705 return false;
706 }
707
708 return self::set_active_template_id( $template_id, $template['type'] );
709 }
710
711 /**
712 * Get the stored display order for templates of a given type.
713 *
714 * @param string $type Template type (receipt, report).
715 *
716 * @return array Ordered array of template IDs (int for database, string for virtual).
717 */
718 public static function get_template_order( string $type = 'receipt' ): array {
719 $order = get_option( 'wcpos_template_order_' . $type, array() );
720
721 if ( ! \is_array( $order ) ) {
722 return array();
723 }
724
725 return $order;
726 }
727
728 /**
729 * Save the display order for templates of a given type.
730 *
731 * @param array $order Array of template IDs in display order.
732 * @param string $type Template type (receipt, report).
733 *
734 * @return bool True on success.
735 */
736 public static function save_template_order( array $order, string $type = 'receipt' ): bool {
737 // Sanitize: keep only integers and safe strings.
738 $sanitized = array();
739 foreach ( $order as $id ) {
740 if ( \is_int( $id ) || ( \is_numeric( $id ) && (int) $id > 0 ) ) {
741 $sanitized[] = (int) $id;
742 } elseif ( \is_string( $id ) ) {
743 $clean = sanitize_text_field( $id );
744 if ( '' !== $clean ) {
745 $sanitized[] = $clean;
746 }
747 }
748 }
749
750 return update_option( 'wcpos_template_order_' . $type, $sanitized );
751 }
752
753 /**
754 * Get the list of disabled virtual template IDs for a given type.
755 *
756 * @param string $type Template type (receipt, report).
757 *
758 * @return string[] Array of disabled virtual template IDs.
759 */
760 public static function get_disabled_virtual_templates( string $type = 'receipt' ): array {
761 $disabled = get_option( 'wcpos_disabled_virtual_templates_' . $type, array() );
762
763 if ( ! \is_array( $disabled ) ) {
764 return array();
765 }
766
767 return $disabled;
768 }
769
770 /**
771 * Check if a virtual template is disabled.
772 *
773 * @param string $template_id Virtual template ID.
774 * @param string $type Template type (receipt, report).
775 *
776 * @return bool True if disabled.
777 */
778 public static function is_virtual_template_disabled( string $template_id, string $type = 'receipt' ): bool {
779 $disabled = self::get_disabled_virtual_templates( $type );
780
781 return \in_array( $template_id, $disabled, true );
782 }
783
784 /**
785 * Set the disabled state of a virtual template.
786 *
787 * @param string $template_id Virtual template ID.
788 * @param bool $disabled True to disable, false to enable.
789 * @param string $type Template type (receipt, report).
790 *
791 * @return bool True on success.
792 */
793 public static function set_virtual_template_disabled( string $template_id, bool $disabled, string $type = 'receipt' ): bool {
794 $current = self::get_disabled_virtual_templates( $type );
795
796 if ( $disabled ) {
797 if ( ! \in_array( $template_id, $current, true ) ) {
798 $current[] = $template_id;
799 }
800 } else {
801 $current = array_values(
802 array_filter(
803 $current,
804 function ( $id ) use ( $template_id ) {
805 return $id !== $template_id;
806 }
807 )
808 );
809 }
810
811 return update_option( 'wcpos_disabled_virtual_templates_' . $type, $current );
812 }
813
814 /**
815 * Check if a template is currently active.
816 *
817 * @param int|string $template_id Template ID.
818 * @param string $type Template type.
819 *
820 * @return bool True if active.
821 */
822 public static function is_active_template( $template_id, string $type = 'receipt' ): bool {
823 $active_id = self::get_active_template_id( $type );
824 if ( null === $active_id ) {
825 return false;
826 }
827
828 // Normalize for comparison.
829 if ( is_numeric( $template_id ) && is_numeric( $active_id ) ) {
830 return (int) $template_id === (int) $active_id;
831 }
832
833 return (string) $template_id === (string) $active_id;
834 }
835
836 /**
837 * Resolve the ordered list of templates for a given store.
838 *
839 * If the store has a per-store override (active_receipt_templates meta),
840 * returns only those templates intersected with the global enabled list.
841 * Otherwise returns the full global enabled list.
842 *
843 * @param int $store_id Store post ID. 0 for global defaults.
844 * @param string $type Template type (e.g. 'receipt').
845 *
846 * @return array Array of template data arrays, in display order.
847 */
848 public static function resolve_templates( int $store_id, string $type = 'receipt' ): array {
849 $global_list = self::get_enabled_templates( $type );
850
851 if ( ! $store_id ) {
852 return $global_list;
853 }
854
855 $meta_key = '_wcpos_active_' . sanitize_key( $type ) . '_templates';
856 $raw = get_post_meta( $store_id, $meta_key, true );
857 $override = is_string( $raw ) ? json_decode( $raw, true ) : array();
858
859 if ( empty( $override ) || ! \is_array( $override ) ) {
860 return $global_list;
861 }
862
863 // Build a lookup of global templates by ID (normalize to string for comparison).
864 $global_by_id = array();
865 foreach ( $global_list as $template ) {
866 $global_by_id[ (string) $template['id'] ] = $template;
867 }
868
869 // Intersect store override with global enabled list, preserving store order.
870 $resolved = array();
871 foreach ( $override as $template_id ) {
872 $key = (string) $template_id;
873 if ( isset( $global_by_id[ $key ] ) ) {
874 $resolved[] = $global_by_id[ $key ];
875 }
876 }
877
878 // Fallback: if all overridden templates are globally disabled, use global list.
879 if ( empty( $resolved ) ) {
880 return $global_list;
881 }
882
883 return $resolved;
884 }
885
886 /**
887 * Get all enabled templates for a type, in stored order.
888 *
889 * Combines virtual (filesystem) templates and database (custom) templates,
890 * filters to only enabled ones, and sorts by the stored template order.
891 *
892 * @param string $type Template type.
893 *
894 * @return array Array of template data arrays.
895 */
896 public static function get_enabled_templates( string $type = 'receipt' ): array {
897 $disabled_virtual = self::get_disabled_virtual_templates( $type );
898 $order = self::get_template_order( $type );
899 $templates = array();
900
901 // Collect enabled virtual templates.
902 $virtual = self::detect_filesystem_templates( $type );
903 foreach ( $virtual as $template ) {
904 if ( ! \in_array( (string) $template['id'], $disabled_virtual, true ) ) {
905 $templates[] = $template;
906 }
907 }
908
909 // Collect enabled database (custom) templates.
910 $posts = get_posts(
911 array(
912 'post_type' => 'wcpos_template',
913 'post_status' => 'publish',
914 'posts_per_page' => -1,
915 'tax_query' => array(
916 array(
917 'taxonomy' => 'wcpos_template_type',
918 'field' => 'slug',
919 'terms' => $type,
920 ),
921 ),
922 )
923 );
924 foreach ( $posts as $post ) {
925 $template = self::get_template( $post->ID );
926 if ( $template ) {
927 $templates[] = $template;
928 }
929 }
930
931 // Sort by stored order if available.
932 if ( ! empty( $order ) ) {
933 $order_map = array_flip( array_map( 'strval', $order ) );
934 usort(
935 $templates,
936 function ( $a, $b ) use ( $order_map ) {
937 $pos_a = $order_map[ (string) $a['id'] ] ?? PHP_INT_MAX;
938 $pos_b = $order_map[ (string) $b['id'] ] ?? PHP_INT_MAX;
939 return $pos_a - $pos_b;
940 }
941 );
942 }
943
944 return $templates;
945 }
946
947 /**
948 * One-time migration: move the active template to first position in the order,
949 * then delete the wcpos_active_template_{type} option.
950 *
951 * @param string $type Template type.
952 */
953 public static function migrate_active_template_to_order( string $type = 'receipt' ): void {
954 $option_key = 'wcpos_active_template_' . $type;
955 $active_id = get_option( $option_key );
956
957 if ( false === $active_id ) {
958 return; // Nothing to migrate.
959 }
960
961 $order = self::get_template_order( $type );
962
963 if ( ! empty( $order ) ) {
964 // Remove active_id from its current position.
965 $order = array_values(
966 array_filter(
967 $order,
968 function ( $id ) use ( $active_id ) {
969 return (string) $id !== (string) $active_id;
970 }
971 )
972 );
973
974 // Prepend it.
975 array_unshift( $order, $active_id );
976 self::save_template_order( $order, $type );
977 }
978
979 delete_option( $option_key );
980 }
981
982 /**
983 * Get available starter/example templates.
984 *
985 * Returns metadata for example templates bundled with the plugin.
986 * These can be installed as custom templates by the user.
987 *
988 * @return array Array of starter template definitions.
989 */
990 public static function get_starter_templates(): array {
991 $starters = array();
992
993 // Only include starters whose files actually exist.
994 return array_filter(
995 $starters,
996 function ( $starter ) {
997 return file_exists( $starter['file'] );
998 }
999 );
1000 }
1001
1002 /**
1003 * Get gallery templates from the templates/gallery/ directory.
1004 *
1005 * @param string|null $type Filter by type. Null for all.
1006 * @param string|null $category Filter by category. Null for all.
1007 *
1008 * @return array Array of gallery template data.
1009 */
1010 public static function get_gallery_templates( ?string $type = null, ?string $category = null ): array {
1011 $gallery_dir = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/gallery/';
1012
1013 if ( ! is_dir( $gallery_dir ) ) {
1014 return array();
1015 }
1016
1017 $templates = array();
1018
1019 foreach ( Gallery_Registry::all() as $key => $metadata ) {
1020 if ( $type && ( $metadata['type'] ?? '' ) !== $type ) {
1021 continue;
1022 }
1023 if ( $category && ( $metadata['category'] ?? '' ) !== $category ) {
1024 continue;
1025 }
1026
1027 $content_file = self::find_gallery_content_file( $key );
1028
1029 if ( '' === $content_file ) {
1030 continue;
1031 }
1032
1033 $templates[] = self::build_gallery_template(
1034 self::prepare_gallery_metadata( $key, $metadata ),
1035 $content_file
1036 );
1037 }
1038
1039 usort(
1040 $templates,
1041 function ( $a, $b ) {
1042 return strcmp( $a['key'], $b['key'] );
1043 }
1044 );
1045
1046 return $templates;
1047 }
1048
1049 /**
1050 * Locate the content file for a bundled gallery template key.
1051 *
1052 * Only stats the candidate paths for the given key — it never reads file
1053 * contents, and never touches the other bundled gallery templates.
1054 *
1055 * @param string $key Gallery template key (e.g. "standard-receipt").
1056 *
1057 * @return string Absolute path to the content file, or '' when none exists.
1058 */
1059 private static function find_gallery_content_file( string $key ): string {
1060 $gallery_dir = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/gallery/';
1061
1062 foreach ( self::GALLERY_CONTENT_EXTENSIONS as $ext ) {
1063 $candidate = $gallery_dir . $key . '.' . $ext;
1064 if ( file_exists( $candidate ) ) {
1065 return $candidate;
1066 }
1067 }
1068
1069 return '';
1070 }
1071
1072 /**
1073 * Normalize a raw registry entry into gallery template metadata.
1074 *
1075 * @param string $key Gallery template key.
1076 * @param array<string,mixed> $metadata Raw registry entry.
1077 *
1078 * @return array<string,mixed> Normalized metadata.
1079 */
1080 private static function prepare_gallery_metadata( string $key, array $metadata ): array {
1081 $metadata['key'] = $key;
1082 $metadata['direction'] = isset( $metadata['direction'] ) && 'rtl' === $metadata['direction']
1083 ? 'rtl'
1084 : 'ltr';
1085
1086 return $metadata;
1087 }
1088
1089 /**
1090 * Build a full gallery template record by reading its content file.
1091 *
1092 * @param array<string,mixed> $metadata Normalized metadata for the key.
1093 * @param string $content_file Absolute path to the content file.
1094 *
1095 * @return array<string,mixed> Gallery template record.
1096 */
1097 private static function build_gallery_template( array $metadata, string $content_file ): array {
1098 return array_merge(
1099 $metadata,
1100 array(
1101 'content' => file_get_contents( $content_file ),
1102 'content_file' => $content_file,
1103 'is_premade' => true,
1104 'is_virtual' => true,
1105 'source' => 'gallery',
1106 'offline_capable' => in_array( $metadata['engine'] ?? 'logicless', self::OFFLINE_CAPABLE_ENGINES, true ),
1107 )
1108 );
1109 }
1110
1111 /**
1112 * Get a single gallery template's metadata without reading any template file.
1113 *
1114 * Metadata (title, description, engine, preview_data, ...) lives in
1115 * Gallery_Registry, so callers that only need metadata never pay for reading
1116 * the bundled template content — which is why get_template() uses this rather
1117 * than get_gallery_template_by_key().
1118 *
1119 * @param string $key Gallery template key (e.g. "standard-receipt").
1120 *
1121 * @return null|array Gallery template metadata, or null when the key is unknown
1122 * or has no bundled content file.
1123 */
1124 public static function get_gallery_template_metadata( string $key ): ?array {
1125 $registry = Gallery_Registry::all();
1126
1127 if ( ! isset( $registry[ $key ] ) ) {
1128 return null;
1129 }
1130
1131 if ( '' === self::find_gallery_content_file( $key ) ) {
1132 return null;
1133 }
1134
1135 return self::prepare_gallery_metadata( $key, $registry[ $key ] );
1136 }
1137
1138 /**
1139 * Get a single gallery template by its key.
1140 *
1141 * Reads only the requested template's content file.
1142 *
1143 * @param string $key Gallery template key (e.g. "standard-receipt").
1144 *
1145 * @return null|array Gallery template data or null if not found.
1146 */
1147 public static function get_gallery_template_by_key( string $key ): ?array {
1148 $metadata = self::get_gallery_template_metadata( $key );
1149
1150 if ( null === $metadata ) {
1151 return null;
1152 }
1153
1154 return self::build_gallery_template( $metadata, self::find_gallery_content_file( $key ) );
1155 }
1156
1157 /**
1158 * Install a starter template as a custom (database) template.
1159 *
1160 * @param string $starter_key Key from get_starter_templates().
1161 *
1162 * @return int|\WP_Error Post ID on success, WP_Error on failure.
1163 */
1164 public static function install_starter_template( string $starter_key ) {
1165 $starters = self::get_starter_templates();
1166
1167 if ( ! isset( $starters[ $starter_key ] ) ) {
1168 return new \WP_Error( 'invalid_starter', __( 'Starter template not found.', 'woocommerce-pos' ) );
1169 }
1170
1171 $starter = $starters[ $starter_key ];
1172 $content = file_get_contents( $starter['file'] );
1173
1174 if ( false === $content ) {
1175 return new \WP_Error( 'read_failed', __( 'Could not read starter template file.', 'woocommerce-pos' ) );
1176 }
1177
1178 $post_id = wp_insert_post(
1179 array(
1180 'post_title' => $starter['title'],
1181 'post_content' => $content,
1182 'post_status' => 'publish',
1183 'post_type' => 'wcpos_template',
1184 ),
1185 true
1186 );
1187
1188 if ( is_wp_error( $post_id ) ) {
1189 return $post_id;
1190 }
1191
1192 wp_set_object_terms( $post_id, $starter['type'], 'wcpos_template_type' );
1193 update_post_meta( $post_id, '_template_language', $starter['language'] );
1194 update_post_meta( $post_id, '_template_engine', $starter['engine'] );
1195 update_post_meta( $post_id, '_template_output_type', 'html' );
1196
1197 // Bypass wp_kses for offline-capable engines — it strips unknown HTML/XML tags.
1198 if ( \in_array( $starter['engine'], self::OFFLINE_CAPABLE_ENGINES, true ) ) {
1199 if ( ! self::save_raw_post_content( $post_id, $content ) ) {
1200 wp_delete_post( $post_id, true );
1201
1202 return new \WP_Error(
1203 'wcpos_template_content_save_failed',
1204 __( 'Template was created but raw content could not be saved.', 'woocommerce-pos' )
1205 );
1206 }
1207 }
1208
1209 return $post_id;
1210 }
1211
1212 /**
1213 * Install a gallery template as a custom (database) template.
1214 *
1215 * @param string $gallery_key Key matching a gallery template JSON file.
1216 *
1217 * @return int|\WP_Error Post ID on success, WP_Error on failure.
1218 */
1219 public static function install_gallery_template( string $gallery_key ) {
1220 $gallery_templates = self::get_gallery_templates();
1221 $template = null;
1222
1223 foreach ( $gallery_templates as $gt ) {
1224 if ( $gt['key'] === $gallery_key ) {
1225 $template = $gt;
1226 break;
1227 }
1228 }
1229
1230 if ( ! $template ) {
1231 return new \WP_Error( 'invalid_gallery_key', __( 'Gallery template not found.', 'woocommerce-pos' ) );
1232 }
1233
1234 // Translate interpolated phrases (text mixed with Mustache variables) for the current locale.
1235 $content = Receipt_I18n_Labels::translate_interpolated_phrases( $template['content'] );
1236
1237 $post_id = wp_insert_post(
1238 array(
1239 'post_title' => $template['title'],
1240 'post_content' => $content,
1241 'post_status' => 'publish',
1242 'post_type' => 'wcpos_template',
1243 ),
1244 true
1245 );
1246
1247 if ( is_wp_error( $post_id ) ) {
1248 return $post_id;
1249 }
1250
1251 // Set taxonomies.
1252 wp_set_object_terms( $post_id, $template['type'] ?? 'receipt', 'wcpos_template_type' );
1253 if ( ! empty( $template['category'] ) ) {
1254 wp_set_object_terms( $post_id, $template['category'], 'wcpos_template_category' );
1255 }
1256
1257 // Set meta fields — normalize engine once for consistent derived values.
1258 $engine = $template['engine'] ?? 'logicless';
1259 update_post_meta( $post_id, '_template_description', $template['description'] ?? '' );
1260 update_post_meta( $post_id, '_template_engine', $engine );
1261 update_post_meta( $post_id, '_template_output_type', $template['output_type'] ?? 'html' );
1262 if ( 'logicless' === $engine ) {
1263 $language = 'html';
1264 } elseif ( 'thermal' === $engine ) {
1265 $language = 'xml';
1266 } else {
1267 $language = 'php';
1268 }
1269 update_post_meta( $post_id, '_template_language', $language );
1270 update_post_meta( $post_id, '_template_gallery_key', $gallery_key );
1271 update_post_meta( $post_id, '_template_gallery_version', $template['version'] ?? 1 );
1272 update_post_meta( $post_id, '_template_tax_display', 'default' );
1273
1274 if ( ! empty( $template['paper_width'] ) ) {
1275 update_post_meta( $post_id, '_template_paper_width', $template['paper_width'] );
1276 }
1277
1278 // Bypass wp_kses for offline-capable engines — it strips unknown HTML/XML tags.
1279 if ( \in_array( $engine, self::OFFLINE_CAPABLE_ENGINES, true ) ) {
1280 if ( ! self::save_raw_post_content( $post_id, $content ) ) {
1281 wp_delete_post( $post_id, true );
1282
1283 return new \WP_Error(
1284 'wcpos_template_content_save_failed',
1285 __( 'Template was created but raw content could not be saved.', 'woocommerce-pos' )
1286 );
1287 }
1288 }
1289
1290 return $post_id;
1291 }
1292
1293 /**
1294 * Register default template types (receipt, report).
1295 *
1296 * @return void
1297 */
1298 private function register_default_template_types(): void {
1299 // Check if terms already exist to avoid duplicates.
1300 if ( ! term_exists( 'receipt', 'wcpos_template_type' ) ) {
1301 wp_insert_term(
1302 'Receipt',
1303 'wcpos_template_type',
1304 array(
1305 'slug' => 'receipt',
1306 'description' => /* translators: Receipt template post type or template option label. */ __( 'Receipt templates for printing orders', 'woocommerce-pos' ),
1307 )
1308 );
1309 }
1310
1311 if ( ! term_exists( 'report', 'wcpos_template_type' ) ) {
1312 wp_insert_term(
1313 'Report',
1314 'wcpos_template_type',
1315 array(
1316 'slug' => 'report',
1317 'description' => __( 'Report templates for analytics', 'woocommerce-pos' ),
1318 )
1319 );
1320 }
1321 }
1322
1323 /**
1324 * Register default template categories.
1325 *
1326 * @return void
1327 */
1328 private function register_default_template_categories(): void {
1329 $categories = array(
1330 'receipt' => /* translators: Receipt template post type or template option label. */ __( 'Receipt', 'woocommerce-pos' ),
1331 'invoice' => /* translators: Receipt template post type or template option label. */ __( 'Invoice', 'woocommerce-pos' ),
1332 'gift-receipt' => /* translators: Receipt template post type or template option label. */ __( 'Gift Receipt', 'woocommerce-pos' ),
1333 'credit-note' => /* translators: Receipt template post type or template option label. */ __( 'Credit Note', 'woocommerce-pos' ),
1334 'purchase-order' => /* translators: Receipt template post type or template option label. */ __( 'Purchase Order', 'woocommerce-pos' ),
1335 'kitchen-ticket' => /* translators: Receipt template post type or template option label. */ __( 'Kitchen Ticket', 'woocommerce-pos' ),
1336 'bar-ticket' => /* translators: Receipt template post type or template option label. */ __( 'Bar Ticket', 'woocommerce-pos' ),
1337 );
1338
1339 foreach ( $categories as $slug => $name ) {
1340 if ( ! term_exists( $slug, 'wcpos_template_category' ) ) {
1341 wp_insert_term( $name, 'wcpos_template_category', array( 'slug' => $slug ) );
1342 }
1343 }
1344 }
1345
1346 /**
1347 * Custom metabox for template type selection.
1348 * Ensures one type is always selected with 'receipt' as default.
1349 *
1350 * @param \WP_Post $post Post object.
1351 * @param array $box Metabox arguments.
1352 *
1353 * @return void
1354 */
1355 public function template_type_metabox( \WP_Post $post, array $box ): void {
1356 $taxonomy = $box['args']['taxonomy'];
1357 $terms = get_terms(
1358 array(
1359 'taxonomy' => $taxonomy,
1360 'hide_empty' => false,
1361 )
1362 );
1363
1364 if ( empty( $terms ) || is_wp_error( $terms ) ) {
1365 return;
1366 }
1367
1368 // Get current terms.
1369 $current_terms = wp_get_post_terms( $post->ID, $taxonomy );
1370 $current_slug = ! empty( $current_terms ) && ! is_wp_error( $current_terms ) ? $current_terms[0]->slug : 'receipt';
1371
1372 ?>
1373 <div id="taxonomy-<?php echo esc_attr( $taxonomy ); ?>" class="categorydiv">
1374 <div id="<?php echo esc_attr( $taxonomy ); ?>-all" class="tabs-panel">
1375 <ul id="<?php echo esc_attr( $taxonomy ); ?>checklist" class="categorychecklist form-no-clear">
1376 <?php foreach ( $terms as $term ) : ?>
1377 <li>
1378 <label class="selectit">
1379 <input
1380 type="radio"
1381 name="tax_input[<?php echo esc_attr( $taxonomy ); ?>][]"
1382 value="<?php echo esc_attr( $term->slug ); ?>"
1383 <?php checked( $current_slug, $term->slug ); ?>
1384 required
1385 />
1386 <?php echo esc_html( $term->name ); ?>
1387 </label>
1388 </li>
1389 <?php endforeach; ?>
1390 </ul>
1391 </div>
1392 </div>
1393 <?php
1394 }
1395 }
1396