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 @@
);
?>
-
+
+
+
+
+
+
+
+
-
+
+
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 @@
?>
-
+
From e6ada3feb9d4e279663acdec826c0a13707ca23f Mon Sep 17 00:00:00 2001
From: Alex Kirk
Date: Tue, 15 Sep 2026 14:26:46 +0200
Subject: [PATCH 3/5] Show selected count in plain list
---
plain-list.js | 25 +++++++++++++++
templates/plain-list.php | 56 +++++++++++++++++++++++++++------
tests/Test_Send_To_E_Reader.php | 1 +
3 files changed, 73 insertions(+), 9 deletions(-)
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/plain-list.php b/templates/plain-list.php
index a1c0f49..67b779a 100644
--- a/templates/plain-list.php
+++ b/templates/plain-list.php
@@ -24,6 +24,9 @@
return get_the_time( $date_format, $post );
};
+$selected_count = count( $args['unsent'] );
+$total_count = count( $args['posts'] );
+
wp_enqueue_script(
'send-to-e-reader-plain-list',
plugins_url( 'plain-list.js', dirname( __DIR__ ) . '/send-to-e-reader.php' ),
@@ -55,7 +58,7 @@
}
header {
- align-items: flex-start;
+ align-items: center;
border-bottom: 1px solid #d8d2c6;
display: flex;
gap: 16px;
@@ -70,6 +73,10 @@
margin: 0;
}
+ .header-title {
+ min-width: 0;
+ }
+
button,
.list-actions a,
.item-actions a {
@@ -97,6 +104,11 @@
.header-controls {
align-items: center;
+ flex: 0 1 360px;
+ }
+
+ .header-download {
+ flex: 0 0 auto;
}
.post-list {
@@ -125,6 +137,7 @@
}
.post-meta,
+ .selection-count,
.post-summary,
.item-actions {
color: #5d646d;
@@ -157,13 +170,27 @@
}
header {
- display: block;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .header-title {
+ flex: 1 1 160px;
+ }
+
+ .header-controls {
+ display: contents;
+ }
+
+ .header-download {
+ order: 1;
}
- .header-controls,
.list-actions {
+ flex: 1 0 100%;
justify-content: flex-start;
margin-top: 12px;
+ order: 2;
}
}
@@ -173,14 +200,25 @@