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 / Templates.php

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

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