diff --git a/includes/class-e-reader.php b/includes/class-e-reader.php index 5378ac8..c4829aa 100644 --- a/includes/class-e-reader.php +++ b/includes/class-e-reader.php @@ -231,7 +231,8 @@ protected function generate_file( array $posts, $title = null, $author = null ) $this->ebook_title = $this->strip_emojis( $this->ebook_title ); $this->ebook_author = $this->strip_emojis( $this->ebook_author ); - $url = home_url( '?' . implode( '-', array_map( 'intval', array_column( $posts, 'ID' ) ) ) ); + $url = home_url( '?' . implode( '-', array_map( 'intval', array_column( $posts, 'ID' ) ) ) ); + $generation_id = str_replace( '.', '-', uniqid( 'generated-', true ) ); foreach ( $posts as $post ) { $post_title = $post->post_title; @@ -253,8 +254,9 @@ protected function generate_file( array $posts, $title = null, $author = null ) $this->ebook_author, $chapters, array( - 'identifier' => $url, - 'source_url' => $url, + 'filename_suffix' => $generation_id, + 'identifier' => $url . '#' . $generation_id, + 'source_url' => $url, ) ); } diff --git a/includes/class-epub-builder.php b/includes/class-epub-builder.php index 36081a0..342434f 100644 --- a/includes/class-epub-builder.php +++ b/includes/class-epub-builder.php @@ -48,7 +48,7 @@ public static function build_file( $title, $author, array $chapters, array $args } $args['base_dir'] = $dir; - $filename = self::build_book_filename( $title, $author ); + $filename = self::build_book_filename( $title, $author, isset( $args['filename_suffix'] ) ? $args['filename_suffix'] : '' ); $book = self::build_book( $title, $author, $chapters, $args ); if ( false === $book->saveBook( $filename . '.epub', $dir ) ) { @@ -205,12 +205,20 @@ private static function build_book( $title, $author, array $chapters, array $arg * * @param string $title The book title. * @param string $author The book author. + * @param string $suffix Optional filename suffix. * @return string */ - private static function build_book_filename( $title, $author ) { + private static function build_book_filename( $title, $author, $suffix = '' ) { $filename = sanitize_title( substr( (string) $author, 0, 40 ) . ' - ' . substr( (string) $title, 0, 100 ) ); + $suffix = sanitize_title( (string) $suffix ); + if ( ! $filename ) { + $filename = 'ebook'; + } + if ( $suffix ) { + $filename .= '-' . $suffix; + } - return $filename ? $filename : 'ebook'; + return $filename; } /** diff --git a/includes/class-post-collection-integration.php b/includes/class-post-collection-integration.php index 3392e16..d8251f4 100644 --- a/includes/class-post-collection-integration.php +++ b/includes/class-post-collection-integration.php @@ -40,7 +40,7 @@ class Post_Collection_Integration { * * @var array */ - private static $selections = array( 'unread', 'new', 'all', 'last', 'list' ); + private static $selections = array( 'unread', 'new', 'all', 'last', 'list', 'compact' ); /** * Constructor. @@ -274,6 +274,7 @@ public function download_urls( $app, $collection ) { 'unread' => __( 'Unread articles', 'send-to-e-reader' ), 'unread-10' => __( '10 most recent unread articles', 'send-to-e-reader' ), 'new' => __( 'Articles not yet sent', 'send-to-e-reader' ), + 'compact' => __( 'Compact picker on the e-reader', 'send-to-e-reader' ), ); ?>
@@ -306,14 +307,15 @@ public function maybe_download( $app, $collection = null ) { return; } + Send_To_E_Reader::prevent_response_caching(); list( $selection, $limit ) = $request; // The download password stands in for being logged in here, the same way // it does on the Friends frontend: whoever knows it gets the private // posts too, because that is the point of pulling the unread articles // onto a device that cannot log in. - if ( 'list' === $selection ) { - $this->render_list( $app, $collection ); + if ( in_array( $selection, array( 'list', 'compact' ), true ) ) { + $this->render_list( $app, $collection, 'compact' === $selection ); exit; } @@ -343,8 +345,9 @@ public function maybe_download( $app, $collection = null ) { * * @param \PostCollection\Post_Collection_App $app The app instance. * @param \WP_Term|null $collection The collection in context. + * @param bool $compact Whether to render the compact list view. */ - private function render_list( $app, $collection ) { + private function render_list( $app, $collection, $compact = false ) { $posts = array(); $unsent = array(); foreach ( $this->get_posts( $app, 'last', $collection, true, 50 ) as $post ) { @@ -370,6 +373,7 @@ private function render_list( $app, $collection ) { 'unsent' => $unsent, 'posts' => $posts, 'inputname' => $this->send_to_e_reader->get_download_url_var(), + 'compact' => $compact, ) ); } @@ -610,6 +614,7 @@ public function settings_download_urls( $download_password ) { 'all' => __( 'All collected articles:', 'send-to-e-reader' ), 'last' => __( 'The last 10 collected articles:', 'send-to-e-reader' ), 'list' => __( 'A list to pick from:', 'send-to-e-reader' ), + 'compact' => __( 'A compact list to pick from:', 'send-to-e-reader' ), ); $base = wp_parse_url( $app->get_home_url(), PHP_URL_PATH ); diff --git a/includes/class-send-to-e-reader.php b/includes/class-send-to-e-reader.php index ec8886b..2d4f893 100644 --- a/includes/class-send-to-e-reader.php +++ b/includes/class-send-to-e-reader.php @@ -683,10 +683,7 @@ public function get_unsent_posts( $query_vars = array() ) { $query_vars = $wp_query->query_vars; } - // Prevent super cache from caching this page. - if ( ! defined( 'DONOTCACHEPAGE' ) ) { - define( 'DONOTCACHEPAGE', true ); - } + self::prevent_response_caching(); $query = new \WP_Query( array_merge( @@ -701,6 +698,19 @@ public function get_unsent_posts( $query_vars = array() ) { return $query->get_posts(); } + /** + * Prevent page-cache and browser-cache layers from storing dynamic e-reader responses. + */ + public static function prevent_response_caching() { + if ( ! defined( 'DONOTCACHEPAGE' ) ) { + define( 'DONOTCACHEPAGE', true ); + } + + if ( function_exists( 'nocache_headers' ) && ! headers_sent() ) { + nocache_headers(); + } + } + /** * Get the meta query that selects posts by their e-reader sent status. * @@ -1115,6 +1125,7 @@ public function enable_download_via_url( $viewable ) { 'all', 'last', 'list', + 'compact', ), true ) @@ -1122,6 +1133,7 @@ public function enable_download_via_url( $viewable ) { return $viewable; } + self::prevent_response_caching(); $this->download_request = $request_value; return true; } @@ -1146,7 +1158,7 @@ public function download_via_url( $template ) { return $template; } - if ( 'list' === $this->download_request ) { + if ( in_array( $this->download_request, array( 'list', 'compact' ), true ) ) { $unsent = array(); foreach ( $this->get_unsent_posts() as $post ) { if ( in_array( get_post_format( $post ), array( 'video' ), true ) ) { @@ -1185,6 +1197,7 @@ public function download_via_url( $template ) { 'unsent' => $unsent, 'posts' => $posts, 'inputname' => 'epub' . get_option( self::DOWNLOAD_PASSWORD_OPTION, hash( 'crc32', wp_salt( 'nonce' ), false ) ), + 'compact' => 'compact' === $this->download_request, ) ); exit; diff --git a/plain-list.js b/plain-list.js index 2ed6830..78d470e 100644 --- a/plain-list.js +++ b/plain-list.js @@ -5,6 +5,21 @@ document.querySelectorAll('input[type="checkbox"]').forEach(function (checkbox) { checkbox.checked = checked; }); + updateSelectionCount(); + } + + function updateSelectionCount() { + var count = document.querySelector('[data-send-to-e-reader-selection-count]'); + var checkboxes = document.querySelectorAll('input[type="checkbox"]'); + var selected = document.querySelectorAll('input[type="checkbox"]:checked').length; + + if (!count) { + return; + } + + count.textContent = count.getAttribute('data-selected-template') + .replace('%1$d', checkboxes.length) + .replace('%2$d', selected); } function reverseList() { @@ -63,4 +78,14 @@ break; } }); + + document.addEventListener('change', function (event) { + if (!event.target.matches('input[type="checkbox"]')) { + return; + } + + updateSelectionCount(); + }); + + updateSelectionCount(); }()); diff --git a/templates/admin/ereader-settings.php b/templates/admin/ereader-settings.php index 6d917f6..fb8c40b 100644 --- a/templates/admin/ereader-settings.php +++ b/templates/admin/ereader-settings.php @@ -27,10 +27,11 @@ __( 'All posts from this friend:', 'send-to-e-reader' ), - 'last' => __( 'The last 10 posts from this friend:', 'send-to-e-reader' ), - 'new' => __( 'Posts not yet sent from this friend:', 'send-to-e-reader' ), - 'list' => __( 'List last for manual selection from this friend:', 'send-to-e-reader' ), + 'all' => __( 'All posts from this friend:', 'send-to-e-reader' ), + 'last' => __( 'The last 10 posts from this friend:', 'send-to-e-reader' ), + 'new' => __( 'Posts not yet sent from this friend:', 'send-to-e-reader' ), + 'list' => __( 'List last for manual selection from this friend:', 'send-to-e-reader' ), + 'compact' => __( 'Compact list for manual selection from this friend:', 'send-to-e-reader' ), ) as $key => $description ) : ?> diff --git a/templates/plain-list.php b/templates/plain-list.php index 6711733..bc2cff6 100644 --- a/templates/plain-list.php +++ b/templates/plain-list.php @@ -7,6 +7,27 @@ defined( 'ABSPATH' ) || exit; +$date_format = get_option( 'date_format' ); +if ( ! $date_format ) { + $date_format = 'F j, Y'; +} + +$get_post_display_date = static function ( $post ) use ( $date_format ) { + $published_time = get_post_meta( $post->ID, 'published_time', true ); + if ( $published_time ) { + $timestamp = is_numeric( $published_time ) ? (int) $published_time : strtotime( (string) $published_time ); + if ( $timestamp ) { + return date_i18n( $date_format, $timestamp ); + } + } + + return get_the_time( $date_format, $post ); +}; + +$selected_count = count( $args['unsent'] ); +$total_count = count( $args['posts'] ); +$compact = ! empty( $args['compact'] ); + wp_enqueue_script( 'send-to-e-reader-plain-list', plugins_url( 'plain-list.js', dirname( __DIR__ ) . '/send-to-e-reader.php' ), @@ -16,23 +37,288 @@ ); ?> -<?php echo esc_html( $args['title'] ); ?> + + + + + +<?php echo esc_html( $args['title'] ); ?> + - + +
- Reverse | Select all | Select none - - -
    +
    +
    +

    +

    + > + +

    +
    +
    + + +
    +
    +
      -
    • ID ] ) ); ?> /> -
      post_content ) ) ); ?> -
      | Move up | Move down

      +
    • + ID ] ) ); ?> aria-label="" /> +
      +

      + +

      post_content ) ) ); ?>

      +
      + + + +
      +
    - + - +
