* * @see http://wcpos.com * @package WCPOS\WooCommercePOS */ namespace WCPOS\WooCommercePOS; use WP_Query; use WCPOS\WooCommercePOS\Services\Receipt_I18n_Labels; use WCPOS\WooCommercePOS\Templates\Gallery_Registry; /** * Templates class. */ class Templates { /** * Virtual template ID constants. */ const TEMPLATE_THEME = 'theme'; const TEMPLATE_PLUGIN_PRO = 'plugin-pro'; const TEMPLATE_PLUGIN_CORE = 'plugin-core'; const TEMPLATE_WP_OVERNIGHT_INVOICE = 'wp-overnight-invoice'; const TEMPLATE_WP_OVERNIGHT_PACKING_SLIP = 'wp-overnight-packing-slip'; /** * Supported template types. */ const SUPPORTED_TYPES = array( 'receipt', 'report' ); /** * Engines that support offline (client-side) rendering. */ const OFFLINE_CAPABLE_ENGINES = array( 'logicless', 'thermal' ); /** * File extensions a bundled gallery template's content file can use. */ const GALLERY_CONTENT_EXTENSIONS = array( 'html', 'php', 'xml' ); /** * Per-request cache of installed gallery template preview data profiles. * * @var array */ private static $gallery_preview_data_cache = array(); /** * Bump when the default term set below changes; a lower stored value reseeds. * * This class is constructed on EVERY request (Init::init_common), and the * seeding used to run nine `term_exists()` checks each time — 18 of the 31 * queries the plugin added to every storefront page (measured 2026-09-03 * on dev-next, see .claude/research/2026-09-03-online-store-footprint.md). * Behind the latch the whole registration costs no queries. */ public const DEFAULT_TERMS_VERSION = 1; /** Autoloaded latch: read on every request, so it must ride in alloptions. */ public const DEFAULT_TERMS_OPTION = 'woocommerce_pos_template_default_terms_version'; /** * Constructor. */ public function __construct() { // Register immediately since this is already being called during 'init'. $this->register_post_type(); $this->register_taxonomy(); $this->maybe_seed_default_terms(); } /** * Make sure the template post type and taxonomies exist on this request. * * A storefront request constructs Templates only on its first order write * (see Init::ensure_order_services()), but the static readers below are * also reached from a plain page — the My Account order actions read the * active receipt template. A tax_query against an unregistered taxonomy * matches nothing, and get_active_template_id() would then treat the * merchant's custom template as gone and delete the active-template * option. Registration itself costs no queries (the default terms are * behind an autoloaded latch), so every static reader calls this first. */ public static function ensure_registered(): void { if ( taxonomy_exists( 'wcpos_template_type' ) && post_type_exists( 'wcpos_template' ) ) { return; } new self(); } /** * Seed the default template types and categories once per DEFAULT_TERMS_VERSION. * * The latch is set only once every default term verifiably exists, so a * failed `wp_insert_term()` (a filter returning WP_Error, a transient DB * fault) leaves seeding armed for the next request instead of marking it * done. A term deleted by hand is restored on the next version bump or * plugin (re)activation, not the next request; the taxonomies are hidden * from menus and the defaults exist for the receipt UI, so that is the * right trade for a free page load. */ private function maybe_seed_default_terms(): void { if ( (int) get_option( self::DEFAULT_TERMS_OPTION, 0 ) >= self::DEFAULT_TERMS_VERSION ) { return; } $this->register_default_template_types(); $this->register_default_template_categories(); if ( $this->default_terms_present() ) { update_option( self::DEFAULT_TERMS_OPTION, self::DEFAULT_TERMS_VERSION, true ); } } /** Whether every default type and category term exists. */ private function default_terms_present(): bool { foreach ( array( 'receipt', 'report' ) as $slug ) { if ( ! term_exists( $slug, 'wcpos_template_type' ) ) { return false; } } foreach ( array_keys( self::default_template_categories() ) as $slug ) { if ( ! term_exists( $slug, 'wcpos_template_category' ) ) { return false; } } return true; } /** * Register the custom post type for templates. * Only custom user-created templates are stored in the database. * * @return void */ public function register_post_type(): void { $labels = array( 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Templates', 'Post Type General Name', 'woocommerce-pos' ), 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template', 'Post Type Singular Name', 'woocommerce-pos' ), 'menu_name' => /* translators: Receipt template post type or template option label. */ __( 'Templates', 'woocommerce-pos' ), 'name_admin_bar' => /* translators: Receipt template post type or template option label. */ __( 'Template', 'woocommerce-pos' ), 'archives' => /* translators: Receipt template post type or template option label. */ __( 'Template Archives', 'woocommerce-pos' ), 'attributes' => /* translators: Receipt template post type or template option label. */ __( 'Template Attributes', 'woocommerce-pos' ), 'parent_item_colon' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template:', 'woocommerce-pos' ), 'all_items' => /* translators: Receipt template post type or template option label. */ __( 'Templates', 'woocommerce-pos' ), 'add_new_item' => /* translators: Receipt template post type or template option label. */ __( 'Add New Template', 'woocommerce-pos' ), 'add_new' => /* translators: Receipt template post type or template option label. */ __( 'Add New', 'woocommerce-pos' ), 'new_item' => /* translators: Receipt template post type or template option label. */ __( 'New Template', 'woocommerce-pos' ), 'edit_item' => /* translators: Receipt template post type or template option label. */ __( 'Edit Template', 'woocommerce-pos' ), 'update_item' => /* translators: Receipt template post type or template option label. */ __( 'Update Template', 'woocommerce-pos' ), 'view_item' => /* translators: Receipt template post type or template option label. */ __( 'View Template', 'woocommerce-pos' ), 'view_items' => /* translators: Receipt template post type or template option label. */ __( 'View Templates', 'woocommerce-pos' ), 'search_items' => /* translators: Receipt template post type or template option label. */ __( 'Search Template', 'woocommerce-pos' ), 'not_found' => /* translators: Receipt template post type or template option label. */ __( 'Not found', 'woocommerce-pos' ), 'not_found_in_trash' => __( 'Not found in Trash', 'woocommerce-pos' ), 'featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Featured Image', 'woocommerce-pos' ), 'set_featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Set featured image', 'woocommerce-pos' ), 'remove_featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Remove featured image', 'woocommerce-pos' ), 'use_featured_image' => __( 'Use as featured image', 'woocommerce-pos' ), 'insert_into_item' => /* translators: Receipt template post type or template option label. */ __( 'Insert into template', 'woocommerce-pos' ), 'uploaded_to_this_item' => __( 'Uploaded to this template', 'woocommerce-pos' ), 'items_list' => /* translators: Receipt template post type or template option label. */ __( 'Templates list', 'woocommerce-pos' ), 'items_list_navigation' => /* translators: Receipt template post type or template option label. */ __( 'Templates list navigation', 'woocommerce-pos' ), 'filter_items_list' => /* translators: Receipt template post type or template option label. */ __( 'Filter templates list', 'woocommerce-pos' ), ); $args = array( 'label' => /* translators: Receipt template post type or template option label. */ __( 'Template', 'woocommerce-pos' ), 'description' => /* translators: Receipt template post type or template option label. */ __( 'POS Templates', 'woocommerce-pos' ), 'labels' => $labels, 'supports' => array( 'title', 'editor', 'revisions' ), 'taxonomies' => array( 'wcpos_template_type', 'wcpos_template_category' ), 'hierarchical' => false, 'public' => false, 'show_ui' => true, 'show_in_menu' => false, // Hidden from menu; Gallery SPA provides the submenu entry. 'menu_position' => 5, 'show_in_admin_bar' => true, 'show_in_nav_menus' => false, 'can_export' => true, 'has_archive' => false, 'exclude_from_search' => true, 'publicly_queryable' => false, 'capability_type' => 'post', 'capabilities' => array( 'edit_post' => 'manage_woocommerce_pos', 'read_post' => 'manage_woocommerce_pos', 'delete_post' => 'manage_woocommerce_pos', 'edit_posts' => 'manage_woocommerce_pos', 'edit_others_posts' => 'manage_woocommerce_pos', 'delete_posts' => 'manage_woocommerce_pos', 'publish_posts' => 'manage_woocommerce_pos', 'read_private_posts' => 'manage_woocommerce_pos', ), 'show_in_rest' => false, // Disable Gutenberg. 'rest_base' => 'wcpos_templates', ); register_post_type( 'wcpos_template', $args ); } /** * Register the taxonomy for template types. * * @return void */ public function register_taxonomy(): void { $labels = array( 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Types', 'Taxonomy General Name', 'woocommerce-pos' ), 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Type', 'Taxonomy Singular Name', 'woocommerce-pos' ), 'menu_name' => /* translators: Receipt template post type or template option label. */ __( 'Template Types', 'woocommerce-pos' ), 'all_items' => /* translators: Receipt template post type or template option label. */ __( 'All Template Types', 'woocommerce-pos' ), 'parent_item' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template Type', 'woocommerce-pos' ), 'parent_item_colon' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template Type:', 'woocommerce-pos' ), 'new_item_name' => /* translators: Receipt template post type or template option label. */ __( 'New Template Type Name', 'woocommerce-pos' ), 'add_new_item' => /* translators: Receipt template post type or template option label. */ __( 'Add New Template Type', 'woocommerce-pos' ), 'edit_item' => /* translators: Receipt template post type or template option label. */ __( 'Edit Template Type', 'woocommerce-pos' ), 'update_item' => /* translators: Receipt template post type or template option label. */ __( 'Update Template Type', 'woocommerce-pos' ), 'view_item' => /* translators: Receipt template post type or template option label. */ __( 'View Template Type', 'woocommerce-pos' ), 'separate_items_with_commas' => __( 'Separate template types with commas', 'woocommerce-pos' ), 'add_or_remove_items' => __( 'Add or remove template types', 'woocommerce-pos' ), 'choose_from_most_used' => __( 'Choose from the most used', 'woocommerce-pos' ), 'popular_items' => /* translators: Receipt template post type or template option label. */ __( 'Popular Template Types', 'woocommerce-pos' ), 'search_items' => /* translators: Receipt template post type or template option label. */ __( 'Search Template Types', 'woocommerce-pos' ), 'not_found' => /* translators: Receipt template post type or template option label. */ __( 'Not Found', 'woocommerce-pos' ), 'no_terms' => /* translators: Receipt template post type or template option label. */ __( 'No template types', 'woocommerce-pos' ), 'items_list' => /* translators: Receipt template post type or template option label. */ __( 'Template types list', 'woocommerce-pos' ), 'items_list_navigation' => /* translators: Receipt template post type or template option label. */ __( 'Template types list navigation', 'woocommerce-pos' ), ); $args = array( 'labels' => $labels, 'hierarchical' => false, 'public' => false, 'show_ui' => true, 'show_admin_column' => true, 'show_in_nav_menus' => false, 'show_tagcloud' => false, 'show_in_rest' => true, 'meta_box_cb' => array( $this, 'template_type_metabox' ), 'capabilities' => array( 'manage_terms' => 'manage_woocommerce_pos', 'edit_terms' => 'manage_woocommerce_pos', 'delete_terms' => 'manage_woocommerce_pos', 'assign_terms' => 'manage_woocommerce_pos', ), ); register_taxonomy( 'wcpos_template_type', array( 'wcpos_template' ), $args ); // Default terms are seeded by maybe_seed_default_terms(), behind a latch. // Register category taxonomy for gallery filtering. register_taxonomy( 'wcpos_template_category', array( 'wcpos_template' ), array( 'labels' => array( 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Categories', 'Taxonomy General Name', 'woocommerce-pos' ), 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Category', 'Taxonomy Singular Name', 'woocommerce-pos' ), ), 'hierarchical' => false, 'public' => false, 'show_ui' => true, 'show_admin_column' => true, 'show_in_nav_menus' => false, 'show_tagcloud' => false, 'show_in_rest' => true, 'capabilities' => array( 'manage_terms' => 'manage_woocommerce_pos', 'edit_terms' => 'manage_woocommerce_pos', 'delete_terms' => 'manage_woocommerce_pos', 'assign_terms' => 'manage_woocommerce_pos', ), ) ); } /** * Save raw post content directly to the database, bypassing wp_kses. * * WordPress applies wp_kses and other content filters during wp_insert_post() * that strip unknown HTML/XML tags from template markup. This method writes * raw content via $wpdb->update() to preserve the original markup. * * SECURITY: this bypass is self-defending. It refuses to write unless: * * 1. The target post type is `wcpos_template` — prevents accidental use on * any other post type (post, page, etc.) where raw HTML would become a * stored-XSS vector. * 2. The post's `_template_engine` meta is in OFFLINE_CAPABLE_ENGINES * (logicless, thermal). legacy-php templates are executed via include * and MUST stay kses-filtered to prevent code injection. * * Callers are still responsible for capability checks; this function only * enforces the structural invariants needed for safe storage. Reads from the * stored content remain responsible for sanitisation at render time (see * Logicless_Renderer::render() which applies wp_kses_post to output). * * @param int $post_id Post ID. Must reference an existing `wcpos_template` post. * @param string $content Raw template content to save. * * @return bool True on success, false if the guards rejected the call or the DB write failed. */ public static function save_raw_post_content( int $post_id, string $content ): bool { // Refuse anything that isn't a WCPOS template — the bypass is only safe // for content that flows through the WCPOS render pipeline. if ( 'wcpos_template' !== get_post_type( $post_id ) ) { return false; } // Refuse engines whose render path executes code (legacy-php) or that // haven't been classified yet (missing meta). OFFLINE_CAPABLE_ENGINES is // the explicit allowlist of engines whose output is re-sanitised at render. $engine = get_post_meta( $post_id, '_template_engine', true ); if ( ! \in_array( $engine, self::OFFLINE_CAPABLE_ENGINES, true ) ) { return false; } global $wpdb; $result = $wpdb->update( $wpdb->posts, array( 'post_content' => $content ), array( 'ID' => $post_id ), array( '%s' ), array( '%d' ) ); if ( false === $result ) { return false; } clean_post_cache( $post_id ); return true; } /** * Generate a readable fallback title for templates saved without a name. * * @param int $template_id Template post ID. * @param string $type Template type slug. * @param string $engine Template engine slug. * @param string $paper_width Thermal paper width, when available. * * @return string Fallback template title. */ public static function get_fallback_template_title( int $template_id, string $type = 'receipt', string $engine = '', string $paper_width = '' ): string { if ( 'report' === $type ) { /* translators: %d: template post ID. */ return sprintf( __( 'Report Template #%d', 'woocommerce-pos' ), $template_id ); } if ( 'thermal' === $engine ) { if ( '' !== $paper_width ) { /* translators: 1: thermal paper width, 2: template post ID. */ return sprintf( __( 'Thermal Receipt Template (%1$s) #%2$d', 'woocommerce-pos' ), $paper_width, $template_id ); } /* translators: %d: template post ID. */ return sprintf( __( 'Thermal Receipt Template #%d', 'woocommerce-pos' ), $template_id ); } if ( 'logicless' === $engine ) { /* translators: %d: template post ID. */ return sprintf( __( 'HTML Receipt Template #%d', 'woocommerce-pos' ), $template_id ); } if ( 'legacy-php' === $engine ) { /* translators: %d: template post ID. */ return sprintf( __( 'PHP Receipt Template #%d', 'woocommerce-pos' ), $template_id ); } /* translators: %d: template post ID. */ return sprintf( __( 'Receipt Template #%d', 'woocommerce-pos' ), $template_id ); } /** * Get a database template by ID. * * @param int $template_id Template post ID. * * @return null|array Template data or null if not found. */ public static function get_template( int $template_id ): ?array { self::ensure_registered(); $post = get_post( $template_id ); if ( ! $post || 'wcpos_template' !== $post->post_type ) { return null; } $terms = wp_get_post_terms( $template_id, 'wcpos_template_type' ); $type = ! empty( $terms ) && ! is_wp_error( $terms ) ? $terms[0]->slug : 'receipt'; $description = get_post_meta( $template_id, '_template_description', true ); $language = get_post_meta( $template_id, '_template_language', true ); $engine = get_post_meta( $template_id, '_template_engine', true ); $output_type = get_post_meta( $template_id, '_template_output_type', true ); $tax_display = get_post_meta( $template_id, '_template_tax_display', true ); $gallery_key = get_post_meta( $template_id, '_template_gallery_key', true ); $preview_data = null; if ( \is_string( $gallery_key ) && '' !== $gallery_key ) { if ( ! array_key_exists( $gallery_key, self::$gallery_preview_data_cache ) ) { $gallery_metadata = self::get_gallery_template_metadata( $gallery_key ); self::$gallery_preview_data_cache[ $gallery_key ] = $gallery_metadata['preview_data'] ?? null; } $preview_data = self::$gallery_preview_data_cache[ $gallery_key ]; } $paper_width = get_post_meta( $template_id, '_template_paper_width', true ); $category = self::get_template_category( $template_id ); $title = trim( $post->post_title ); if ( '' === $title ) { $title = self::get_fallback_template_title( $template_id, $type, $engine ? $engine : 'legacy-php', $paper_width ? $paper_width : '' ); } return array( 'id' => $post->ID, 'title' => $title, 'description' => $description ? $description : '', 'content' => $post->post_content, 'type' => $type, 'category' => '' !== $category ? $category : ( 'receipt' === $type ? 'receipt' : '' ), 'language' => $language ? $language : 'php', 'file_path' => get_post_meta( $template_id, '_template_file_path', true ), 'engine' => $engine ? $engine : 'legacy-php', 'output_type' => $output_type ? $output_type : 'html', 'paper_width' => $paper_width ? $paper_width : null, 'tax_display' => $tax_display ? $tax_display : 'default', 'is_virtual' => false, 'is_premade' => (bool) get_post_meta( $template_id, '_template_is_premade', true ), 'gallery_key' => $gallery_key ? $gallery_key : null, 'preview_data' => $preview_data, 'gallery_version' => (int) get_post_meta( $template_id, '_template_gallery_version', true ), 'status' => $post->post_status, 'source' => 'custom', 'menu_order' => $post->menu_order, 'date_created' => $post->post_date, 'date_modified' => $post->post_modified, 'date_modified_gmt' => $post->post_modified_gmt, ); } /** * Get the category slug for a template. * * @param int $template_id Template post ID. * * @return string Category slug or empty string. */ private static function get_template_category( int $template_id ): string { $terms = wp_get_post_terms( $template_id, 'wcpos_template_category' ); if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) { return $terms[0]->slug; } return ''; } /** * Get a virtual (filesystem) template by ID. * * @param string $template_id Virtual template ID (theme, plugin-pro, plugin-core). * @param string $type Template type (receipt, report). * * @return null|array Template data or null if not found. */ public static function get_virtual_template( string $template_id, string $type = 'receipt' ): ?array { $file_path = self::get_virtual_template_path( $template_id, $type ); if ( ! $file_path || ! file_exists( $file_path ) ) { return null; } $metadata = array( self::TEMPLATE_THEME => array( 'title' => /* translators: Receipt template post type or template option label. */ __( 'Theme Receipt Template', 'woocommerce-pos' ), 'description' => '', 'category' => 'receipt', ), self::TEMPLATE_PLUGIN_PRO => array( 'title' => /* translators: Receipt template post type or template option label. */ __( 'Pro Receipt Template', 'woocommerce-pos' ), 'description' => '', 'category' => 'receipt', ), self::TEMPLATE_PLUGIN_CORE => array( 'title' => /* translators: Receipt template post type or template option label. */ __( 'Legacy PHP Template', 'woocommerce-pos' ), 'description' => '', 'category' => 'receipt', ), self::TEMPLATE_WP_OVERNIGHT_INVOICE => array( 'title' => __( 'Invoice (WP Overnight)', 'woocommerce-pos' ), 'description' => __( 'Renders the PDF Invoices & Packing Slips invoice HTML through the WP Overnight document API.', 'woocommerce-pos' ), 'category' => 'invoice', ), self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP => array( 'title' => __( 'Packing Slip (WP Overnight)', 'woocommerce-pos' ), 'description' => __( 'Renders the PDF Invoices & Packing Slips packing slip HTML through the WP Overnight document API.', 'woocommerce-pos' ), 'category' => 'receipt', ), ); return array( 'id' => $template_id, 'title' => $metadata[ $template_id ]['title'] ?? $template_id, 'description' => $metadata[ $template_id ]['description'] ?? '', 'content' => file_get_contents( $file_path ), 'type' => $type, 'category' => 'receipt' === $type ? ( $metadata[ $template_id ]['category'] ?? 'receipt' ) : '', 'language' => 'php', 'file_path' => $file_path, 'engine' => 'legacy-php', 'output_type' => 'html', 'paper_width' => null, 'is_virtual' => true, 'source' => self::TEMPLATE_THEME === $template_id ? 'theme' : 'plugin', 'menu_order' => 0, 'date_modified_gmt' => gmdate( 'Y-m-d H:i:s', filemtime( $file_path ) ), ); } /** * Check if the Pro license is active. * * @return bool True if Pro license is active. */ public static function is_pro_license_active(): bool { if ( \function_exists( 'woocommerce_pos_pro_activated' ) ) { return (bool) woocommerce_pos_pro_activated(); } return false; } /** * Get the file path for a virtual template. * * @param string $template_id Virtual template ID. * @param string $type Template type. * * @return null|string File path or null if not found. */ public static function get_virtual_template_path( string $template_id, string $type = 'receipt' ): ?string { if ( ! in_array( $type, self::SUPPORTED_TYPES, true ) ) { return null; } $file_name = $type . '.php'; $directory = null; $path = null; switch ( $template_id ) { case self::TEMPLATE_THEME: $directory = get_stylesheet_directory() . '/woocommerce-pos/'; $path = $directory . $file_name; break; case self::TEMPLATE_PLUGIN_PRO: // Pro template requires both the plugin AND an active license. if ( \defined( 'WCPOS\WooCommercePOSPro\PLUGIN_PATH' ) && self::is_pro_license_active() ) { $directory = \WCPOS\WooCommercePOSPro\PLUGIN_PATH . 'templates/'; $path = $directory . $file_name; break; } return null; case self::TEMPLATE_PLUGIN_CORE: $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/'; $path = $directory . $file_name; break; case self::TEMPLATE_WP_OVERNIGHT_INVOICE: if ( 'receipt' !== $type || ! self::is_wp_overnight_pdf_templates_available() ) { return null; } $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/'; $path = $directory . 'wp-overnight-invoice.php'; break; case self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP: if ( 'receipt' !== $type || ! self::is_wp_overnight_pdf_templates_available() ) { return null; } $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/'; $path = $directory . 'wp-overnight-packing-slip.php'; break; default: return null; } if ( null === $directory || null === $path ) { return null; } $real_directory = realpath( $directory ); $real_path = realpath( $path ); if ( false === $real_directory || false === $real_path || ! is_file( $real_path ) ) { return null; } $trusted_prefix = trailingslashit( wp_normalize_path( $real_directory ) ); $real_path = wp_normalize_path( $real_path ); return 0 === strpos( $real_path, $trusted_prefix ) ? $real_path : null; } /** * Check whether WP Overnight PDF Invoices & Packing Slips APIs are available. * * @return bool True when the integration templates can be exposed. */ private static function is_wp_overnight_pdf_templates_available(): bool { $available = \function_exists( 'wcpdf_get_document' ); /** * Filters WP Overnight PDF Invoices & Packing Slips template availability. * * @param bool $available Whether the third-party document API appears available. * * @returns bool Whether to expose the WP Overnight virtual receipt templates. * * @since 1.9.2 * * @hook woocommerce_pos_wp_overnight_pdf_templates_enabled */ return (bool) apply_filters( 'woocommerce_pos_wp_overnight_pdf_templates_enabled', $available ); } /** * Detect all available filesystem templates for a type. * Returns templates in priority order: Theme > Pro > Core. * * @param string $type Template type (receipt, report). * * @return array Array of available virtual templates. */ public static function detect_filesystem_templates( string $type = 'receipt' ): array { $templates = array(); // Check in priority order: Theme > Pro > Core. $priority_order = array( self::TEMPLATE_THEME, self::TEMPLATE_PLUGIN_PRO, self::TEMPLATE_PLUGIN_CORE, self::TEMPLATE_WP_OVERNIGHT_INVOICE, self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP, ); foreach ( $priority_order as $template_id ) { $template = self::get_virtual_template( $template_id, $type ); if ( $template ) { $templates[] = $template; } } return $templates; } /** * Get the default (highest priority) filesystem template for a type. * * @param string $type Template type (receipt, report). * * @return null|array Default template data or null if none found. */ public static function get_default_template( string $type = 'receipt' ): ?array { $templates = self::detect_filesystem_templates( $type ); return ! empty( $templates ) ? $templates[0] : null; } /** * Get the ID of the active template for a type. * * @param string $type Template type (receipt, report). * * @return null|int|string Active template ID (int for database, string for virtual), or null. */ public static function get_active_template_id( string $type = 'receipt' ) { $active_id = get_option( 'wcpos_active_template_' . $type, null ); $enabled = self::get_enabled_templates( $type ); $enabled_ids = array_map( static function ( $template ) { return (string) $template['id']; }, $enabled ); // If no explicit active template, use first from enabled list. if ( null === $active_id || '' === $active_id ) { return ! empty( $enabled ) ? $enabled[0]['id'] : null; } // Validate that the stored active template is still enabled. if ( ! \in_array( (string) $active_id, $enabled_ids, true ) ) { delete_option( 'wcpos_active_template_' . $type ); return ! empty( $enabled ) ? $enabled[0]['id'] : null; } return is_numeric( $active_id ) ? (int) $active_id : $active_id; } /** * Get active template for a specific type. * Returns the full template data. * * @param string $type Template type (receipt, report). * * @return null|array Active template data or null if not found. */ public static function get_active_template( string $type = 'receipt' ): ?array { $active_id = self::get_active_template_id( $type ); if ( null === $active_id ) { return null; } // Check if it's a database template (numeric ID). if ( is_numeric( $active_id ) ) { return self::get_template( (int) $active_id ); } // It's a virtual template. return self::get_virtual_template( $active_id, $type ); } /** * Set the active template by ID. * * @param int|string $template_id Template ID (int for database, string for virtual). * @param string $type Template type (receipt, report). * * @return bool True on success, false on failure. */ public static function set_active_template_id( $template_id, string $type = 'receipt' ): bool { // Validate the template exists. if ( is_numeric( $template_id ) ) { $template = self::get_template( (int) $template_id ); if ( ! $template ) { return false; } } else { $template = self::get_virtual_template( $template_id, $type ); if ( ! $template ) { return false; } } return update_option( 'wcpos_active_template_' . $type, $template_id ); } /** * Set template as active (legacy method for backwards compatibility). * * @param int $template_id Template post ID. * * @return bool True on success, false on failure. */ public static function set_active_template( int $template_id ): bool { $template = self::get_template( $template_id ); if ( ! $template ) { return false; } return self::set_active_template_id( $template_id, $template['type'] ); } /** * Get the stored display order for templates of a given type. * * @param string $type Template type (receipt, report). * * @return array Ordered array of template IDs (int for database, string for virtual). */ public static function get_template_order( string $type = 'receipt' ): array { $order = get_option( 'wcpos_template_order_' . $type, array() ); if ( ! \is_array( $order ) ) { return array(); } return $order; } /** * Save the display order for templates of a given type. * * @param array $order Array of template IDs in display order. * @param string $type Template type (receipt, report). * * @return bool True on success. */ public static function save_template_order( array $order, string $type = 'receipt' ): bool { // Sanitize: keep only integers and safe strings. $sanitized = array(); foreach ( $order as $id ) { if ( \is_int( $id ) || ( \is_numeric( $id ) && (int) $id > 0 ) ) { $sanitized[] = (int) $id; } elseif ( \is_string( $id ) ) { $clean = sanitize_text_field( $id ); if ( '' !== $clean ) { $sanitized[] = $clean; } } } return update_option( 'wcpos_template_order_' . $type, $sanitized ); } /** * Get the list of disabled virtual template IDs for a given type. * * @param string $type Template type (receipt, report). * * @return string[] Array of disabled virtual template IDs. */ public static function get_disabled_virtual_templates( string $type = 'receipt' ): array { $disabled = get_option( 'wcpos_disabled_virtual_templates_' . $type, array() ); if ( ! \is_array( $disabled ) ) { return array(); } return $disabled; } /** * Check if a virtual template is disabled. * * @param string $template_id Virtual template ID. * @param string $type Template type (receipt, report). * * @return bool True if disabled. */ public static function is_virtual_template_disabled( string $template_id, string $type = 'receipt' ): bool { $disabled = self::get_disabled_virtual_templates( $type ); return \in_array( $template_id, $disabled, true ); } /** * Set the disabled state of a virtual template. * * @param string $template_id Virtual template ID. * @param bool $disabled True to disable, false to enable. * @param string $type Template type (receipt, report). * * @return bool True on success. */ public static function set_virtual_template_disabled( string $template_id, bool $disabled, string $type = 'receipt' ): bool { $current = self::get_disabled_virtual_templates( $type ); if ( $disabled ) { if ( ! \in_array( $template_id, $current, true ) ) { $current[] = $template_id; } } else { $current = array_values( array_filter( $current, function ( $id ) use ( $template_id ) { return $id !== $template_id; } ) ); } return update_option( 'wcpos_disabled_virtual_templates_' . $type, $current ); } /** * Check if a template is currently active. * * @param int|string $template_id Template ID. * @param string $type Template type. * * @return bool True if active. */ public static function is_active_template( $template_id, string $type = 'receipt' ): bool { $active_id = self::get_active_template_id( $type ); if ( null === $active_id ) { return false; } // Normalize for comparison. if ( is_numeric( $template_id ) && is_numeric( $active_id ) ) { return (int) $template_id === (int) $active_id; } return (string) $template_id === (string) $active_id; } /** * Resolve the ordered list of templates for a given store. * * If the store has a per-store override (active_receipt_templates meta), * returns only those templates intersected with the global enabled list. * Otherwise returns the full global enabled list. * * @param int $store_id Store post ID. 0 for global defaults. * @param string $type Template type (e.g. 'receipt'). * * @return array Array of template data arrays, in display order. */ public static function resolve_templates( int $store_id, string $type = 'receipt' ): array { $global_list = self::get_enabled_templates( $type ); if ( ! $store_id ) { return $global_list; } $meta_key = '_wcpos_active_' . sanitize_key( $type ) . '_templates'; $raw = get_post_meta( $store_id, $meta_key, true ); $override = is_string( $raw ) ? json_decode( $raw, true ) : array(); if ( empty( $override ) || ! \is_array( $override ) ) { return $global_list; } // Build a lookup of global templates by ID (normalize to string for comparison). $global_by_id = array(); foreach ( $global_list as $template ) { $global_by_id[ (string) $template['id'] ] = $template; } // Intersect store override with global enabled list, preserving store order. $resolved = array(); foreach ( $override as $template_id ) { $key = (string) $template_id; if ( isset( $global_by_id[ $key ] ) ) { $resolved[] = $global_by_id[ $key ]; } } // Fallback: if all overridden templates are globally disabled, use global list. if ( empty( $resolved ) ) { return $global_list; } return $resolved; } /** * Get all enabled templates for a type, in stored order. * * Combines virtual (filesystem) templates and database (custom) templates, * filters to only enabled ones, and sorts by the stored template order. * * @param string $type Template type. * * @return array Array of template data arrays. */ public static function get_enabled_templates( string $type = 'receipt' ): array { self::ensure_registered(); $disabled_virtual = self::get_disabled_virtual_templates( $type ); $order = self::get_template_order( $type ); $templates = array(); // Collect enabled virtual templates. $virtual = self::detect_filesystem_templates( $type ); foreach ( $virtual as $template ) { if ( ! \in_array( (string) $template['id'], $disabled_virtual, true ) ) { $templates[] = $template; } } // Collect enabled database (custom) templates. $posts = get_posts( array( 'post_type' => 'wcpos_template', 'post_status' => 'publish', 'posts_per_page' => -1, 'tax_query' => array( array( 'taxonomy' => 'wcpos_template_type', 'field' => 'slug', 'terms' => $type, ), ), ) ); foreach ( $posts as $post ) { $template = self::get_template( $post->ID ); if ( $template ) { $templates[] = $template; } } // Sort by stored order if available. if ( ! empty( $order ) ) { $order_map = array_flip( array_map( 'strval', $order ) ); usort( $templates, function ( $a, $b ) use ( $order_map ) { $pos_a = $order_map[ (string) $a['id'] ] ?? PHP_INT_MAX; $pos_b = $order_map[ (string) $b['id'] ] ?? PHP_INT_MAX; return $pos_a - $pos_b; } ); } return $templates; } /** * One-time migration: move the active template to first position in the order, * then delete the wcpos_active_template_{type} option. * * @param string $type Template type. */ public static function migrate_active_template_to_order( string $type = 'receipt' ): void { $option_key = 'wcpos_active_template_' . $type; $active_id = get_option( $option_key ); if ( false === $active_id ) { return; // Nothing to migrate. } $order = self::get_template_order( $type ); if ( ! empty( $order ) ) { // Remove active_id from its current position. $order = array_values( array_filter( $order, function ( $id ) use ( $active_id ) { return (string) $id !== (string) $active_id; } ) ); // Prepend it. array_unshift( $order, $active_id ); self::save_template_order( $order, $type ); } delete_option( $option_key ); } /** * Get available starter/example templates. * * Returns metadata for example templates bundled with the plugin. * These can be installed as custom templates by the user. * * @return array Array of starter template definitions. */ public static function get_starter_templates(): array { $starters = array(); // Only include starters whose files actually exist. return array_filter( $starters, function ( $starter ) { return file_exists( $starter['file'] ); } ); } /** * Get gallery templates from the templates/gallery/ directory. * * @param string|null $type Filter by type. Null for all. * @param string|null $category Filter by category. Null for all. * * @return array Array of gallery template data. */ public static function get_gallery_templates( ?string $type = null, ?string $category = null ): array { $gallery_dir = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/gallery/'; if ( ! is_dir( $gallery_dir ) ) { return array(); } $templates = array(); foreach ( Gallery_Registry::all() as $key => $metadata ) { if ( $type && ( $metadata['type'] ?? '' ) !== $type ) { continue; } if ( $category && ( $metadata['category'] ?? '' ) !== $category ) { continue; } $content_file = self::find_gallery_content_file( $key ); if ( '' === $content_file ) { continue; } $templates[] = self::build_gallery_template( self::prepare_gallery_metadata( $key, $metadata ), $content_file ); } usort( $templates, function ( $a, $b ) { return strcmp( $a['key'], $b['key'] ); } ); return $templates; } /** * Locate the content file for a bundled gallery template key. * * Only stats the candidate paths for the given key — it never reads file * contents, and never touches the other bundled gallery templates. * * @param string $key Gallery template key (e.g. "standard-receipt"). * * @return string Absolute path to the content file, or '' when none exists. */ private static function find_gallery_content_file( string $key ): string { $gallery_dir = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/gallery/'; foreach ( self::GALLERY_CONTENT_EXTENSIONS as $ext ) { $candidate = $gallery_dir . $key . '.' . $ext; if ( file_exists( $candidate ) ) { return $candidate; } } return ''; } /** * Normalize a raw registry entry into gallery template metadata. * * @param string $key Gallery template key. * @param array $metadata Raw registry entry. * * @return array Normalized metadata. */ private static function prepare_gallery_metadata( string $key, array $metadata ): array { $metadata['key'] = $key; $metadata['direction'] = isset( $metadata['direction'] ) && 'rtl' === $metadata['direction'] ? 'rtl' : 'ltr'; return $metadata; } /** * Build a full gallery template record by reading its content file. * * @param array $metadata Normalized metadata for the key. * @param string $content_file Absolute path to the content file. * * @return array Gallery template record. */ private static function build_gallery_template( array $metadata, string $content_file ): array { return array_merge( $metadata, array( 'content' => file_get_contents( $content_file ), 'content_file' => $content_file, 'is_premade' => true, 'is_virtual' => true, 'source' => 'gallery', 'offline_capable' => in_array( $metadata['engine'] ?? 'logicless', self::OFFLINE_CAPABLE_ENGINES, true ), ) ); } /** * Get a single gallery template's metadata without reading any template file. * * Metadata (title, description, engine, preview_data, ...) lives in * Gallery_Registry, so callers that only need metadata never pay for reading * the bundled template content — which is why get_template() uses this rather * than get_gallery_template_by_key(). * * @param string $key Gallery template key (e.g. "standard-receipt"). * * @return null|array Gallery template metadata, or null when the key is unknown * or has no bundled content file. */ public static function get_gallery_template_metadata( string $key ): ?array { $registry = Gallery_Registry::all(); if ( ! isset( $registry[ $key ] ) ) { return null; } if ( '' === self::find_gallery_content_file( $key ) ) { return null; } return self::prepare_gallery_metadata( $key, $registry[ $key ] ); } /** * Get a single gallery template by its key. * * Reads only the requested template's content file. * * @param string $key Gallery template key (e.g. "standard-receipt"). * * @return null|array Gallery template data or null if not found. */ public static function get_gallery_template_by_key( string $key ): ?array { $metadata = self::get_gallery_template_metadata( $key ); if ( null === $metadata ) { return null; } return self::build_gallery_template( $metadata, self::find_gallery_content_file( $key ) ); } /** * Install a starter template as a custom (database) template. * * @param string $starter_key Key from get_starter_templates(). * * @return int|\WP_Error Post ID on success, WP_Error on failure. */ public static function install_starter_template( string $starter_key ) { $starters = self::get_starter_templates(); if ( ! isset( $starters[ $starter_key ] ) ) { return new \WP_Error( 'invalid_starter', __( 'Starter template not found.', 'woocommerce-pos' ) ); } $starter = $starters[ $starter_key ]; $content = file_get_contents( $starter['file'] ); if ( false === $content ) { return new \WP_Error( 'read_failed', __( 'Could not read starter template file.', 'woocommerce-pos' ) ); } $post_id = wp_insert_post( array( 'post_title' => $starter['title'], 'post_content' => $content, 'post_status' => 'publish', 'post_type' => 'wcpos_template', ), true ); if ( is_wp_error( $post_id ) ) { return $post_id; } wp_set_object_terms( $post_id, $starter['type'], 'wcpos_template_type' ); update_post_meta( $post_id, '_template_language', $starter['language'] ); update_post_meta( $post_id, '_template_engine', $starter['engine'] ); update_post_meta( $post_id, '_template_output_type', 'html' ); // Bypass wp_kses for offline-capable engines — it strips unknown HTML/XML tags. if ( \in_array( $starter['engine'], self::OFFLINE_CAPABLE_ENGINES, true ) ) { if ( ! self::save_raw_post_content( $post_id, $content ) ) { wp_delete_post( $post_id, true ); return new \WP_Error( 'wcpos_template_content_save_failed', __( 'Template was created but raw content could not be saved.', 'woocommerce-pos' ) ); } } return $post_id; } /** * Install a gallery template as a custom (database) template. * * @param string $gallery_key Key matching a gallery template JSON file. * * @return int|\WP_Error Post ID on success, WP_Error on failure. */ public static function install_gallery_template( string $gallery_key ) { $gallery_templates = self::get_gallery_templates(); $template = null; foreach ( $gallery_templates as $gt ) { if ( $gt['key'] === $gallery_key ) { $template = $gt; break; } } if ( ! $template ) { return new \WP_Error( 'invalid_gallery_key', __( 'Gallery template not found.', 'woocommerce-pos' ) ); } // Translate interpolated phrases (text mixed with Mustache variables) for the current locale. $content = Receipt_I18n_Labels::translate_interpolated_phrases( $template['content'] ); $post_id = wp_insert_post( array( 'post_title' => $template['title'], 'post_content' => $content, 'post_status' => 'publish', 'post_type' => 'wcpos_template', ), true ); if ( is_wp_error( $post_id ) ) { return $post_id; } // Set taxonomies. wp_set_object_terms( $post_id, $template['type'] ?? 'receipt', 'wcpos_template_type' ); if ( ! empty( $template['category'] ) ) { wp_set_object_terms( $post_id, $template['category'], 'wcpos_template_category' ); } // Set meta fields — normalize engine once for consistent derived values. $engine = $template['engine'] ?? 'logicless'; update_post_meta( $post_id, '_template_description', $template['description'] ?? '' ); update_post_meta( $post_id, '_template_engine', $engine ); update_post_meta( $post_id, '_template_output_type', $template['output_type'] ?? 'html' ); if ( 'logicless' === $engine ) { $language = 'html'; } elseif ( 'thermal' === $engine ) { $language = 'xml'; } else { $language = 'php'; } update_post_meta( $post_id, '_template_language', $language ); update_post_meta( $post_id, '_template_gallery_key', $gallery_key ); update_post_meta( $post_id, '_template_gallery_version', $template['version'] ?? 1 ); update_post_meta( $post_id, '_template_tax_display', 'default' ); if ( ! empty( $template['paper_width'] ) ) { update_post_meta( $post_id, '_template_paper_width', $template['paper_width'] ); } // Bypass wp_kses for offline-capable engines — it strips unknown HTML/XML tags. if ( \in_array( $engine, self::OFFLINE_CAPABLE_ENGINES, true ) ) { if ( ! self::save_raw_post_content( $post_id, $content ) ) { wp_delete_post( $post_id, true ); return new \WP_Error( 'wcpos_template_content_save_failed', __( 'Template was created but raw content could not be saved.', 'woocommerce-pos' ) ); } } return $post_id; } /** * Register default template types (receipt, report). * * @return void */ private function register_default_template_types(): void { // Check if terms already exist to avoid duplicates. if ( ! term_exists( 'receipt', 'wcpos_template_type' ) ) { wp_insert_term( 'Receipt', 'wcpos_template_type', array( 'slug' => 'receipt', 'description' => /* translators: Receipt template post type or template option label. */ __( 'Receipt templates for printing orders', 'woocommerce-pos' ), ) ); } if ( ! term_exists( 'report', 'wcpos_template_type' ) ) { wp_insert_term( 'Report', 'wcpos_template_type', array( 'slug' => 'report', 'description' => __( 'Report templates for analytics', 'woocommerce-pos' ), ) ); } } /** * The default template categories: slug => label. * * @return array */ private static function default_template_categories(): array { return array( 'receipt' => /* translators: Receipt template post type or template option label. */ __( 'Receipt', 'woocommerce-pos' ), 'invoice' => /* translators: Receipt template post type or template option label. */ __( 'Invoice', 'woocommerce-pos' ), 'gift-receipt' => /* translators: Receipt template post type or template option label. */ __( 'Gift Receipt', 'woocommerce-pos' ), 'credit-note' => /* translators: Receipt template post type or template option label. */ __( 'Credit Note', 'woocommerce-pos' ), 'purchase-order' => /* translators: Receipt template post type or template option label. */ __( 'Purchase Order', 'woocommerce-pos' ), 'kitchen-ticket' => /* translators: Receipt template post type or template option label. */ __( 'Kitchen Ticket', 'woocommerce-pos' ), 'bar-ticket' => /* translators: Receipt template post type or template option label. */ __( 'Bar Ticket', 'woocommerce-pos' ), ); } /** * Register default template categories. * * @return void */ private function register_default_template_categories(): void { foreach ( self::default_template_categories() as $slug => $name ) { if ( ! term_exists( $slug, 'wcpos_template_category' ) ) { wp_insert_term( $name, 'wcpos_template_category', array( 'slug' => $slug ) ); } } } /** * Custom metabox for template type selection. * Ensures one type is always selected with 'receipt' as default. * * @param \WP_Post $post Post object. * @param array $box Metabox arguments. * * @return void */ public function template_type_metabox( \WP_Post $post, array $box ): void { $taxonomy = $box['args']['taxonomy']; $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, ) ); if ( empty( $terms ) || is_wp_error( $terms ) ) { return; } // Get current terms. $current_terms = wp_get_post_terms( $post->ID, $taxonomy ); $current_slug = ! empty( $current_terms ) && ! is_wp_error( $current_terms ) ? $current_terms[0]->slug : 'receipt'; ?>