diff --git a/includes/optional-modules/class-indesign-exporter.php b/includes/optional-modules/class-indesign-exporter.php index 612a539cbc..5e38b99ed1 100644 --- a/includes/optional-modules/class-indesign-exporter.php +++ b/includes/optional-modules/class-indesign-exporter.php @@ -23,6 +23,58 @@ class InDesign_Exporter { */ public const MODULE_NAME = 'indesign-export'; + /** + * Option name storing the platform header preference. + * + * Accepts 'auto', 'mac', or 'win'. 'auto' resolves the header at export + * time from the requesting browser's User-Agent. + * + * @var string + */ + public const PLATFORM_OPTION = 'newspack_indesign_export_platform'; + + /** + * Default value for the platform option. + * + * @var string + */ + public const PLATFORM_DEFAULT = 'auto'; + + /** + * Allowed values for the platform option. + * + * @var string[] + */ + public const ALLOWED_PLATFORMS = [ 'auto', 'mac', 'win' ]; + + /** + * Option name storing the list of post types whose admin screens get the export action. + * + * @var string + */ + public const POST_TYPES_OPTION = 'newspack_indesign_export_post_types'; + + /** + * Default value for the post types option. + * + * @var string[] + */ + public const POST_TYPES_DEFAULT = [ 'post' ]; + + /** + * Post types hidden from the admin setting because they have no editorial + * "article content" to export (lists, feeds, store products, etc.). + * + * @var string[] + */ + private const EXCLUDED_POST_TYPES = [ + 'attachment', + 'partner_rss_feed', // Newspack RSS feeds. + 'newspack_nl_list', // Newspack Newsletters subscription lists. + 'newspack_collection', // Newspack Collections. + 'product', // WooCommerce products. + ]; + /** * Initialize the module. */ @@ -45,7 +97,10 @@ public static function init() { add_filter( "handle_bulk_actions-edit-{$post_type}", [ __CLASS__, 'handle_bulk_action' ], 100, 3 ); } + // WordPress dispatches to `page_row_actions` for hierarchical post types + // (pages, hierarchical CPTs) and `post_row_actions` for the rest, so hook both. add_filter( 'post_row_actions', [ __CLASS__, 'add_row_action' ], 10, 2 ); + add_filter( 'page_row_actions', [ __CLASS__, 'add_row_action' ], 10, 2 ); add_action( 'admin_post_export_indesign_single', [ __CLASS__, 'handle_single_export' ] ); add_action( 'admin_notices', [ __CLASS__, 'admin_notices' ] ); } @@ -67,10 +122,14 @@ public static function is_feature_enabled() { /** * Get supported post types for InDesign export. * - * @return array Array of supported post types. + * Reads from the site setting (defaulting to the built-in post type) and + * then runs the `newspack_indesign_export_supported_post_types` filter so + * code-level extension points still work alongside the admin setting. + * + * @return array Array of supported post type slugs. */ public static function get_supported_post_types() { - $supported_post_types = [ 'post' ]; + $supported_post_types = self::get_post_types_setting(); /** * Filters the post types that support InDesign export. @@ -80,6 +139,77 @@ public static function get_supported_post_types() { return apply_filters( 'newspack_indesign_export_supported_post_types', $supported_post_types ); } + /** + * Get the stored post types setting, sanitized. + * + * Filters out slugs whose post type is no longer registered (e.g. a CPT + * plugin was deactivated). Returns the default when the option is unset + * or contains a non-array value. + * + * @return string[] Sanitized array of post type slugs. + */ + public static function get_post_types_setting() { + $value = get_option( self::POST_TYPES_OPTION, self::POST_TYPES_DEFAULT ); + if ( ! is_array( $value ) ) { + return self::POST_TYPES_DEFAULT; + } + + return array_values( + array_filter( + $value, + static function ( $slug ) { + return is_string( $slug ) && post_type_exists( $slug ); + } + ) + ); + } + + /** + * Get the list of post types eligible to appear in the admin setting. + * + * Returns post types registered as public and with an admin UI, excluding + * attachments and any post type listed in EXCLUDED_POST_TYPES (lists, feeds, + * products, etc. — types with no editorial article content). + * + * @return array Available options. + */ + public static function get_available_post_types() { + $post_types = get_post_types( + [ + 'public' => true, + 'show_ui' => true, + ], + 'objects' + ); + + /** + * Filters the list of post type slugs hidden from the InDesign export + * setting. Lets sites add or remove exclusions for custom post types + * that aren't editorial content. + * + * @param string[] $excluded Default exclusions: attachments, RSS feeds, + * subscription lists, collections, WooCommerce + * products. + */ + $excluded = (array) apply_filters( + 'newspack_indesign_export_excluded_post_types', + self::EXCLUDED_POST_TYPES + ); + + $options = []; + foreach ( $post_types as $post_type ) { + if ( in_array( $post_type->name, $excluded, true ) ) { + continue; + } + $options[] = [ + 'value' => $post_type->name, + 'label' => $post_type->labels->name ?? $post_type->name, + ]; + } + + return $options; + } + /** * Enqueue block editor assets. */ @@ -135,6 +265,11 @@ public static function handle_bulk_action( $redirect_to, $doaction, $post_ids ) return add_query_arg( 'indesign_export_error', 'no_posts', $redirect_to ); } + $post_ids = array_values( array_filter( $post_ids, [ __CLASS__, 'is_post_supported' ] ) ); + if ( empty( $post_ids ) ) { + return add_query_arg( 'indesign_export_error', 'unsupported_post_type', $redirect_to ); + } + self::export_posts( $post_ids ); exit; } @@ -200,10 +335,37 @@ public static function handle_single_export() { exit; } + if ( ! self::is_post_supported( $post_id ) ) { + wp_safe_redirect( + add_query_arg( 'indesign_export_error', 'unsupported_post_type', admin_url( 'edit.php' ) ) + ); + exit; + } + self::export_posts( [ $post_id ] ); exit; } + /** + * Whether the given post may be exported under the current settings. + * + * Defense in depth — the bulk and row UI actions only appear for + * post types in get_supported_post_types(), but the underlying + * `admin_post_export_indesign_single` action and bulk handler could + * otherwise be invoked with a post of a disabled type by anyone who can + * edit that post. + * + * @param int|\WP_Post $post Post ID or object. + * @return bool True when the post type is enabled for export. + */ + public static function is_post_supported( $post ) { + $post = get_post( $post ); + if ( ! $post ) { + return false; + } + return in_array( $post->post_type, self::get_supported_post_types(), true ); + } + /** * Export posts as InDesign Tagged Text files. * @@ -211,6 +373,7 @@ public static function handle_single_export() { */ private static function export_posts( $post_ids ) { $converter = new InDesign_Converter(); + $platform = self::resolve_platform(); $exported_files = []; foreach ( $post_ids as $post_id ) { @@ -219,7 +382,7 @@ private static function export_posts( $post_ids ) { continue; } - $content = $converter->convert_post( $post ); + $content = $converter->convert_post( $post, [ 'platform' => $platform ] ); $filename = self::generate_filename( $post ); $exported_files[] = [ 'filename' => $filename, @@ -237,6 +400,63 @@ private static function export_posts( $post_ids ) { } } + /** + * Get the configured platform setting. + * + * @return string One of 'auto', 'mac', 'win'. + */ + public static function get_platform_setting() { + $value = get_option( self::PLATFORM_OPTION, self::PLATFORM_DEFAULT ); + return in_array( $value, self::ALLOWED_PLATFORMS, true ) ? $value : self::PLATFORM_DEFAULT; + } + + /** + * Map a User-Agent string to a platform. + * + * Pure helper extracted so the auto-detect branch of resolve_platform() + * is testable without spoofing $_SERVER globals. + * + * @param string $user_agent User-Agent string to inspect. + * @return string Either 'mac' or 'win'. Empty/non-Mac strings yield 'win'. + */ + public static function sniff_user_agent_platform( $user_agent ) { + return ( false !== stripos( $user_agent, 'Mac' ) || false !== stripos( $user_agent, 'iPad' ) || false !== stripos( $user_agent, 'iPhone' ) ) ? 'mac' : 'win'; + } + + /** + * Resolve the InDesign Tagged Text header platform for the current export. + * + * Honors the site setting first. When the setting is 'auto', the platform + * is sniffed from the requesting browser's User-Agent — InDesign requires + * the header to match the host OS or markup is rendered literally. + * + * @return string Either 'mac' or 'win'. + */ + private static function resolve_platform() { + $setting = self::get_platform_setting(); + $user_agent = ''; + + if ( 'mac' === $setting || 'win' === $setting ) { + $platform = $setting; + } else { + // The export runs from an authenticated admin-post.php request that is never cached. + // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__HTTP_USER_AGENT__ + $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; + $platform = self::sniff_user_agent_platform( $user_agent ); + } + + /** + * Filters the resolved platform for an InDesign export. + * + * @param string $platform 'mac' or 'win'. + * @param string $setting The stored platform setting ('auto', 'mac', or 'win'). + * @param string $user_agent The User-Agent header from the request after + * sanitize_text_field() + wp_unslash(), or '' when + * not consulted (i.e. setting is not 'auto'). + */ + return apply_filters( 'newspack_indesign_export_platform', $platform, $setting, $user_agent ); + } + /** * Generate filename for exported post. * @@ -321,6 +541,9 @@ public static function admin_notices() { case 'no_posts': $message = __( 'No posts were selected for export.', 'newspack' ); break; + case 'unsupported_post_type': + $message = __( 'The selected post type is not enabled for InDesign export.', 'newspack' ); + break; case 'zip_error': $message = __( 'Could not create ZIP file for export.', 'newspack' ); break; diff --git a/includes/optional-modules/indesign-export/class-indesign-converter.php b/includes/optional-modules/indesign-export/class-indesign-converter.php index a4e6c47dbe..2485c89d2f 100644 --- a/includes/optional-modules/indesign-export/class-indesign-converter.php +++ b/includes/optional-modules/indesign-export/class-indesign-converter.php @@ -63,8 +63,17 @@ public function __construct( $styles = [] ) { /** * Convert a WordPress post to InDesign Tagged Text format. * - * @param int|\WP_Post $post Post ID or WP_Post object. - * @param array $options Optional conversion options. + * @param int|\WP_Post $post Post ID or WP_Post object. + * @param array $options { + * Optional. Conversion options. + * + * @type bool $include_subtitle Whether to include the post subtitle. Default true. + * @type bool $include_byline Whether to include the byline. Default true. + * @type string $platform Target platform for the tagged-text header. + * 'win' emits , 'mac' emits . + * InDesign requires the header to match the host OS, + * otherwise markup is rendered literally. Default 'win'. + * } * @return string|false InDesign Tagged Text content, or false on failure. */ public function convert_post( $post, $options = [] ) { @@ -76,12 +85,13 @@ public function convert_post( $post, $options = [] ) { $default_options = [ 'include_subtitle' => true, 'include_byline' => true, + 'platform' => 'win', ]; $options = wp_parse_args( $options, $default_options ); $content_parts = []; - $content_parts[] = ''; + $content_parts[] = 'mac' === $options['platform'] ? '' : ''; $content_parts[] = $this->styles['headline'] . $this->get_transformed_text( $post->post_title ); if ( $options['include_subtitle'] ) { @@ -431,7 +441,7 @@ private function get_transformed_text( $text ) { // Dashes. '--' => '<0x2014>', '—' => '<0x2014>', - '–' => '<0x2014>', + '–' => '<0x2013>', // Quotes. '“' => '"', diff --git a/includes/wizards/newspack/class-print-section.php b/includes/wizards/newspack/class-print-section.php index 57c5f41c2f..60392e1352 100644 --- a/includes/wizards/newspack/class-print-section.php +++ b/includes/wizards/newspack/class-print-section.php @@ -69,6 +69,9 @@ public function register_rest_routes() { public function api_get_print_settings() { return [ 'module_enabled_print' => Optional_Modules::is_optional_module_active( InDesign_Exporter::MODULE_NAME ), + 'indesign_platform' => InDesign_Exporter::get_platform_setting(), + 'indesign_post_types' => InDesign_Exporter::get_post_types_setting(), + 'available_post_types' => InDesign_Exporter::get_available_post_types(), ]; } @@ -84,14 +87,49 @@ public function api_update_print_settings( $request ) { return new \WP_Error( 'invalid_param', __( 'Invalid parameter for module_enabled_print.', 'newspack' ), [ 'status' => 400 ] ); } + $has_platform_param = $request->has_param( 'indesign_platform' ); + $platform = $has_platform_param ? $request->get_param( 'indesign_platform' ) : null; + if ( $has_platform_param && ! in_array( $platform, InDesign_Exporter::ALLOWED_PLATFORMS, true ) ) { + return new \WP_Error( 'invalid_param', __( 'Invalid parameter for indesign_platform.', 'newspack' ), [ 'status' => 400 ] ); + } + + $has_post_types_param = $request->has_param( 'indesign_post_types' ); + $post_types = $has_post_types_param ? $request->get_param( 'indesign_post_types' ) : null; + if ( $has_post_types_param ) { + if ( ! is_array( $post_types ) ) { + return new \WP_Error( 'invalid_param', __( 'Invalid parameter for indesign_post_types.', 'newspack' ), [ 'status' => 400 ] ); + } + $post_types = array_values( + array_unique( + array_filter( + $post_types, + static function ( $slug ) { + return is_string( $slug ) && post_type_exists( $slug ); + } + ) + ) + ); + } + if ( $module_enabled_print ) { Optional_Modules::activate_optional_module( InDesign_Exporter::MODULE_NAME ); } else { Optional_Modules::deactivate_optional_module( InDesign_Exporter::MODULE_NAME ); } + if ( $has_platform_param ) { + update_option( InDesign_Exporter::PLATFORM_OPTION, $platform ); + } + + if ( $has_post_types_param ) { + update_option( InDesign_Exporter::POST_TYPES_OPTION, $post_types ); + } + return [ 'module_enabled_print' => $module_enabled_print, + 'indesign_platform' => InDesign_Exporter::get_platform_setting(), + 'indesign_post_types' => InDesign_Exporter::get_post_types_setting(), + 'available_post_types' => InDesign_Exporter::get_available_post_types(), ]; } } diff --git a/src/wizards/newspack/views/settings/print/index.tsx b/src/wizards/newspack/views/settings/print/index.tsx index ec20a65879..78b78881af 100644 --- a/src/wizards/newspack/views/settings/print/index.tsx +++ b/src/wizards/newspack/views/settings/print/index.tsx @@ -6,6 +6,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { CheckboxControl, SelectControl } from '@wordpress/components'; /** * Internal dependencies @@ -15,18 +16,37 @@ import WizardSection from '../../../../wizards-section'; import WizardsActionCard from '../../../../wizards-action-card'; import useWizardApiFetchToggle from '../../../../hooks/use-wizard-api-fetch-toggle'; +const PLATFORM_OPTIONS: { label: string; value: IndesignPlatform }[] = [ + { label: __( 'Auto-detect (per export)', 'newspack-plugin' ), value: 'auto' }, + { label: __( 'Mac', 'newspack-plugin' ), value: 'mac' }, + { label: __( 'Windows', 'newspack-plugin' ), value: 'win' }, +]; + function Print() { const { description, apiData, isFetching, actionText, apiFetchToggle, errorMessage } = useWizardApiFetchToggle< PrintData >( { path: '/newspack/v1/wizard/newspack-settings/print', apiNamespace: 'newspack-settings/print', data: { module_enabled_print: false, + indesign_platform: 'auto', + indesign_post_types: [ 'post' ], + available_post_types: [], }, description: __( 'Allows editors to export article content in Adobe InDesign Tagged Text format.', 'newspack-plugin' ), } ); + const togglePostType = ( slug: string, checked: boolean ) => { + const next = new Set( apiData.indesign_post_types ); + if ( checked ) { + next.add( slug ); + } else { + next.delete( slug ); + } + apiFetchToggle( { ...apiData, indesign_post_types: Array.from( next ) }, true ); + }; + return ( - + apiFetchToggle( { ...apiData, module_enabled_print: value }, true ) } /> + { apiData.module_enabled_print && ( + <> + + apiFetchToggle( { ...apiData, indesign_platform: value }, true ) } + /> + + + { apiData.available_post_types.map( option => ( + togglePostType( option.value, checked ) } + /> + ) ) } + + + ) } ); } diff --git a/src/wizards/newspack/views/settings/types.d.ts b/src/wizards/newspack/views/settings/types.d.ts index 679ea3d4d4..d6c9340fe7 100644 --- a/src/wizards/newspack/views/settings/types.d.ts +++ b/src/wizards/newspack/views/settings/types.d.ts @@ -101,9 +101,19 @@ type JetpackSSOSettings = Partial< { } >; /** Print */ +type IndesignPlatform = 'auto' | 'mac' | 'win'; + +type IndesignPostTypeOption = { + value: string; + label: string; +}; + /** * Print API data */ type PrintData = { module_enabled_print: boolean; + indesign_platform: IndesignPlatform; + indesign_post_types: string[]; + available_post_types: IndesignPostTypeOption[]; }; diff --git a/tests/unit-tests/indesign-exporter/class-test-print-section.php b/tests/unit-tests/indesign-exporter/class-test-print-section.php new file mode 100644 index 0000000000..3dc6f8dbb8 --- /dev/null +++ b/tests/unit-tests/indesign-exporter/class-test-print-section.php @@ -0,0 +1,190 @@ +section = new Print_Section(); + } + + /** + * Test that api_get_print_settings returns the expected keys with default values. + */ + public function test_api_get_print_settings_returns_all_keys() { + $result = $this->section->api_get_print_settings(); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'module_enabled_print', $result ); + $this->assertArrayHasKey( 'indesign_platform', $result ); + $this->assertArrayHasKey( 'indesign_post_types', $result ); + $this->assertArrayHasKey( 'available_post_types', $result ); + + $this->assertFalse( $result['module_enabled_print'] ); + $this->assertSame( 'auto', $result['indesign_platform'] ); + $this->assertSame( [ 'post' ], $result['indesign_post_types'] ); + $this->assertIsArray( $result['available_post_types'] ); + } + + /** + * Test that enabling the module persists the optional-module setting. + */ + public function test_api_update_print_settings_enables_module() { + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', true ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertSame( true, $result['module_enabled_print'] ); + $this->assertTrue( Optional_Modules::is_optional_module_active( InDesign_Exporter::MODULE_NAME ) ); + } + + /** + * Test that disabling the module persists the optional-module setting. + */ + public function test_api_update_print_settings_disables_module() { + Optional_Modules::activate_optional_module( InDesign_Exporter::MODULE_NAME ); + + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', false ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertSame( false, $result['module_enabled_print'] ); + $this->assertFalse( Optional_Modules::is_optional_module_active( InDesign_Exporter::MODULE_NAME ) ); + } + + /** + * Test that a non-boolean module_enabled_print value is rejected. + */ + public function test_api_update_print_settings_rejects_non_boolean_module() { + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', 'yes' ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_param', $result->get_error_code() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + } + + /** + * Test that a valid platform value is persisted. + */ + public function test_api_update_print_settings_persists_platform() { + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', true ); + $request->set_param( 'indesign_platform', 'mac' ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertSame( 'mac', $result['indesign_platform'] ); + $this->assertSame( 'mac', get_option( InDesign_Exporter::PLATFORM_OPTION ) ); + } + + /** + * Test that an invalid platform value is rejected. + */ + public function test_api_update_print_settings_rejects_invalid_platform() { + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', true ); + $request->set_param( 'indesign_platform', 'linux' ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_param', $result->get_error_code() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + } + + /** + * Test that an invalid platform value does NOT trigger a module toggle. + * + * Regression test for an earlier bug where the module was activated + * before parameter validation finished. + */ + public function test_api_update_print_settings_validates_before_module_toggle() { + $this->assertFalse( Optional_Modules::is_optional_module_active( InDesign_Exporter::MODULE_NAME ) ); + + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', true ); + $request->set_param( 'indesign_platform', 'linux' ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertFalse( Optional_Modules::is_optional_module_active( InDesign_Exporter::MODULE_NAME ) ); + } + + /** + * Test that a valid post types array is persisted. + */ + public function test_api_update_print_settings_persists_post_types() { + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', true ); + $request->set_param( 'indesign_post_types', [ 'post', 'page' ] ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertSame( [ 'post', 'page' ], $result['indesign_post_types'] ); + $this->assertSame( [ 'post', 'page' ], get_option( InDesign_Exporter::POST_TYPES_OPTION ) ); + } + + /** + * Test that a non-array post types value is rejected. + */ + public function test_api_update_print_settings_rejects_non_array_post_types() { + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', true ); + $request->set_param( 'indesign_post_types', 'post' ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_param', $result->get_error_code() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + } + + /** + * Test that unregistered or non-string post type entries are stripped before saving. + */ + public function test_api_update_print_settings_filters_invalid_post_types() { + $request = new WP_REST_Request(); + $request->set_param( 'module_enabled_print', true ); + $request->set_param( 'indesign_post_types', [ 'post', 'no_such_cpt', 42, '', 'post' ] ); + + $result = $this->section->api_update_print_settings( $request ); + + $this->assertSame( [ 'post' ], $result['indesign_post_types'] ); + $this->assertSame( [ 'post' ], get_option( InDesign_Exporter::POST_TYPES_OPTION ) ); + } +} diff --git a/tests/unit-tests/indesign-exporter/indesign-exporter.php b/tests/unit-tests/indesign-exporter/indesign-exporter.php index b91502d9e3..394fafbb4f 100644 --- a/tests/unit-tests/indesign-exporter/indesign-exporter.php +++ b/tests/unit-tests/indesign-exporter/indesign-exporter.php @@ -6,6 +6,7 @@ */ use Newspack\Optional_Modules\InDesign_Export\InDesign_Converter; +use Newspack\Optional_Modules\InDesign_Exporter; /** * Tests the InDesign Exporter functionality. @@ -29,6 +30,288 @@ public function test_convert_simple_post() { $this->assertStringContainsString( 'This is a test post.', $content ); } + /** + * Test that the Mac platform option emits the header. + * + * InDesign on macOS requires the file to begin with for the + * tagged text to be interpreted as markup rather than literal content. + */ + public function test_convert_post_mac_platform() { + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Test Post', + 'post_content' => '

This is a test post.

', + ] + ); + + $converter = new InDesign_Converter(); + $content = $converter->convert_post( $post_id, [ 'platform' => 'mac' ] ); + $this->assertStringContainsString( '', $content ); + $this->assertStringNotContainsString( '', $content ); + } + + /** + * Test that the Win platform option emits the header. + */ + public function test_convert_post_win_platform() { + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Test Post', + 'post_content' => '

This is a test post.

', + ] + ); + + $converter = new InDesign_Converter(); + $content = $converter->convert_post( $post_id, [ 'platform' => 'win' ] ); + $this->assertStringContainsString( '', $content ); + $this->assertStringNotContainsString( '', $content ); + } + + /** + * Test that the platform setting defaults to 'auto' when unset. + */ + public function test_platform_setting_default() { + delete_option( InDesign_Exporter::PLATFORM_OPTION ); + $this->assertSame( 'auto', InDesign_Exporter::get_platform_setting() ); + } + + /** + * Test that the platform setting returns the stored value when valid. + */ + public function test_platform_setting_valid_values() { + update_option( InDesign_Exporter::PLATFORM_OPTION, 'mac' ); + $this->assertSame( 'mac', InDesign_Exporter::get_platform_setting() ); + + update_option( InDesign_Exporter::PLATFORM_OPTION, 'win' ); + $this->assertSame( 'win', InDesign_Exporter::get_platform_setting() ); + + update_option( InDesign_Exporter::PLATFORM_OPTION, 'auto' ); + $this->assertSame( 'auto', InDesign_Exporter::get_platform_setting() ); + + delete_option( InDesign_Exporter::PLATFORM_OPTION ); + } + + /** + * Test that the platform setting sanitizes invalid stored values. + */ + public function test_platform_setting_rejects_invalid_value() { + update_option( InDesign_Exporter::PLATFORM_OPTION, 'linux' ); + $this->assertSame( 'auto', InDesign_Exporter::get_platform_setting() ); + + update_option( InDesign_Exporter::PLATFORM_OPTION, '' ); + $this->assertSame( 'auto', InDesign_Exporter::get_platform_setting() ); + + delete_option( InDesign_Exporter::PLATFORM_OPTION ); + } + + /** + * Test that the post types setting defaults to ['post'] when unset. + */ + public function test_post_types_setting_default() { + delete_option( InDesign_Exporter::POST_TYPES_OPTION ); + $this->assertSame( [ 'post' ], InDesign_Exporter::get_post_types_setting() ); + } + + /** + * Test that valid stored post types are returned. + */ + public function test_post_types_setting_valid_values() { + update_option( InDesign_Exporter::POST_TYPES_OPTION, [ 'post', 'page' ] ); + $this->assertSame( [ 'post', 'page' ], InDesign_Exporter::get_post_types_setting() ); + + delete_option( InDesign_Exporter::POST_TYPES_OPTION ); + } + + /** + * Test that slugs whose post type is no longer registered get filtered out. + */ + public function test_post_types_setting_drops_stale_slugs() { + update_option( InDesign_Exporter::POST_TYPES_OPTION, [ 'post', 'no_such_cpt', 42, '' ] ); + $this->assertSame( [ 'post' ], InDesign_Exporter::get_post_types_setting() ); + + delete_option( InDesign_Exporter::POST_TYPES_OPTION ); + } + + /** + * Test that a non-array stored value falls back to the default. + */ + public function test_post_types_setting_rejects_non_array() { + update_option( InDesign_Exporter::POST_TYPES_OPTION, 'post' ); + $this->assertSame( [ 'post' ], InDesign_Exporter::get_post_types_setting() ); + + delete_option( InDesign_Exporter::POST_TYPES_OPTION ); + } + + /** + * Test that get_supported_post_types() honors the stored setting. + */ + public function test_get_supported_post_types_uses_setting() { + update_option( InDesign_Exporter::POST_TYPES_OPTION, [ 'page' ] ); + $this->assertSame( [ 'page' ], InDesign_Exporter::get_supported_post_types() ); + + delete_option( InDesign_Exporter::POST_TYPES_OPTION ); + } + + /** + * Test that available_post_types excludes attachments, RSS feeds, + * subscription lists, collections, and WooCommerce products. + */ + public function test_get_available_post_types_excludes_non_editorial_types() { + register_post_type( + 'partner_rss_feed', + [ + 'public' => true, + 'show_ui' => true, + ] + ); + register_post_type( + 'newspack_nl_list', + [ + 'public' => true, + 'show_ui' => true, + ] + ); + register_post_type( + 'newspack_collection', + [ + 'public' => true, + 'show_ui' => true, + ] + ); + register_post_type( + 'product', + [ + 'public' => true, + 'show_ui' => true, + ] + ); + register_post_type( + 'event', + [ + 'public' => true, + 'show_ui' => true, + ] + ); + + $available = InDesign_Exporter::get_available_post_types(); + $slugs = array_column( $available, 'value' ); + + $this->assertContains( 'post', $slugs ); + $this->assertContains( 'page', $slugs ); + $this->assertContains( 'event', $slugs, 'Editorial CPTs should remain available.' ); + $this->assertNotContains( 'attachment', $slugs ); + $this->assertNotContains( 'partner_rss_feed', $slugs ); + $this->assertNotContains( 'newspack_nl_list', $slugs ); + $this->assertNotContains( 'newspack_collection', $slugs ); + $this->assertNotContains( 'product', $slugs ); + + unregister_post_type( 'partner_rss_feed' ); + unregister_post_type( 'newspack_nl_list' ); + unregister_post_type( 'newspack_collection' ); + unregister_post_type( 'product' ); + unregister_post_type( 'event' ); + } + + /** + * Test that the excluded-types filter can add or remove exclusions. + */ + public function test_get_available_post_types_filter() { + register_post_type( + 'flyer', + [ + 'public' => true, + 'show_ui' => true, + ] + ); + + $callback = static function ( $excluded ) { + $excluded[] = 'flyer'; + return $excluded; + }; + add_filter( 'newspack_indesign_export_excluded_post_types', $callback ); + + $available = InDesign_Exporter::get_available_post_types(); + $slugs = array_column( $available, 'value' ); + + $this->assertNotContains( 'flyer', $slugs ); + + remove_filter( 'newspack_indesign_export_excluded_post_types', $callback ); + unregister_post_type( 'flyer' ); + } + + /** + * Test User-Agent → platform mapping for representative strings. + */ + public function test_sniff_user_agent_platform() { + // macOS Safari / Chrome. + $this->assertSame( + 'mac', + InDesign_Exporter::sniff_user_agent_platform( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' ) + ); + // iPad. + $this->assertSame( + 'mac', + InDesign_Exporter::sniff_user_agent_platform( 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15' ) + ); + // iPhone. + $this->assertSame( + 'mac', + InDesign_Exporter::sniff_user_agent_platform( 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15' ) + ); + // Windows Chrome. + $this->assertSame( + 'win', + InDesign_Exporter::sniff_user_agent_platform( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ) + ); + // Linux (treated as Windows-compatible by InDesign Tagged Text — there is no Linux variant). + $this->assertSame( + 'win', + InDesign_Exporter::sniff_user_agent_platform( 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' ) + ); + // Empty. + $this->assertSame( 'win', InDesign_Exporter::sniff_user_agent_platform( '' ) ); + } + + /** + * Test that is_post_supported gates posts by the configured post types setting. + */ + public function test_is_post_supported() { + update_option( InDesign_Exporter::POST_TYPES_OPTION, [ 'post' ] ); + + $post_id = $this->factory->post->create(); + $page_id = $this->factory->post->create( [ 'post_type' => 'page' ] ); + + $this->assertTrue( InDesign_Exporter::is_post_supported( $post_id ) ); + $this->assertFalse( InDesign_Exporter::is_post_supported( $page_id ) ); + $this->assertFalse( InDesign_Exporter::is_post_supported( 0 ) ); + $this->assertFalse( InDesign_Exporter::is_post_supported( 99999999 ) ); + + update_option( InDesign_Exporter::POST_TYPES_OPTION, [ 'post', 'page' ] ); + $this->assertTrue( InDesign_Exporter::is_post_supported( $page_id ) ); + + delete_option( InDesign_Exporter::POST_TYPES_OPTION ); + } + + /** + * Test that en-dashes and em-dashes map to their own Unicode code points. + * + * Previously '–' (en-dash, U+2013) was incorrectly mapped to <0x2014> (em-dash). + */ + public function test_convert_dashes() { + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Test Post', + 'post_content' => '

en–dash and em—dash and double--hyphen.

', + ] + ); + + $converter = new InDesign_Converter(); + $content = $converter->convert_post( $post_id ); + $this->assertStringContainsString( 'en<0x2013>dash', $content ); + $this->assertStringContainsString( 'em<0x2014>dash', $content ); + $this->assertStringContainsString( 'double<0x2014>hyphen', $content ); + } + /** * Test converting pullquotes. */