Skip to content
This repository was archived by the owner on Feb 18, 2026. It is now read-only.

Commit 015fed4

Browse files
Generate feeds in temporary folders (#34)
* Start generating the file in the tmp dir * Fix CR nitpicks * Fix FilesystemUtil case * Only move the file when a URL is requested * Test adjustments * Use copy instead of rename, include the error in the log * Use the last error properly * Track tmp paths internally
1 parent d7b5fe9 commit 015fed4

3 files changed

Lines changed: 135 additions & 58 deletions

File tree

src/Feed/FeedInterface.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@ public function end(): void;
3636
/**
3737
* Get the file path of the feed.
3838
*
39-
* @return string
39+
* @return string|null The path to the feed file, null if not ready.
4040
*/
41-
public function get_file_path(): string;
41+
public function get_file_path(): ?string;
4242

4343
/**
4444
* Get the URL of the feed file.
4545
*
46-
* @return string|null The URL of the feed file, null if not completed.
46+
* @return string|null The URL of the feed file, null if not ready.
4747
*/
4848
public function get_file_url(): ?string;
4949
}

src/Storage/JsonFileFeed.php

Lines changed: 122 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
* This class writes JSON directly to a file, entry by entry, without keeping everything in memory.
2323
*/
2424
class JsonFileFeed implements FeedInterface {
25+
public const UPLOAD_DIR = 'product-feeds';
26+
2527
/**
2628
* Indicates if there are previous entries in the feed.
2729
*
@@ -30,25 +32,32 @@ class JsonFileFeed implements FeedInterface {
3032
private $has_entries = false;
3133

3234
/**
33-
* The path to the feed file.
35+
* The base name of the feed file.
3436
*
3537
* @var string
3638
*/
37-
private $file_path;
39+
private $base_name;
3840

3941
/**
40-
* The file handle.
42+
* The name of the feed file, no directory.
4143
*
42-
* @var resource|null
44+
* @var string
4345
*/
44-
private $file_handle = null;
46+
private $file_name;
4547

4648
/**
47-
* The base name of the feed file.
49+
* The path to the feed file.
4850
*
4951
* @var string
5052
*/
51-
private $base_name;
53+
private $file_path;
54+
55+
/**
56+
* The file handle.
57+
*
58+
* @var resource|null
59+
*/
60+
private $file_handle = null;
5261

5362
/**
5463
* Indicates if the feed file has been completed.
@@ -64,6 +73,13 @@ class JsonFileFeed implements FeedInterface {
6473
*/
6574
private $file_url = null;
6675

76+
/**
77+
* Indicates if the feed file is in a temp directory.
78+
*
79+
* @var bool
80+
*/
81+
private $is_temp_filepath = false;
82+
6783
/**
6884
* Constructor.
6985
*
@@ -80,27 +96,6 @@ public function __construct( string $base_name ) {
8096
* @throws Exception If the feed directory cannot be created.
8197
*/
8298
public function start(): void {
83-
$upload_dir = wp_upload_dir( null, true );
84-
$directory = $upload_dir['basedir'] . DIRECTORY_SEPARATOR . 'product-feeds' . DIRECTORY_SEPARATOR;
85-
86-
// Try to create the directory if it does not exist.
87-
if ( ! is_dir( $directory ) ) {
88-
FileSystemUtil::mkdir_p_not_indexable( $directory );
89-
}
90-
91-
// `mkdir_p_not_indexable()` returns `void`, so we need to check again.
92-
if ( ! is_dir( $directory ) ) {
93-
throw new Exception(
94-
esc_html(
95-
sprintf(
96-
/* translators: %s: directory path */
97-
__( 'Unable to create feed directory: %s', 'woocommerce-product-feed-openai' ),
98-
$directory
99-
)
100-
)
101-
);
102-
}
103-
10499
/**
105100
* Allows the current time to be overridden before a feed is stored.
106101
*
@@ -109,19 +104,26 @@ public function start(): void {
109104
* @return int The current time.
110105
* @since 0.1.0
111106
*/
112-
$current_time = apply_filters( 'wpfoai_feed_time', time(), $this );
113-
$hash_data = $this->base_name . gmdate( 'r', $current_time );
114-
$file_name = sprintf(
107+
$current_time = apply_filters( 'wpfoai_feed_time', time(), $this );
108+
$hash_data = $this->base_name . gmdate( 'r', $current_time );
109+
$this->file_name = sprintf(
115110
'%s-%s-%s.json',
116111
$this->base_name,
117112
gmdate( 'Y-m-d', $current_time ),
118113
wp_hash( $hash_data )
119114
);
120115

121-
$this->file_path = $directory . $file_name;
122-
$this->file_url = $upload_dir['baseurl'] . '/product-feeds/' . $file_name;
123-
116+
// Start by trying to use a temp directory to generate the feed.
117+
$this->file_path = get_temp_dir() . DIRECTORY_SEPARATOR . $this->file_name;
124118
$this->file_handle = fopen( $this->file_path, 'w' );
119+
if ( false === $this->file_handle ) {
120+
// Fall back to immediately using the upload directory for generation.
121+
$upload_dir = $this->get_upload_dir();
122+
$this->file_path = $upload_dir['path'] . $this->file_name;
123+
$this->file_handle = fopen( $this->file_path, 'w' );
124+
} else {
125+
$this->is_temp_filepath = true;
126+
}
125127

126128
if ( false === $this->file_handle ) {
127129
throw new Exception(
@@ -170,24 +172,104 @@ public function end(): void {
170172
}
171173

172174
/**
173-
* Get the path to the feed file.
174-
*
175-
* @return string The path to the feed file.
175+
* {@inheritDoc}
176176
*/
177-
public function get_file_path(): string {
177+
public function get_file_path(): ?string {
178+
if ( ! $this->file_completed ) {
179+
return null;
180+
}
181+
178182
return $this->file_path;
179183
}
180184

181185
/**
182-
* Get the URL of the feed file.
186+
* {@inheritDoc}
183187
*
184-
* @return string|null The URL of the feed file, null if not completed.
188+
* @throws Exception If the feed file cannot be moved to the upload directory.
185189
*/
186190
public function get_file_url(): ?string {
187191
if ( ! $this->file_completed ) {
188192
return null;
189193
}
190194

195+
$upload_dir = $this->get_upload_dir();
196+
197+
// Move the file to the upload directory if it is in temp.
198+
if ( $this->is_temp_filepath ) {
199+
$tmp_path = $this->file_path;
200+
$this->file_path = $upload_dir['path'] . $this->file_name;
201+
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
202+
if ( ! @copy( $tmp_path, $this->file_path ) ) {
203+
$error = error_get_last();
204+
throw new Exception(
205+
esc_html(
206+
sprintf(
207+
/* translators: %1$s: file path, %2$s: error message */
208+
__( 'Unable to move feed file %1$s to upload directory: %2$s', 'woocommerce-product-feed-openai' ),
209+
$this->file_path,
210+
( esc_html( $error['message'] ) ?? 'Unknown error' )
211+
)
212+
)
213+
);
214+
}
215+
216+
unlink( $tmp_path );
217+
218+
$this->is_temp_filepath = false;
219+
}
220+
221+
// Generate the URL.
222+
$this->file_url = $upload_dir['url'] . $this->file_name;
223+
191224
return $this->file_url;
192225
}
226+
227+
/**
228+
* Get the upload directory for the feed.
229+
*
230+
* @return array {
231+
* The upload directory for the feed. Both fields end with the right trailing slash.
232+
*
233+
* @type string $path The path to the upload directory.
234+
* @type string $url The URL to the upload directory.
235+
* }
236+
* @throws Exception If the upload directory cannot be created.
237+
*/
238+
private function get_upload_dir(): array {
239+
// Only generate everything once.
240+
static $prepared;
241+
if ( isset( $prepared ) ) {
242+
return $prepared;
243+
}
244+
245+
$upload_dir = wp_upload_dir( null, true );
246+
$directory_path = $upload_dir['basedir'] . DIRECTORY_SEPARATOR . self::UPLOAD_DIR . DIRECTORY_SEPARATOR;
247+
248+
// Try to create the directory if it does not exist.
249+
if ( ! is_dir( $directory_path ) ) {
250+
FilesystemUtil::mkdir_p_not_indexable( $directory_path );
251+
}
252+
253+
// `mkdir_p_not_indexable()` returns `void`, we have to check again.
254+
if ( ! is_dir( $directory_path ) ) {
255+
throw new Exception(
256+
esc_html(
257+
sprintf(
258+
/* translators: %s: directory path */
259+
__( 'Unable to create feed directory: %s', 'woocommerce-product-feed-openai' ),
260+
$directory_path
261+
)
262+
)
263+
);
264+
}
265+
266+
$directory_url = $upload_dir['baseurl'] . '/' . self::UPLOAD_DIR . '/';
267+
268+
// Follow the format, returned by `wp_upload_dir()`.
269+
$prepared = [
270+
'path' => $directory_path,
271+
'url' => $directory_url,
272+
];
273+
return $prepared;
274+
}
193275
}

tests/unit/ProductFeed/Storage/JsonFileFeedTest.php

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,23 @@ public function test_feed_file_is_created() {
2727
$current_time = time();
2828
add_filter( 'wpfoai_feed_time', fn() => $current_time );
2929

30-
// Make sure there is no directory and that it will be created.
31-
$directory = $this->get_and_delete_dir();
32-
3330
$feed = new JsonFileFeed( 'test-feed' );
3431
$feed->start();
3532
$feed->end();
3633

34+
// The file should be in `/tmp` at first.
3735
$path = $feed->get_file_path();
38-
$this->assertStringContainsString( 'product-feeds', $path );
39-
$this->assertStringContainsString( $directory, $path );
36+
$this->assertStringStartsWith( get_temp_dir(), $path );
4037
$this->assertStringContainsString( gmdate( 'Y-m-d', $current_time ), $path );
4138
$this->assertStringContainsString( wp_hash( 'test-feed' . gmdate( 'r', $current_time ) ), $path );
4239
$this->assertTrue( file_exists( $path ) );
4340
$this->assertEquals( '[]', file_get_contents( $path ) );
4441

45-
$url = $feed->get_file_url();
42+
// Once a URL is retrieved, the file will be moved to the uploads dir.
43+
$url = $feed->get_file_url();
44+
$path2 = $feed->get_file_path();
4645
$this->assertNotNull( $url );
46+
$this->assertStringContainsString( 'uploads/product-feed', $path2 );
4747
$this->assertStringEndsWith( '.json', (string) $url );
4848
$this->assertStringContainsString( '/product-feeds/', (string) $url );
4949
}
@@ -80,13 +80,6 @@ public function test_get_file_url_returns_null_if_not_completed() {
8080
$feed->end();
8181
}
8282

83-
public function test_get_file_path_before_start_throws_type_error() {
84-
$feed = new JsonFileFeed( 'test-feed' );
85-
$this->expectException( \TypeError::class );
86-
// Property is unset until start(); return type is string → TypeError.
87-
$feed->get_file_path();
88-
}
89-
9083
public function test_add_entry_before_start_throws_type_error() {
9184
$feed = new JsonFileFeed( 'test-feed' );
9285
$this->expectException( \TypeError::class );
@@ -99,7 +92,7 @@ public function test_end_before_start_throws_type_error() {
9992
$feed->end();
10093
}
10194

102-
public function test_start_throws_when_directory_cannot_be_created() {
95+
public function test_get_file_url_throws_when_directory_cannot_be_created() {
10396
// Ensure clean state then create a FILE where the directory should be.
10497
$this->get_and_delete_dir();
10598
$uploads_dir = wp_upload_dir()['basedir'];
@@ -110,8 +103,10 @@ public function test_start_throws_when_directory_cannot_be_created() {
110103

111104
try {
112105
$feed = new JsonFileFeed( 'test-feed' );
113-
$this->expectException( \Exception::class );
114106
$feed->start();
107+
$feed->end();
108+
$this->expectException( \Exception::class );
109+
$feed->get_file_url();
115110
} finally {
116111
// Cleanup: remove blocking file.
117112
if ( file_exists( $block_path ) && is_file( $block_path ) ) {

0 commit comments

Comments
 (0)