From f479cc93a7f6c0fcf5eed5e8138da4dd9289e2a5 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Tue, 15 Sep 2026 13:53:41 +0200 Subject: [PATCH 1/5] Improve e-reader plain list --- includes/class-e-reader.php | 8 +- includes/class-epub-builder.php | 14 +- .../class-post-collection-integration.php | 1 + includes/class-send-to-e-reader.php | 19 +- templates/plain-list.php | 192 +++++++++++++++++- tests/Test_Send_To_E_Reader.php | 55 +++++ tests/bootstrap.php | 39 ++++ 7 files changed, 309 insertions(+), 19 deletions(-) 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..9d8e62c 100644 --- a/includes/class-post-collection-integration.php +++ b/includes/class-post-collection-integration.php @@ -306,6 +306,7 @@ 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 diff --git a/includes/class-send-to-e-reader.php b/includes/class-send-to-e-reader.php index ec8886b..5509713 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. * @@ -1122,6 +1132,7 @@ public function enable_download_via_url( $viewable ) { return $viewable; } + self::prevent_response_caching(); $this->download_request = $request_value; return true; } diff --git a/templates/plain-list.php b/templates/plain-list.php index 6711733..89cdc7c 100644 --- a/templates/plain-list.php +++ b/templates/plain-list.php @@ -7,6 +7,23 @@ 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 ); +}; + wp_enqueue_script( 'send-to-e-reader-plain-list', plugins_url( 'plain-list.js', dirname( __DIR__ ) . '/send-to-e-reader.php' ), @@ -16,23 +33,180 @@ ); ?> -<?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_Send_To_E_Reader.php b/tests/Test_Send_To_E_Reader.php index 3ef92ad..ec8d505 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,57 @@ 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 ); + } + + /** + * 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 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; } From bf717e1bdd46c2b5861fe94b1fb90cffffbf402e Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Tue, 15 Sep 2026 14:20:54 +0200 Subject: [PATCH 2/5] Add header download button to plain list --- templates/plain-list.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/templates/plain-list.php b/templates/plain-list.php index 89cdc7c..a1c0f49 100644 --- a/templates/plain-list.php +++ b/templates/plain-list.php @@ -87,6 +87,7 @@ padding: 8px 14px; } + .header-controls, .list-actions { display: flex; flex-wrap: wrap; @@ -94,6 +95,10 @@ justify-content: flex-end; } + .header-controls { + align-items: center; + } + .post-list { display: grid; gap: 12px; @@ -155,6 +160,7 @@ display: block; } + .header-controls, .list-actions { justify-content: flex-start; margin-top: 12px; @@ -179,11 +185,14 @@ ?>

- +
+ + +