Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 226 additions & 3 deletions includes/optional-modules/class-indesign-exporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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' ] );
Comment thread
miguelpeixe marked this conversation as resolved.
add_action( 'admin_notices', [ __CLASS__, 'admin_notices' ] );
}
Expand All @@ -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.
Expand All @@ -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<int, array{value:string, label:string}> 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.
*/
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -200,17 +335,45 @@ 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.
*
* @param array $post_ids Array of post IDs to export.
*/
private static function export_posts( $post_ids ) {
$converter = new InDesign_Converter();
$platform = self::resolve_platform();
$exported_files = [];

foreach ( $post_ids as $post_id ) {
Expand All @@ -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,
Expand All @@ -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 );
}
Comment thread
miguelpeixe marked this conversation as resolved.

/**
* 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.
*
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ASCII-WIN>, 'mac' emits <ASCII-MAC>.
* 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 = [] ) {
Expand All @@ -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[] = '<ASCII-WIN>';
$content_parts[] = 'mac' === $options['platform'] ? '<ASCII-MAC>' : '<ASCII-WIN>';
$content_parts[] = $this->styles['headline'] . $this->get_transformed_text( $post->post_title );

if ( $options['include_subtitle'] ) {
Expand Down Expand Up @@ -431,7 +441,7 @@ private function get_transformed_text( $text ) {
// Dashes.
'--' => '<0x2014>',
'—' => '<0x2014>',
'–' => '<0x2014>',
'–' => '<0x2013>',

// Quotes.
'“' => '"',
Expand Down
Loading
Loading