|
1
|
|
|
<?php |
|
2
|
|
|
/** |
|
3
|
|
|
* A diaspora* flavoured WP Post class. |
|
4
|
|
|
* |
|
5
|
|
|
* @package WP_To_Diaspora\Post |
|
6
|
|
|
* @since 1.5.0 |
|
7
|
|
|
*/ |
|
8
|
|
|
|
|
9
|
|
|
// Exit if accessed directly. |
|
10
|
|
|
defined( 'ABSPATH' ) || exit; |
|
11
|
|
|
|
|
12
|
|
|
use League\HTMLToMarkdown\HtmlConverter; |
|
13
|
|
|
|
|
14
|
|
|
/** |
|
15
|
|
|
* Custom diaspora* post class to manage all post related things. |
|
16
|
|
|
* |
|
17
|
|
|
* @since 1.5.0 |
|
18
|
|
|
*/ |
|
19
|
|
|
class WP2D_Post { |
|
20
|
|
|
|
|
21
|
|
|
/** |
|
22
|
|
|
* The original post object. |
|
23
|
|
|
* |
|
24
|
|
|
* @since 1.5.0 |
|
25
|
|
|
* |
|
26
|
|
|
* @var WP_Post |
|
27
|
|
|
*/ |
|
28
|
|
|
public $post; |
|
29
|
|
|
|
|
30
|
|
|
/** |
|
31
|
|
|
* The original post ID. |
|
32
|
|
|
* |
|
33
|
|
|
* @since 1.5.0 |
|
34
|
|
|
* |
|
35
|
|
|
* @var int |
|
36
|
|
|
*/ |
|
37
|
|
|
public $ID; |
|
38
|
|
|
|
|
39
|
|
|
/** |
|
40
|
|
|
* If this post should be shared on diaspora*. |
|
41
|
|
|
* |
|
42
|
|
|
* @since 1.5.0 |
|
43
|
|
|
* |
|
44
|
|
|
* @var bool |
|
45
|
|
|
*/ |
|
46
|
|
|
public $post_to_diaspora; |
|
47
|
|
|
|
|
48
|
|
|
/** |
|
49
|
|
|
* If a link back to the original post should be added. |
|
50
|
|
|
* |
|
51
|
|
|
* @since 1.5.0 |
|
52
|
|
|
* |
|
53
|
|
|
* @var bool |
|
54
|
|
|
*/ |
|
55
|
|
|
public $fullentrylink; |
|
56
|
|
|
|
|
57
|
|
|
/** |
|
58
|
|
|
* What content gets posted. |
|
59
|
|
|
* |
|
60
|
|
|
* @since 1.5.0 |
|
61
|
|
|
* |
|
62
|
|
|
* @var string |
|
63
|
|
|
*/ |
|
64
|
|
|
public $display; |
|
65
|
|
|
|
|
66
|
|
|
/** |
|
67
|
|
|
* The types of tags to post. (global,custom,post) |
|
68
|
|
|
* |
|
69
|
|
|
* @since 1.5.0 |
|
70
|
|
|
* |
|
71
|
|
|
* @var array |
|
72
|
|
|
*/ |
|
73
|
|
|
public $tags_to_post; |
|
74
|
|
|
|
|
75
|
|
|
/** |
|
76
|
|
|
* The post's custom tags. |
|
77
|
|
|
* |
|
78
|
|
|
* @since 1.5.0 |
|
79
|
|
|
* |
|
80
|
|
|
* @var array |
|
81
|
|
|
*/ |
|
82
|
|
|
public $custom_tags; |
|
83
|
|
|
|
|
84
|
|
|
/** |
|
85
|
|
|
* Aspects this post gets posted to. |
|
86
|
|
|
* |
|
87
|
|
|
* @since 1.5.0 |
|
88
|
|
|
* |
|
89
|
|
|
* @var array |
|
90
|
|
|
*/ |
|
91
|
|
|
public $aspects; |
|
92
|
|
|
|
|
93
|
|
|
/** |
|
94
|
|
|
* Services this post gets posted to. |
|
95
|
|
|
* |
|
96
|
|
|
* @since 1.5.0 |
|
97
|
|
|
* |
|
98
|
|
|
* @var array |
|
99
|
|
|
*/ |
|
100
|
|
|
public $services; |
|
101
|
|
|
|
|
102
|
|
|
/** |
|
103
|
|
|
* The post's history of diaspora* posts. |
|
104
|
|
|
* |
|
105
|
|
|
* @since 1.5.0 |
|
106
|
|
|
* |
|
107
|
|
|
* @var array |
|
108
|
|
|
*/ |
|
109
|
|
|
public $post_history; |
|
110
|
|
|
|
|
111
|
|
|
/** |
|
112
|
|
|
* If the post actions have all been set up already. |
|
113
|
|
|
* |
|
114
|
|
|
* @since 1.5.0 |
|
115
|
|
|
* |
|
116
|
|
|
* @var boolean |
|
117
|
|
|
*/ |
|
118
|
|
|
private static $is_set_up = false; |
|
119
|
|
|
|
|
120
|
|
|
/** |
|
121
|
|
|
* Setup all the necessary WP callbacks. |
|
122
|
|
|
* |
|
123
|
|
|
* @since 1.5.0 |
|
124
|
|
|
*/ |
|
125
|
|
|
public static function setup() { |
|
126
|
|
|
if ( self::$is_set_up ) { |
|
127
|
|
|
return; |
|
128
|
|
|
} |
|
129
|
|
|
|
|
130
|
|
|
$instance = new WP2D_Post( null ); |
|
131
|
|
|
|
|
132
|
|
|
// Notices when a post has been shared or if it has failed. |
|
133
|
|
|
add_action( 'admin_notices', [ $instance, 'admin_notices' ] ); |
|
134
|
|
|
add_action( 'admin_init', [ $instance, 'ignore_post_error' ] ); |
|
135
|
|
|
|
|
136
|
|
|
// Handle diaspora* posting when saving the post. |
|
137
|
|
|
add_action( 'save_post', [ $instance, 'post' ], 20, 2 ); |
|
138
|
|
|
add_action( 'save_post', [ $instance, 'save_meta_box_data' ], 10 ); |
|
139
|
|
|
|
|
140
|
|
|
// Add meta boxes. |
|
141
|
|
|
add_action( 'add_meta_boxes', [ $instance, 'add_meta_boxes' ] ); |
|
142
|
|
|
|
|
143
|
|
|
// AJAX callback for diaspora* post history. |
|
144
|
|
|
add_action( 'wp_ajax_wp_to_diaspora_get_post_history', [ $instance, 'get_post_history_callback' ] ); |
|
145
|
|
|
|
|
146
|
|
|
self::$is_set_up = true; |
|
147
|
|
|
} |
|
148
|
|
|
|
|
149
|
|
|
/** |
|
150
|
|
|
* Constructor. |
|
151
|
|
|
* |
|
152
|
|
|
* @since 1.5.0 |
|
153
|
|
|
* |
|
154
|
|
|
* @param int|WP_Post $post Post ID or the post itself. |
|
155
|
|
|
*/ |
|
156
|
|
|
public function __construct( $post ) { |
|
157
|
|
|
$this->assign_wp_post( $post ); |
|
158
|
|
|
} |
|
159
|
|
|
|
|
160
|
|
|
/** |
|
161
|
|
|
* Assign the original WP_Post object and all the custom meta data. |
|
162
|
|
|
* |
|
163
|
|
|
* @since 1.5.0 |
|
164
|
|
|
* |
|
165
|
|
|
* @param int|WP_Post $post Post ID or the post itself. |
|
166
|
|
|
*/ |
|
167
|
|
|
private function assign_wp_post( $post ) { |
|
168
|
|
|
if ( $this->post = get_post( $post ) ) { |
|
169
|
|
|
$this->ID = $this->post->ID; |
|
170
|
|
|
|
|
171
|
|
|
$options = WP2D_Options::instance(); |
|
172
|
|
|
|
|
173
|
|
|
// Assign all meta values, expanding non-existent ones with the defaults. |
|
174
|
|
|
$meta_current = get_post_meta( $this->ID, '_wp_to_diaspora', true ); |
|
175
|
|
|
$meta = wp_parse_args( |
|
176
|
|
|
$meta_current, |
|
177
|
|
|
$options->get_options() |
|
178
|
|
|
); |
|
179
|
|
|
if ( $meta ) { |
|
180
|
|
|
foreach ( $meta as $key => $value ) { |
|
181
|
|
|
$this->$key = $value; |
|
182
|
|
|
} |
|
183
|
|
|
} |
|
184
|
|
|
|
|
185
|
|
|
// If no WP2D meta data has been saved yet, this post shouldn't be published. |
|
186
|
|
|
// This can happen if existing posts (before WP2D) get updated externally, not through the post edit screen. |
|
187
|
|
|
// Check DiasPHPora/wp-to-diaspora#91 for reference. |
|
188
|
|
|
// Also, when we have a post scheduled for publishing, don't touch it. |
|
189
|
|
|
// This is important when modifying scheduled posts using Quick Edit. |
|
190
|
|
|
if ( ! $meta_current && ! in_array( $this->post->post_status, [ 'auto-draft', 'future' ], true ) ) { |
|
191
|
|
|
$this->post_to_diaspora = false; |
|
192
|
|
|
} |
|
193
|
|
|
|
|
194
|
|
|
$this->post_history = get_post_meta( $this->ID, '_wp_to_diaspora_post_history', true ); |
|
195
|
|
|
} |
|
196
|
|
|
} |
|
197
|
|
|
|
|
198
|
|
|
/** |
|
199
|
|
|
* Post to diaspora* when saving a post. |
|
200
|
|
|
* |
|
201
|
|
|
* @since 1.5.0 |
|
202
|
|
|
* |
|
203
|
|
|
* @todo Maybe somebody wants to share a password protected post to a closed aspect. |
|
204
|
|
|
* |
|
205
|
|
|
* @param integer $post_id ID of the post being saved. |
|
206
|
|
|
* @param WP_Post $post Post object being saved. |
|
207
|
|
|
* |
|
208
|
|
|
* @return bool If the post was posted successfully. |
|
209
|
|
|
*/ |
|
210
|
|
|
public function post( $post_id, $post ) { |
|
211
|
|
|
// Ignore any revisions and auto-saves. |
|
212
|
|
|
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { |
|
213
|
|
|
return false; |
|
214
|
|
|
} |
|
215
|
|
|
|
|
216
|
|
|
$this->assign_wp_post( $post ); |
|
217
|
|
|
|
|
218
|
|
|
$options = WP2D_Options::instance(); |
|
219
|
|
|
|
|
220
|
|
|
// Is this post type enabled for posting? |
|
221
|
|
|
if ( ! in_array( $post->post_type, $options->get_option( 'enabled_post_types' ), true ) ) { |
|
222
|
|
|
return false; |
|
223
|
|
|
} |
|
224
|
|
|
|
|
225
|
|
|
// Make sure we're posting to diaspora* and the post isn't password protected. |
|
226
|
|
|
if ( ! ( $this->post_to_diaspora && 'publish' === $post->post_status && '' === $post->post_password ) ) { |
|
227
|
|
|
return false; |
|
228
|
|
|
} |
|
229
|
|
|
|
|
230
|
|
|
// Unset post_to_diaspora meta field to prevent mistakenly republishing to diaspora*. |
|
231
|
|
|
$meta = get_post_meta( $post_id, '_wp_to_diaspora', true ); |
|
232
|
|
|
$meta['post_to_diaspora'] = false; |
|
233
|
|
|
update_post_meta( $post_id, '_wp_to_diaspora', $meta ); |
|
234
|
|
|
|
|
235
|
|
|
$status_message = $this->get_title_link(); |
|
236
|
|
|
|
|
237
|
|
|
// Post the full post text, just the excerpt, or nothing at all? |
|
238
|
|
|
if ( 'full' === $this->display ) { |
|
239
|
|
|
$status_message .= $this->get_full_content(); |
|
240
|
|
|
} elseif ( 'excerpt' === $this->display ) { |
|
241
|
|
|
$status_message .= $this->get_excerpt_content(); |
|
242
|
|
|
} |
|
243
|
|
|
|
|
244
|
|
|
// Add the tags assigned to the post. |
|
245
|
|
|
$status_message .= $this->get_tags_to_add(); |
|
246
|
|
|
|
|
247
|
|
|
// Add the original entry link to the post? |
|
248
|
|
|
$status_message .= $this->get_posted_at_link(); |
|
249
|
|
|
|
|
250
|
|
|
$status_converter = new HtmlConverter( [ 'strip_tags' => true ] ); |
|
251
|
|
|
$status_message = $status_converter->convert( $status_message ); |
|
252
|
|
|
|
|
253
|
|
|
// Set up the connection to diaspora*. |
|
254
|
|
|
$api = WP2D_Helpers::api_quick_connect(); |
|
255
|
|
|
if ( empty( $status_message ) ) { |
|
256
|
|
|
return false; |
|
257
|
|
|
} |
|
258
|
|
|
|
|
259
|
|
|
if ( $api->has_last_error() ) { |
|
260
|
|
|
// Save the post error as post meta data, so we can display it to the user. |
|
261
|
|
|
update_post_meta( $post_id, '_wp_to_diaspora_post_error', $api->get_last_error() ); |
|
262
|
|
|
|
|
263
|
|
|
return false; |
|
264
|
|
|
} |
|
265
|
|
|
|
|
266
|
|
|
// Add services to share to via diaspora*. |
|
267
|
|
|
$extra_data = [ |
|
268
|
|
|
'services' => $this->services, |
|
269
|
|
|
]; |
|
270
|
|
|
|
|
271
|
|
|
// Try to post to diaspora*. |
|
272
|
|
|
$response = $api->post( $status_message, $this->aspects, $extra_data ); |
|
273
|
|
|
if ( ! $response ) { |
|
274
|
|
|
return false; |
|
275
|
|
|
} |
|
276
|
|
|
|
|
277
|
|
|
// Save certain diaspora* post data as meta data for future reference. |
|
278
|
|
|
$this->save_to_history( (object) $response ); |
|
279
|
|
|
|
|
280
|
|
|
// If there is still a previous post error around, remove it. |
|
281
|
|
|
delete_post_meta( $post_id, '_wp_to_diaspora_post_error' ); |
|
282
|
|
|
|
|
283
|
|
|
// Prevent any duplicate hook firing. |
|
284
|
|
|
remove_action( 'save_post', [ $this, 'post' ], 20, 2 ); |
|
285
|
|
|
|
|
286
|
|
|
return true; |
|
287
|
|
|
} |
|
288
|
|
|
|
|
289
|
|
|
/** |
|
290
|
|
|
* Get the title of the post linking to the post itself. |
|
291
|
|
|
* |
|
292
|
|
|
* @since 1.5.0 |
|
293
|
|
|
* |
|
294
|
|
|
* @return string Post title as a link. |
|
295
|
|
|
*/ |
|
296
|
|
|
private function get_title_link() { |
|
297
|
|
|
$title = esc_html( $this->post->post_title ); |
|
298
|
|
|
$permalink = get_permalink( $this->ID ); |
|
299
|
|
|
$title_link = sprintf( '<strong><a href="%2$s" title="%2$s">%1$s</a></strong>', $title, $permalink ); |
|
300
|
|
|
|
|
301
|
|
|
/** |
|
302
|
|
|
* Filter the title link at the top of the post. |
|
303
|
|
|
* |
|
304
|
|
|
* @since 1.5.4.1 |
|
305
|
|
|
* |
|
306
|
|
|
* @param string $default The whole HTML of the title link to be outputted. |
|
307
|
|
|
* @param WP2D_Post $wp2d_post This object, to allow total customisation of the title. |
|
308
|
|
|
*/ |
|
309
|
|
|
return apply_filters( 'wp2d_title_filter', "<p>{$title_link}</p>", $this ); |
|
310
|
|
|
} |
|
311
|
|
|
|
|
312
|
|
|
/** |
|
313
|
|
|
* Get the full post content with only default filters applied. |
|
314
|
|
|
* |
|
315
|
|
|
* @since 1.5.0 |
|
316
|
|
|
* |
|
317
|
|
|
* @return string The full post content. |
|
318
|
|
|
*/ |
|
319
|
|
|
private function get_full_content() { |
|
320
|
|
|
// Only allow certain shortcodes. |
|
321
|
|
|
global $shortcode_tags; |
|
322
|
|
|
$shortcode_tags_bkp = []; |
|
323
|
|
|
|
|
324
|
|
|
foreach ( $shortcode_tags as $shortcode_tag => $shortcode_function ) { |
|
325
|
|
|
if ( ! in_array( $shortcode_tag, apply_filters( 'wp2d_shortcodes_filter', [ 'wp_caption', 'caption', 'gallery' ] ), true ) ) { |
|
326
|
|
|
$shortcode_tags_bkp[ $shortcode_tag ] = $shortcode_function; |
|
327
|
|
|
unset( $shortcode_tags[ $shortcode_tag ] ); |
|
328
|
|
|
} |
|
329
|
|
|
} |
|
330
|
|
|
|
|
331
|
|
|
// Disable all filters and then enable only defaults. This prevents additional filters from being posted to diaspora*. |
|
332
|
|
|
remove_all_filters( 'the_content' ); |
|
333
|
|
|
|
|
334
|
|
|
/** @var array $content_filters List of filters to apply to the content. */ |
|
335
|
|
|
$content_filters = apply_filters( 'wp2d_content_filters_filter', [ 'do_shortcode', 'wptexturize', 'convert_smilies', 'convert_chars', 'wpautop', 'shortcode_unautop', 'prepend_attachment', [ $this, 'embed_remove' ] ] ); |
|
336
|
|
|
foreach ( $content_filters as $filter ) { |
|
337
|
|
|
add_filter( 'the_content', $filter ); |
|
338
|
|
|
} |
|
339
|
|
|
|
|
340
|
|
|
// Extract URLs from [embed] shortcodes. |
|
341
|
|
|
add_filter( 'embed_oembed_html', [ $this, 'embed_url' ], 10, 2 ); |
|
342
|
|
|
|
|
343
|
|
|
// Add the pretty caption after the images. |
|
344
|
|
|
add_filter( 'img_caption_shortcode', [ $this, 'custom_img_caption' ], 10, 3 ); |
|
345
|
|
|
|
|
346
|
|
|
// Overwrite the native shortcode handler to add pretty captions. |
|
347
|
|
|
// http://wordpress.stackexchange.com/a/74675/54456 for explanation. |
|
348
|
|
|
add_shortcode( 'gallery', [ $this, 'custom_gallery_shortcode' ] ); |
|
349
|
|
|
|
|
350
|
|
|
$post_content = apply_filters( 'the_content', $this->post->post_content ); |
|
351
|
|
|
|
|
352
|
|
|
// Put the removed shortcode tags back again. |
|
353
|
|
|
$shortcode_tags += $shortcode_tags_bkp; // phpcs:ignore |
|
354
|
|
|
|
|
355
|
|
|
/** |
|
356
|
|
|
* Filter the full content of the post. |
|
357
|
|
|
* |
|
358
|
|
|
* @since 2.1.0 |
|
359
|
|
|
* |
|
360
|
|
|
* @param string $default The whole HTML of the post to be outputted. |
|
361
|
|
|
* @param WP2D_Post $wp2d_post This object, to allow total customisation of the post. |
|
362
|
|
|
*/ |
|
363
|
|
|
return apply_filters( 'wp2d_post_filter', $post_content, $this ); |
|
364
|
|
|
} |
|
365
|
|
|
|
|
366
|
|
|
/** |
|
367
|
|
|
* Get the post's excerpt in a nice format. |
|
368
|
|
|
* |
|
369
|
|
|
* @since 1.5.0 |
|
370
|
|
|
* |
|
371
|
|
|
* @return string Post's excerpt. |
|
372
|
|
|
*/ |
|
373
|
|
|
private function get_excerpt_content() { |
|
374
|
|
|
// Look for the excerpt in the following order: |
|
375
|
|
|
// 1. Custom post excerpt. |
|
376
|
|
|
// 2. Text up to the <!--more--> tag. |
|
377
|
|
|
// 3. Manually trimmed content. |
|
378
|
|
|
$content = $this->post->post_content; |
|
379
|
|
|
$excerpt = $this->post->post_excerpt; |
|
380
|
|
|
if ( '' === $excerpt ) { |
|
381
|
|
|
if ( $more_pos = strpos( $content, '<!--more' ) ) { |
|
382
|
|
|
$excerpt = substr( $content, 0, $more_pos ); |
|
383
|
|
|
} else { |
|
384
|
|
|
$excerpt = wp_trim_words( $content, 42, '[...]' ); |
|
385
|
|
|
} |
|
386
|
|
|
} |
|
387
|
|
|
|
|
388
|
|
|
/** |
|
389
|
|
|
* Filter the excerpt of the post. |
|
390
|
|
|
* |
|
391
|
|
|
* @since 2.1.0 |
|
392
|
|
|
* |
|
393
|
|
|
* @param string $default The whole HTML of the excerpt to be outputted. |
|
394
|
|
|
* @param WP2D_Post $wp2d_post This object, to allow total customisation of the excerpt. |
|
395
|
|
|
*/ |
|
396
|
|
|
return apply_filters( 'wp2d_excerpt_filter', "<p>{$excerpt}</p>", $this ); |
|
397
|
|
|
} |
|
398
|
|
|
|
|
399
|
|
|
/** |
|
400
|
|
|
* Get a string of tags that have been added to the post. |
|
401
|
|
|
* |
|
402
|
|
|
* @since 1.5.0 |
|
403
|
|
|
* |
|
404
|
|
|
* @return string Tags added to the post. |
|
405
|
|
|
*/ |
|
406
|
|
|
private function get_tags_to_add() { |
|
407
|
|
|
$options = WP2D_Options::instance(); |
|
408
|
|
|
$tags_to_post = $this->tags_to_post; |
|
409
|
|
|
$tags_to_add = ''; |
|
410
|
|
|
$diaspora_tags = []; |
|
411
|
|
|
|
|
412
|
|
|
// Add any diaspora* tags? |
|
413
|
|
|
if ( ! empty( $tags_to_post ) ) { |
|
414
|
|
|
// The diaspora* tags to add to the post. |
|
415
|
|
|
$diaspora_tags_tmp = []; |
|
416
|
|
|
|
|
417
|
|
|
// Add global tags? |
|
418
|
|
|
$global_tags = $options->get_option( 'global_tags' ); |
|
419
|
|
|
if ( is_array( $global_tags ) && in_array( 'global', $tags_to_post, true ) ) { |
|
420
|
|
|
$diaspora_tags_tmp += array_flip( $global_tags ); |
|
421
|
|
|
} |
|
422
|
|
|
|
|
423
|
|
|
// Add custom tags? |
|
424
|
|
|
if ( is_array( $this->custom_tags ) && in_array( 'custom', $tags_to_post, true ) ) { |
|
425
|
|
|
$diaspora_tags_tmp += array_flip( $this->custom_tags ); |
|
426
|
|
|
} |
|
427
|
|
|
|
|
428
|
|
|
// Add post tags? |
|
429
|
|
|
$post_tags = wp_get_post_tags( $this->ID, [ 'fields' => 'slugs' ] ); |
|
430
|
|
|
if ( is_array( $post_tags ) && in_array( 'post', $tags_to_post, true ) ) { |
|
431
|
|
|
$diaspora_tags_tmp += array_flip( $post_tags ); |
|
432
|
|
|
} |
|
433
|
|
|
|
|
434
|
|
|
// Get an array of cleaned up tags. |
|
435
|
|
|
// NOTE: Validate method needs a variable, as it's passed by reference! |
|
436
|
|
|
$diaspora_tags_tmp = array_keys( $diaspora_tags_tmp ); |
|
437
|
|
|
$options->validate_tags( $diaspora_tags_tmp ); |
|
438
|
|
|
|
|
439
|
|
|
// Get all the tags and list them all nicely in a row. |
|
440
|
|
|
foreach ( $diaspora_tags_tmp as $tag ) { |
|
|
|
|
|
|
441
|
|
|
$diaspora_tags[] = '#' . $tag; |
|
442
|
|
|
} |
|
443
|
|
|
|
|
444
|
|
|
// Add all the found tags. |
|
445
|
|
|
if ( ! empty( $diaspora_tags ) ) { |
|
446
|
|
|
$tags_to_add = implode( ' ', $diaspora_tags ) . '<br/>'; |
|
447
|
|
|
} |
|
448
|
|
|
} |
|
449
|
|
|
|
|
450
|
|
|
/** |
|
451
|
|
|
* Filter the tags of the post. |
|
452
|
|
|
* |
|
453
|
|
|
* @since 2.1.0 |
|
454
|
|
|
* |
|
455
|
|
|
* @param string $default The whole string of tags to be outputted. |
|
456
|
|
|
* @param array $tags All tags that are assigned to this post. |
|
457
|
|
|
* @param WP2D_Post $wp2d_post This object, to allow total customisation of the tags output. |
|
458
|
|
|
*/ |
|
459
|
|
|
return apply_filters( 'wp2d_tags_filter', $tags_to_add, $diaspora_tags, $this ); |
|
460
|
|
|
} |
|
461
|
|
|
|
|
462
|
|
|
/** |
|
463
|
|
|
* Get the link to the original post. |
|
464
|
|
|
* |
|
465
|
|
|
* @since 1.5.0 |
|
466
|
|
|
* |
|
467
|
|
|
* @return string Original post link. |
|
468
|
|
|
*/ |
|
469
|
|
|
private function get_posted_at_link() { |
|
470
|
|
|
if ( $this->fullentrylink ) { |
|
471
|
|
|
$prefix = esc_html__( 'Originally posted at:', 'wp-to-diaspora' ); |
|
472
|
|
|
$permalink = get_permalink( $this->ID ); |
|
473
|
|
|
$title = esc_html__( 'Permalink', 'wp-to-diaspora' ); |
|
474
|
|
|
$posted_at_link = sprintf( '%1$s <a href="%2$s" title="%3$s">%2$s</a>', $prefix, $permalink, $title ); |
|
475
|
|
|
|
|
476
|
|
|
/** |
|
477
|
|
|
* Filter the "Originally posted at" link at the bottom of the post. |
|
478
|
|
|
* |
|
479
|
|
|
* @since 1.5.4.1 |
|
480
|
|
|
* |
|
481
|
|
|
* @param string $default The whole HTML of the text and link to be outputted. |
|
482
|
|
|
* @param WP2D_Post $wp2d_post This object, to allow total customisation of the title. |
|
483
|
|
|
* @param string $prefix The "Originally posted at:" prefix before the link. |
|
484
|
|
|
*/ |
|
485
|
|
|
return apply_filters( 'wp2d_posted_at_link_filter', "<p>{$posted_at_link}</p>", $this, $prefix ); |
|
486
|
|
|
} |
|
487
|
|
|
|
|
488
|
|
|
return ''; |
|
489
|
|
|
} |
|
490
|
|
|
|
|
491
|
|
|
/** |
|
492
|
|
|
* Save the details of the new diaspora* post to this post's history. |
|
493
|
|
|
* |
|
494
|
|
|
* @since 1.5.0 |
|
495
|
|
|
* |
|
496
|
|
|
* @param object $response Response from the API containing the diaspora* post details. |
|
497
|
|
|
*/ |
|
498
|
|
|
private function save_to_history( $response ) { |
|
499
|
|
|
// Make sure the post history is an array. |
|
500
|
|
|
if ( empty( $this->post_history ) ) { |
|
501
|
|
|
$this->post_history = []; |
|
502
|
|
|
} |
|
503
|
|
|
|
|
504
|
|
|
// Add a new entry to the history. |
|
505
|
|
|
$this->post_history[] = [ |
|
506
|
|
|
'id' => $response->id, |
|
507
|
|
|
'guid' => $response->guid, |
|
508
|
|
|
'created_at' => $this->post->post_modified, |
|
509
|
|
|
'aspects' => $this->aspects, |
|
510
|
|
|
'nsfw' => $response->nsfw, |
|
511
|
|
|
'post_url' => $response->permalink, |
|
512
|
|
|
]; |
|
513
|
|
|
|
|
514
|
|
|
update_post_meta( $this->ID, '_wp_to_diaspora_post_history', $this->post_history ); |
|
515
|
|
|
} |
|
516
|
|
|
|
|
517
|
|
|
/** |
|
518
|
|
|
* Return URL from [embed] shortcode instead of generated iframe. |
|
519
|
|
|
* |
|
520
|
|
|
* @since 1.5.0 |
|
521
|
|
|
* @see WP_Embed::shortcode() |
|
522
|
|
|
* |
|
523
|
|
|
* @param mixed $html The cached HTML result, stored in post meta. |
|
524
|
|
|
* @param string $url The attempted embed URL. |
|
525
|
|
|
* |
|
526
|
|
|
* @return string URL of the embed. |
|
527
|
|
|
*/ |
|
528
|
|
|
public function embed_url( $html, $url ) { |
|
529
|
|
|
return $url; |
|
530
|
|
|
} |
|
531
|
|
|
|
|
532
|
|
|
/** |
|
533
|
|
|
* Removes '[embed]' and '[/embed]' left by embed_url. |
|
534
|
|
|
* |
|
535
|
|
|
* @since 1.5.0 |
|
536
|
|
|
* |
|
537
|
|
|
* @todo It would be great to fix it using only one filter. |
|
538
|
|
|
* It's happening because embed filter is being removed by remove_all_filters('the_content') on WP2D_Post::post(). |
|
539
|
|
|
* |
|
540
|
|
|
* @param string $content Content of the post. |
|
541
|
|
|
* |
|
542
|
|
|
* @return string The content with the embed tags removed. |
|
543
|
|
|
*/ |
|
544
|
|
|
public function embed_remove( $content ) { |
|
545
|
|
|
return str_replace( [ '[embed]', '[/embed]' ], [ '<p>', '</p>' ], $content ); |
|
546
|
|
|
} |
|
547
|
|
|
|
|
548
|
|
|
/** |
|
549
|
|
|
* Prettify the image caption. |
|
550
|
|
|
* |
|
551
|
|
|
* @since 1.5.3 |
|
552
|
|
|
* |
|
553
|
|
|
* @param string $caption Caption to be prettified. |
|
554
|
|
|
* |
|
555
|
|
|
* @return string Prettified image caption. |
|
556
|
|
|
*/ |
|
557
|
|
|
public function get_img_caption( $caption ) { |
|
558
|
|
|
$caption = trim( $caption ); |
|
559
|
|
|
if ( '' === $caption ) { |
|
560
|
|
|
return ''; |
|
561
|
|
|
} |
|
562
|
|
|
|
|
563
|
|
|
/** |
|
564
|
|
|
* Filter the image caption to be displayed after images with captions. |
|
565
|
|
|
* |
|
566
|
|
|
* @since 1.5.3 |
|
567
|
|
|
* |
|
568
|
|
|
* @param string $default The whole HTML of the caption. |
|
569
|
|
|
* @param string $caption The caption text. |
|
570
|
|
|
*/ |
|
571
|
|
|
return apply_filters( 'wp2d_image_caption', "<blockquote>{$caption}</blockquote>", $caption ); |
|
572
|
|
|
} |
|
573
|
|
|
|
|
574
|
|
|
/** |
|
575
|
|
|
* Filter the default caption shortcode output. |
|
576
|
|
|
* |
|
577
|
|
|
* @since 1.5.3 |
|
578
|
|
|
* |
|
579
|
|
|
* @see img_caption_shortcode() |
|
580
|
|
|
* |
|
581
|
|
|
* @param string $empty The caption output. Default empty. |
|
582
|
|
|
* @param array $attr Attributes of the caption shortcode. |
|
583
|
|
|
* @param string $content The image element, possibly wrapped in a hyperlink. |
|
584
|
|
|
* |
|
585
|
|
|
* @return string The caption shortcode output. |
|
586
|
|
|
*/ |
|
587
|
|
|
public function custom_img_caption( $empty, $attr, $content ) { |
|
588
|
|
|
$content = do_shortcode( $content ); |
|
589
|
|
|
|
|
590
|
|
|
// If a caption attribute is defined, we'll add it after the image. |
|
591
|
|
|
if ( isset( $attr['caption'] ) && '' !== $attr['caption'] ) { |
|
592
|
|
|
$content .= "\n" . $this->get_img_caption( $attr['caption'] ); |
|
593
|
|
|
} |
|
594
|
|
|
|
|
595
|
|
|
return $content; |
|
596
|
|
|
} |
|
597
|
|
|
|
|
598
|
|
|
/** |
|
599
|
|
|
* Create a custom gallery caption output. |
|
600
|
|
|
* |
|
601
|
|
|
* @since 1.5.3 |
|
602
|
|
|
* |
|
603
|
|
|
* @param array $attr Gallery attributes. |
|
604
|
|
|
* |
|
605
|
|
|
* @return string |
|
606
|
|
|
*/ |
|
607
|
|
|
public function custom_gallery_shortcode( $attr ) { |
|
608
|
|
|
// Try user value and fall back to default value in WordPress. |
|
609
|
|
|
$captiontag = $attr['captiontag'] ?? ( current_theme_supports( 'html5', 'gallery' ) ? 'figcaption' : 'dd' ); |
|
610
|
|
|
|
|
611
|
|
|
// Let WordPress create the regular gallery. |
|
612
|
|
|
$gallery = gallery_shortcode( $attr ); |
|
613
|
|
|
|
|
614
|
|
|
// Change the content of the captions. |
|
615
|
|
|
$gallery = preg_replace_callback( |
|
616
|
|
|
'~(<' . $captiontag . '.*>)(.*)(</' . $captiontag . '>)~mUus', |
|
617
|
|
|
[ $this, 'custom_gallery_regex_callback' ], |
|
618
|
|
|
$gallery |
|
619
|
|
|
); |
|
620
|
|
|
|
|
621
|
|
|
return $gallery; |
|
622
|
|
|
} |
|
623
|
|
|
|
|
624
|
|
|
/** |
|
625
|
|
|
* Change the result of the regex match from custom_gallery_shortcode. |
|
626
|
|
|
* |
|
627
|
|
|
* @param array $m Regex matches. |
|
628
|
|
|
* |
|
629
|
|
|
* @return string Prettified gallery image caption. |
|
630
|
|
|
*/ |
|
631
|
|
|
public function custom_gallery_regex_callback( $m ) { |
|
632
|
|
|
return $this->get_img_caption( $m[2] ); |
|
633
|
|
|
} |
|
634
|
|
|
|
|
635
|
|
|
/* |
|
636
|
|
|
* META BOX |
|
637
|
|
|
*/ |
|
638
|
|
|
|
|
639
|
|
|
/** |
|
640
|
|
|
* Adds a meta box to the main column on the enabled Post Types' edit screens. |
|
641
|
|
|
* |
|
642
|
|
|
* @since 1.5.0 |
|
643
|
|
|
*/ |
|
644
|
|
|
public function add_meta_boxes() { |
|
645
|
|
|
$options = WP2D_Options::instance(); |
|
646
|
|
|
foreach ( $options->get_option( 'enabled_post_types' ) as $post_type ) { |
|
647
|
|
|
add_meta_box( |
|
648
|
|
|
'wp_to_diaspora_meta_box', |
|
649
|
|
|
'WP to diaspora*', |
|
650
|
|
|
[ $this, 'meta_box_render' ], |
|
651
|
|
|
$post_type, |
|
652
|
|
|
'side', |
|
653
|
|
|
'high' |
|
654
|
|
|
); |
|
655
|
|
|
} |
|
656
|
|
|
} |
|
657
|
|
|
|
|
658
|
|
|
/** |
|
659
|
|
|
* Prints the meta box content. |
|
660
|
|
|
* |
|
661
|
|
|
* @since 1.5.0 |
|
662
|
|
|
* |
|
663
|
|
|
* @param WP_Post $post The object for the current post. |
|
664
|
|
|
*/ |
|
665
|
|
|
public function meta_box_render( $post ) { |
|
666
|
|
|
$this->assign_wp_post( $post ); |
|
667
|
|
|
|
|
668
|
|
|
// Add an nonce field so we can check for it later. |
|
669
|
|
|
wp_nonce_field( 'wp_to_diaspora_meta_box', 'wp_to_diaspora_meta_box_nonce' ); |
|
670
|
|
|
|
|
671
|
|
|
// Get the default values to use, but give priority to the meta data already set. |
|
672
|
|
|
$options = WP2D_Options::instance(); |
|
673
|
|
|
|
|
674
|
|
|
// Make sure we have some value for post meta fields. |
|
675
|
|
|
$this->custom_tags = $this->custom_tags ?: []; |
|
676
|
|
|
|
|
677
|
|
|
// If this post is already published, don't post again to diaspora* by default. |
|
678
|
|
|
$this->post_to_diaspora = ( $this->post_to_diaspora && 'publish' !== get_post_status( $this->ID ) ); |
|
679
|
|
|
$this->aspects = $this->aspects ?: []; |
|
680
|
|
|
$this->services = $this->services ?: []; |
|
681
|
|
|
|
|
682
|
|
|
// Have we already posted on diaspora*? |
|
683
|
|
|
$diaspora_post_url = '#'; |
|
684
|
|
|
if ( is_array( $this->post_history ) ) { |
|
685
|
|
|
$latest_post = end( $this->post_history ); |
|
686
|
|
|
$diaspora_post_url = $latest_post['post_url']; |
|
687
|
|
|
} |
|
688
|
|
|
?> |
|
689
|
|
|
<p<?php echo '#' === $diaspora_post_url ? ' style="display: none;"' : ''; ?>><a id="diaspora-post-url" href="<?php echo esc_attr( $diaspora_post_url ); ?>" target="_blank"><?php esc_html_e( 'Already posted to diaspora*.', 'wp-to-diaspora' ); ?></a></p> |
|
690
|
|
|
|
|
691
|
|
|
<p><?php $options->post_to_diaspora_render( $this->post_to_diaspora ); ?></p> |
|
692
|
|
|
<p><?php $options->fullentrylink_render( $this->fullentrylink ); ?></p> |
|
693
|
|
|
<p><?php $options->display_render( $this->display ); ?></p> |
|
694
|
|
|
<p><?php $options->tags_to_post_render( $this->tags_to_post ); ?></p> |
|
695
|
|
|
<p><?php $options->custom_tags_render( $this->custom_tags ); ?></p> |
|
696
|
|
|
<p><?php $options->aspects_services_render( [ 'aspects', $this->aspects ] ); ?></p> |
|
697
|
|
|
<p><?php $options->aspects_services_render( [ 'services', $this->services ] ); ?></p> |
|
698
|
|
|
|
|
699
|
|
|
<?php |
|
700
|
|
|
} |
|
701
|
|
|
|
|
702
|
|
|
/** |
|
703
|
|
|
* When the post is saved, save our meta data. |
|
704
|
|
|
* |
|
705
|
|
|
* @since 1.5.0 |
|
706
|
|
|
* |
|
707
|
|
|
* @param integer $post_id The ID of the post being saved. |
|
708
|
|
|
*/ |
|
709
|
|
|
public function save_meta_box_data( $post_id ) { |
|
710
|
|
|
/* |
|
711
|
|
|
* We need to verify this came from our screen and with proper authorization, |
|
712
|
|
|
* because the save_post action can be triggered at other times. |
|
713
|
|
|
*/ |
|
714
|
|
|
if ( ! $this->is_safe_to_save() ) { |
|
715
|
|
|
return; |
|
716
|
|
|
} |
|
717
|
|
|
|
|
718
|
|
|
/* OK, it's safe for us to save the data now. */ |
|
719
|
|
|
|
|
720
|
|
|
// Meta data to save. |
|
721
|
|
|
$meta_to_save = $_POST['wp_to_diaspora_settings']; // phpcs:ignore |
|
722
|
|
|
$options = WP2D_Options::instance(); |
|
723
|
|
|
|
|
724
|
|
|
// Checkboxes. |
|
725
|
|
|
$options->validate_checkboxes( [ 'post_to_diaspora', 'fullentrylink' ], $meta_to_save ); |
|
726
|
|
|
|
|
727
|
|
|
// Single Selects. |
|
728
|
|
|
$options->validate_single_selects( 'display', $meta_to_save ); |
|
729
|
|
|
|
|
730
|
|
|
// Multiple Selects. |
|
731
|
|
|
$options->validate_multi_selects( 'tags_to_post', $meta_to_save ); |
|
732
|
|
|
|
|
733
|
|
|
// Save custom tags as array. |
|
734
|
|
|
$options->validate_tags( $meta_to_save['custom_tags'] ); |
|
735
|
|
|
|
|
736
|
|
|
// Clean up the list of aspects. If the list is empty, only use the 'Public' aspect. |
|
737
|
|
|
$options->validate_aspects_services( $meta_to_save['aspects'], [ 'public' ] ); |
|
738
|
|
|
|
|
739
|
|
|
// Clean up the list of services. |
|
740
|
|
|
$options->validate_aspects_services( $meta_to_save['services'] ); |
|
741
|
|
|
|
|
742
|
|
|
// Update the meta data for this post. |
|
743
|
|
|
update_post_meta( $post_id, '_wp_to_diaspora', $meta_to_save ); |
|
744
|
|
|
} |
|
745
|
|
|
|
|
746
|
|
|
/** |
|
747
|
|
|
* Perform all checks to see if we are allowed to save the meta data. |
|
748
|
|
|
* |
|
749
|
|
|
* @since 1.5.0 |
|
750
|
|
|
* |
|
751
|
|
|
* @return bool If the verification checks have passed. |
|
752
|
|
|
*/ |
|
753
|
|
|
private function is_safe_to_save() { |
|
754
|
|
|
// Verify that our nonce is set and valid. |
|
755
|
|
|
if ( ! ( isset( $_POST['wp_to_diaspora_meta_box_nonce'] ) && wp_verify_nonce( sanitize_key( $_POST['wp_to_diaspora_meta_box_nonce'] ), 'wp_to_diaspora_meta_box' ) ) ) { |
|
756
|
|
|
return false; |
|
757
|
|
|
} |
|
758
|
|
|
|
|
759
|
|
|
// If this is an autosave, our form has not been submitted, so we don't want to do anything. |
|
760
|
|
|
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { |
|
761
|
|
|
return false; |
|
762
|
|
|
} |
|
763
|
|
|
|
|
764
|
|
|
// Check the user's permissions. |
|
765
|
|
|
$permission = ( isset( $_POST['post_type'] ) && 'page' === $_POST['post_type'] ) ? 'edit_pages' : 'edit_posts'; |
|
766
|
|
|
if ( ! current_user_can( $permission, $this->ID ) ) { |
|
767
|
|
|
return false; |
|
768
|
|
|
} |
|
769
|
|
|
|
|
770
|
|
|
// Make real sure that we have some meta data to save. |
|
771
|
|
|
if ( ! isset( $_POST['wp_to_diaspora_settings'] ) ) { |
|
772
|
|
|
return false; |
|
773
|
|
|
} |
|
774
|
|
|
|
|
775
|
|
|
return true; |
|
776
|
|
|
} |
|
777
|
|
|
|
|
778
|
|
|
/** |
|
779
|
|
|
* Add admin notices when a post gets displayed. |
|
780
|
|
|
* |
|
781
|
|
|
* @since 1.5.0 |
|
782
|
|
|
* |
|
783
|
|
|
* @todo Ignore post error with AJAX. |
|
784
|
|
|
*/ |
|
785
|
|
|
public function admin_notices() { |
|
786
|
|
|
global $post, $pagenow; |
|
787
|
|
|
if ( ! $post || 'post.php' !== $pagenow ) { |
|
788
|
|
|
return; |
|
789
|
|
|
} |
|
790
|
|
|
|
|
791
|
|
|
if ( ( $error = get_post_meta( $post->ID, '_wp_to_diaspora_post_error', true ) ) && is_wp_error( $error ) ) { |
|
792
|
|
|
// Are we adding a help tab link to this notice? |
|
793
|
|
|
$help_link = WP2D_Contextual_Help::get_help_tab_quick_link( $error ); |
|
794
|
|
|
|
|
795
|
|
|
// This notice will only be shown if posting to diaspora* has failed. |
|
796
|
|
|
printf( |
|
797
|
|
|
'<div class="error notice is-dismissible"><p>%1$s %2$s %3$s <a href="%4$s">%5$s</a></p></div>', |
|
798
|
|
|
esc_html__( 'Failed to post to diaspora*.', 'wp-to-diaspora' ), |
|
799
|
|
|
esc_html( $error->get_error_message() ), |
|
800
|
|
|
$help_link, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
|
801
|
|
|
esc_url( add_query_arg( 'wp2d_ignore_post_error', '' ) ), |
|
802
|
|
|
esc_html__( 'Ignore', 'wp-to-diaspora' ) |
|
803
|
|
|
); |
|
804
|
|
|
} elseif ( ( $diaspora_post_history = get_post_meta( $post->ID, '_wp_to_diaspora_post_history', true ) ) && is_array( $diaspora_post_history ) ) { |
|
805
|
|
|
// Get the latest post from the history. |
|
806
|
|
|
$latest_post = end( $diaspora_post_history ); |
|
807
|
|
|
|
|
808
|
|
|
// Only show if this post is showing a message and the post is a fresh share. |
|
809
|
|
|
if ( isset( $_GET['message'] ) && $post->post_modified === $latest_post['created_at'] ) { // phpcs:ignore |
|
810
|
|
|
printf( |
|
811
|
|
|
'<div class="updated notice is-dismissible"><p>%1$s <a href="%2$s" target="_blank">%3$s</a></p></div>', |
|
812
|
|
|
esc_html__( 'Successfully posted to diaspora*.', 'wp-to-diaspora' ), |
|
813
|
|
|
esc_url( $latest_post['post_url'] ), |
|
814
|
|
|
esc_html__( 'View Post', 'wp-to-diaspora' ) |
|
815
|
|
|
); |
|
816
|
|
|
} |
|
817
|
|
|
} |
|
818
|
|
|
} |
|
819
|
|
|
|
|
820
|
|
|
/** |
|
821
|
|
|
* Delete the error post meta data if it gets ignored. |
|
822
|
|
|
* |
|
823
|
|
|
* @since 1.5.0 |
|
824
|
|
|
*/ |
|
825
|
|
|
public function ignore_post_error() { |
|
826
|
|
|
// If "Ignore" link has been clicked, delete the post error meta data. |
|
827
|
|
|
if ( isset( $_GET['wp2d_ignore_post_error'], $_GET['post'] ) ) { // phpcs:ignore |
|
828
|
|
|
delete_post_meta( absint( $_GET['post'] ), '_wp_to_diaspora_post_error' ); // phpcs:ignore |
|
829
|
|
|
} |
|
830
|
|
|
} |
|
831
|
|
|
|
|
832
|
|
|
/** |
|
833
|
|
|
* Get latest diaspora* share of this post. |
|
834
|
|
|
* |
|
835
|
|
|
* @since 3.0.0 |
|
836
|
|
|
*/ |
|
837
|
|
|
public function get_post_history_callback() { |
|
838
|
|
View Code Duplication |
if ( ! check_ajax_referer( 'wp2d', 'nonce', false ) ) { |
|
|
|
|
|
|
839
|
|
|
wp_send_json_error( [ |
|
840
|
|
|
'message' => 'WP2D: ' . __( 'AJAX Nonce failure.', 'wp-to-diaspora' ), |
|
841
|
|
|
] ); |
|
842
|
|
|
} |
|
843
|
|
|
|
|
844
|
|
|
$post_id = sanitize_key( $_REQUEST['post_id'] ?? '' ); |
|
845
|
|
|
if ( ! is_numeric( $post_id ) ) { |
|
846
|
|
|
return; |
|
847
|
|
|
} |
|
848
|
|
|
|
|
849
|
|
|
if ( $error = get_post_meta( $post_id, '_wp_to_diaspora_post_error', true ) ) { |
|
850
|
|
|
// This notice will only be shown if posting to diaspora* has failed. |
|
851
|
|
|
wp_send_json_error( [ |
|
852
|
|
|
'message' => esc_html__( 'Failed to post to diaspora*.', 'wp-to-diaspora' ) . ' - ' . esc_html( $error ), |
|
853
|
|
|
] ); |
|
854
|
|
|
} |
|
855
|
|
|
|
|
856
|
|
|
if ( ( $diaspora_post_history = get_post_meta( $post_id, '_wp_to_diaspora_post_history', true ) ) && is_array( $diaspora_post_history ) ) { |
|
857
|
|
|
// Get the latest post from the history. |
|
858
|
|
|
$latest_post = end( $diaspora_post_history ); |
|
859
|
|
|
|
|
860
|
|
|
// Only show if this post is a fresh share. |
|
861
|
|
|
if ( get_post( $post_id )->post_modified === $latest_post['created_at'] ) { // phpcs:ignore |
|
862
|
|
|
wp_send_json_success( [ |
|
863
|
|
|
'message' => esc_html__( 'Successfully posted to diaspora*.', 'wp-to-diaspora' ), |
|
864
|
|
|
'action' => [ |
|
865
|
|
|
'label' => esc_html__( 'View Post', 'wp-to-diaspora' ), |
|
866
|
|
|
'url' => esc_url( $latest_post['post_url'] ), |
|
867
|
|
|
], |
|
868
|
|
|
] ); |
|
869
|
|
|
} |
|
870
|
|
|
} |
|
871
|
|
|
} |
|
872
|
|
|
} |
|
873
|
|
|
|
There are different options of fixing this problem.
If you want to be on the safe side, you can add an additional type-check:
If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:
Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.