+ diff --git a/tests/Test_Post_Collection_Integration.php b/tests/Test_Post_Collection_Integration.php index 04f3fab..fb06e62 100644 --- a/tests/Test_Post_Collection_Integration.php +++ b/tests/Test_Post_Collection_Integration.php @@ -228,7 +228,7 @@ public function test_the_download_url_only_accepts_known_selections() { $this->assertFalse( $this->call( $integration, 'get_download_request' ) ); - foreach ( array( 'unread', 'new', 'all', 'last', 'list' ) as $selection ) { + foreach ( array( 'unread', 'new', 'all', 'last', 'list', 'compact' ) as $selection ) { $_GET['epubsecret'] = $selection; $this->assertSame( array( $selection, null ), $this->call( $integration, 'get_download_request' ) ); } @@ -266,6 +266,7 @@ public function test_download_urls_are_exposed_on_the_collection_frontend() { $this->assertStringContainsString( 'E-reader URLs', $output ); $this->assertStringContainsString( 'URL settings', $output ); $this->assertStringContainsString( 'https://example.com/post-collection/collected-posts/?epubsecret=list', $output ); + $this->assertStringContainsString( 'https://example.com/post-collection/collected-posts/?epubsecret=compact', $output ); $this->assertStringContainsString( 'https://example.com/post-collection/collected-posts/?epubsecret=unread', $output ); $this->assertStringContainsString( 'https://example.com/wp-admin/admin.php?page=send-to-e-reader-settings', $output ); } diff --git a/tests/Test_Send_To_E_Reader.php b/tests/Test_Send_To_E_Reader.php index 3ef92ad..c601999 100644 --- a/tests/Test_Send_To_E_Reader.php +++ b/tests/Test_Send_To_E_Reader.php @@ -26,6 +26,10 @@ public function tearDown(): void { unset( $GLOBALS['send_to_e_reader_test_options'] ); unset( $GLOBALS['send_to_e_reader_test_caps'] ); unset( $GLOBALS['send_to_e_reader_test_posts'] ); + unset( $GLOBALS['send_to_e_reader_test_post_meta'] ); + unset( $GLOBALS['send_to_e_reader_test_scripts'] ); + unset( $GLOBALS['send_to_e_reader_test_nocache_headers_sent'] ); + unset( $_GET['epubsecret'] ); parent::tearDown(); } @@ -237,6 +241,70 @@ public function test_get_template_loader_returns_object() { $this->assertIsObject( $loader ); } + /** + * Test that the plain list shows the original article date when available. + */ + public function test_plain_list_shows_original_article_date() { + $post = new \WP_Post(); + $post->ID = 456; + $post->post_title = 'Collected Link'; + $post->post_content = '

A useful article saved for later.

'; + update_post_meta( $post->ID, 'published_time', '2026-06-29T16:44:13+00:00' ); + + $args = array( + 'title' => 'Reading List', + 'unsent' => array( $post->ID => $post ), + 'posts' => array( $post->ID => $post ), + 'inputname' => 'epubsecret', + ); + + ob_start(); + include FRIENDS_SEND_TO_E_READER_PLUGIN_DIR . 'templates/plain-list.php'; + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Collected Link', $output ); + $this->assertStringContainsString( 'June 29, 2026', $output ); + $this->assertStringContainsString( '1 articles (1 selected)', $output ); + } + + /** + * Test generated ePub filenames can carry a cache-busting suffix. + */ + public function test_epub_filename_accepts_generation_suffix() { + $method = new \ReflectionMethod( \Send_To_E_Reader\Epub_Builder::class, 'build_book_filename' ); + $method->setAccessible( true ); + + $this->assertSame( + 'test-author---reading-list-generated-abc', + $method->invoke( null, 'Reading List', 'Test Author', 'generated-abc' ) + ); + } + + /** + * Test download URLs opt out of page and browser caching. + */ + public function test_download_url_prevents_response_caching() { + update_option( Send_To_E_Reader::DOWNLOAD_PASSWORD_OPTION, 'secret' ); + $_GET['epubsecret'] = 'list'; + + $send_to_e_reader = new Send_To_E_Reader( null ); + + $this->assertTrue( $send_to_e_reader->enable_download_via_url( false ) ); + $this->assertTrue( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ); + } + + /** + * Test compact list download URLs are accepted. + */ + public function test_compact_list_download_url_is_accepted() { + update_option( Send_To_E_Reader::DOWNLOAD_PASSWORD_OPTION, 'secret' ); + $_GET['epubsecret'] = 'compact'; + + $send_to_e_reader = new Send_To_E_Reader( null ); + + $this->assertTrue( $send_to_e_reader->enable_download_via_url( false ) ); + } + /** * Test get_post_author_name returns author name for a post. */ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 73d10a0..5eef1c5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -269,6 +269,23 @@ function wp_kses_post( $data ) { return $data; } + function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) { + $GLOBALS['send_to_e_reader_test_scripts'][ $handle ] = compact( 'src', 'deps', 'ver', 'args' ); + return true; + } + + function wp_print_scripts( $handles = false ) { + return true; + } + + function checked( $checked, $current = true, $display = true ) { + $result = (string) $checked === (string) $current ? ' checked="checked"' : ''; + if ( $display ) { + echo $result; + } + return $result; + } + function add_action( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { return true; } @@ -390,6 +407,10 @@ function sanitize_file_name( $filename ) { } function get_bloginfo( $show = '', $filter = 'raw' ) { + if ( 'charset' === $show ) { + return 'UTF-8'; + } + return 'Test Site'; } @@ -489,6 +510,19 @@ function wp_strip_all_tags( $text, $remove_breaks = false ) { return trim( $text ); } + function wp_trim_words( $text, $num_words = 55, $more = null ) { + $words = preg_split( '/\s+/', trim( (string) $text ) ); + if ( count( $words ) <= $num_words ) { + return implode( ' ', $words ); + } + + if ( null === $more ) { + $more = '...'; + } + + return implode( ' ', array_slice( $words, 0, $num_words ) ) . $more; + } + function admin_url( $path = '', $scheme = 'admin' ) { return 'https://example.com/wp-admin/' . $path; } @@ -546,6 +580,11 @@ function date_i18n( $format, $timestamp = false, $gmt = false ) { return date( $format, $timestamp ? $timestamp : time() ); } + function nocache_headers() { + $GLOBALS['send_to_e_reader_test_nocache_headers_sent'] = true; + return true; + } + function wp_create_nonce( $action = -1 ) { return 'test-nonce-' . $action; }