PluginProbe
Filter Everything — WordPress & WooCommerce Filters / 1.9.7
Filter Everything — WordPress & WooCommerce Filters v1.9.7
1.9.7 1.9.6 1.9.5 1.9.4 1.9.3 1.9.2.2 1.9.2.1 trunk 1.2.1 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.1 1.4.4 1.4.5 1.4.8 1.4.9 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 All 52 releases
filter-everything / src / Admin / WhatsNew.php

WhatsNew.php in Filter Everything — WordPress & WooCommerce Filters 1.9.7, at src/Admin/WhatsNew.php

344 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FilterEverything\Filter;
4
5 if ( ! defined('ABSPATH') ) {
6 exit;
7 }
8
9 /**
10 * «What's new» — release notes inside the admin, plus the badge that points to them.
11 *
12 * The problem it solves: after an update (increasingly an automatic one) nobody
13 * opens the plugin settings to discover what changed, yet a redirect or a modal
14 * would be obnoxious. So:
15 *
16 * - a «What's new» tab on the Settings page (the last one) renders the
17 * release notes of the INSTALLED version straight from the bundled
18 * readme.txt changelog — zero extra maintenance, no remote calls, always in
19 * sync with what actually shipped; earlier releases are listed below,
20 * collapsed. It used to be a submenu entry (1.9.6); it moved into Settings
21 * so that the Filters menu stays short with Import/Export back in it;
22 * - after an in-place update the WP-native red «1» badge sits on the Filters
23 * menu item (collapsed menu), on «Settings» once the menu is open, and on
24 * the «What's new» tab inside Settings, until THIS admin opens the tab — it
25 * is visible from every admin screen but demands nothing, and it stops on
26 * its own after self::BADGE_TTL. Fresh installs never see it: there is
27 * nothing to catch up on. An unread plugin notification lights the same
28 * badge (Messages::hasUnseen()).
29 *
30 * "Seen" is per user (user meta — each admin gets one quiet nudge), "updated"
31 * is per site (AdminNotices' update stamp). Both builds, free and PRO.
32 *
33 * Complements, not replaces, the one-off AdminNotices entries: those are for
34 * releases that change behaviour; the badge is the baseline for every release.
35 */
36 class WhatsNew implements TabInterface
37 {
38 /** Settings tab name (?page=filters-settings&tab=…). */
39 const TAB = 'whats_new';
40
41 /** Former submenu slug (?page=…), kept so old links land on the tab. */
42 const PAGE_SLUG = 'filters-whats-new';
43
44 /** Per-user: the last version whose release notes this admin has opened. */
45 const USER_META = 'flrt_whats_new_seen';
46
47 /** The badge gives up on its own after this long since the update. */
48 const BADGE_TTL = 30 * DAY_IN_SECONDS;
49
50 /** The complete, always-current changelog lives on the site. */
51 const CHANGELOG_URL = 'https://filtereverything.pro/changelog/';
52
53 public function __construct()
54 {
55 if ( ! is_admin() ) {
56 return;
57 }
58
59 // Last Settings tab, after License (PRO) / PRO benefits (free)
60 add_action( 'wpc_settings_tabs_registered', [ $this, 'registerTab' ] );
61 // Runs after every submenu exists
62 add_action( 'admin_menu', [ $this, 'decorateMenu' ], 99 );
63 // Bookmarked / old links to the former submenu page
64 // The old page is no longer registered, so WordPress denies it in
65 // wp-admin/menu.php before admin_init ever runs; this hook fires right
66 // before that 403 and is the only place a redirect can still happen.
67 add_action( 'admin_page_access_denied', [ $this, 'redirectOldPage' ] );
68 }
69
70 /* ----------------------------------------------------------------- TabInterface */
71
72 public function registerTab( TabRenderer $renderer )
73 {
74 $renderer->register( $this );
75 }
76
77 public function init()
78 {
79 }
80
81 public function getName()
82 {
83 return self::TAB;
84 }
85
86 public function getLabel()
87 {
88 return esc_html__( "What's new", 'filter-everything' );
89 }
90
91 public function valid()
92 {
93 return true;
94 }
95
96 /** The unread badge on the tab itself (options.php prints it after the label): same count as the menu. */
97 public function labelBadge()
98 {
99 if ( self::isWhatsNewPage() ) {
100 return '';
101 }
102 list( $count, $label ) = $this->unread();
103
104 return $count ? self::badgeHtml( $count, $label ) : '';
105 }
106
107 /**
108 * What this admin has not seen yet: release notes and/or a plugin
109 * notification (Messages::hasUnseen()).
110 *
111 * @return array{0:int,1:string} count and screen-reader label
112 */
113 public function unread()
114 {
115 $notes = $this->badgeDue();
116 $message = class_exists( __NAMESPACE__ . '\\Messages' ) && Messages::hasUnseen();
117 $label = $notes && $message
118 ? esc_html__( 'New release notes and a new notification', 'filter-everything' )
119 : ( $notes ? esc_html__( 'New release notes available', 'filter-everything' ) : esc_html__( 'New notification', 'filter-everything' ) );
120
121 return array( (int) $notes + (int) $message, $label );
122 }
123
124 public function redirectOldPage()
125 {
126 if ( isset( $_GET['page'] ) && sanitize_key( wp_unslash( $_GET['page'] ) ) === self::PAGE_SLUG ) {
127 wp_safe_redirect( self::pageUrl() );
128 exit;
129 }
130 }
131
132 public static function parentSlug()
133 {
134 return 'edit.php?post_type=' . FLRT_FILTERS_SET_POST_TYPE;
135 }
136
137 public static function pageUrl()
138 {
139 return admin_url( self::parentSlug() . '&page=filters-settings&tab=' . self::TAB );
140 }
141
142 /** Version as it appears in the changelog: 1.9.6-dev → 1.9.6 */
143 public static function baseVersion()
144 {
145 return preg_replace( '/[-+].*$/', '', FLRT_PLUGIN_VER );
146 }
147
148 public static function isWhatsNewPage()
149 {
150 return isset( $_GET['page'], $_GET['tab'] )
151 && sanitize_key( wp_unslash( $_GET['page'] ) ) === 'filters-settings'
152 && sanitize_key( wp_unslash( $_GET['tab'] ) ) === self::TAB;
153 }
154
155 public static function badgeHtml( $count, $label )
156 {
157 return ' <span class="update-plugins count-' . (int) $count . ' flrt-whats-new-badge"><span class="plugin-count" aria-hidden="true">' . (int) $count . '</span>'
158 . '<span class="screen-reader-text">' . $label . '</span></span>';
159 }
160
161 /**
162 * Appends the badge to the Filters menu item and to the «Settings» entry
163 * while this admin has unread release notes and/or an unread plugin
164 * notification (Messages::hasUnseen()). Inside Settings the same count
165 * sits on the «What's new» tab (labelBadge()). Opening the tab marks the
166 * notes read; the notification counts as read once it has been shown on
167 * any of the plugin's screens — the tab included, so one click clears both.
168 */
169 public function decorateMenu()
170 {
171 if ( ! current_user_can( flrt_plugin_user_caps() ) ) {
172 return;
173 }
174
175 if ( self::isWhatsNewPage() ) {
176 $this->markSeen();
177 return;
178 }
179
180 list( $count, $label ) = $this->unread();
181
182 if ( ! $count ) {
183 return;
184 }
185
186 global $menu, $submenu;
187
188 $parent = self::parentSlug();
189 $badge = self::badgeHtml( $count, $label );
190
191 foreach ( (array) $menu as $i => $item ) {
192 if ( isset( $item[2] ) && $item[2] === $parent ) {
193 $menu[ $i ][0] .= $badge;
194 break;
195 }
196 }
197
198 if ( isset( $submenu[ $parent ] ) ) {
199 foreach ( $submenu[ $parent ] as $i => $item ) {
200 if ( isset( $item[2] ) && $item[2] === 'filters-settings' ) {
201 $submenu[ $parent ][ $i ][0] .= $badge;
202 break;
203 }
204 }
205 }
206 }
207
208 /**
209 * Unread notes exist when the site was updated in place to the running
210 * version (never on a fresh install), the update is recent enough, and this
211 * admin has not opened the page for this version yet.
212 */
213 public function badgeDue()
214 {
215 if ( get_option( AdminNotices::UPDATED_OPTION ) !== FLRT_PLUGIN_VER ) {
216 return false;
217 }
218
219 $since = (int) get_option( AdminNotices::UPDATED_AT_OPTION, 0 );
220 if ( $since && ( time() - $since ) > self::BADGE_TTL ) {
221 return false;
222 }
223
224 return get_user_meta( get_current_user_id(), self::USER_META, true ) !== FLRT_PLUGIN_VER;
225 }
226
227 public function markSeen()
228 {
229 if ( get_user_meta( get_current_user_id(), self::USER_META, true ) !== FLRT_PLUGIN_VER ) {
230 update_user_meta( get_current_user_id(), self::USER_META, FLRT_PLUGIN_VER );
231 }
232 }
233
234 public function render()
235 {
236 if ( ! current_user_can( flrt_plugin_user_caps() ) ) {
237 return '';
238 }
239
240 $releases = self::changelog();
241 $version = self::baseVersion();
242 $current = null;
243
244 if ( isset( $releases[ $version ] ) ) {
245 $current = $releases[ $version ];
246 } elseif ( ! empty( $releases ) ) {
247 // A dev/hotfix build without its own block yet: show the newest notes
248 $version = (string) array_key_first( $releases );
249 $current = $releases[ $version ];
250 }
251
252 $earlier = $releases;
253 unset( $earlier[ $version ] );
254
255 ob_start();
256 flrt_include_admin_view( 'whats-new', [
257 'version' => $version,
258 'current' => $current,
259 'earlier' => $earlier,
260 'changelog_url' => self::CHANGELOG_URL,
261 ] );
262
263 return ob_get_clean();
264 }
265
266 /**
267 * Parses the bundled readme.txt changelog.
268 *
269 * Understands the format the project has used for years:
270 * = 1.9.6 =
271 * *Release Date - 5 August 2026*
272 * * Fix - Fixed …
273 * * Dev - NEW: Added …
274 *
275 * @return array version => ['date' => string, 'items' => [ ['label' => 'Fix', 'new' => bool, 'text' => string], … ]]
276 * in readme order (newest first)
277 */
278 public static function changelog()
279 {
280 $file = FLRT_PLUGIN_DIR_PATH . 'readme.txt';
281 if ( ! is_readable( $file ) ) {
282 return [];
283 }
284
285 $text = (string) file_get_contents( $file );
286 $pos = stripos( $text, '== Changelog ==' );
287 if ( $pos === false ) {
288 return [];
289 }
290 $text = substr( $text, $pos + strlen( '== Changelog ==' ) );
291
292 // Stop at the next top-level section, e.g. "== Upgrade Notice =="
293 if ( preg_match( '/^==\s*[^=\r\n]+\s*==\s*$/m', $text, $m, PREG_OFFSET_CAPTURE ) ) {
294 $text = substr( $text, 0, $m[0][1] );
295 }
296
297 $releases = [];
298 $version = null;
299
300 foreach ( preg_split( '/\r\n|\r|\n/', $text ) as $line ) {
301 $line = trim( $line );
302 if ( $line === '' ) {
303 continue;
304 }
305
306 if ( preg_match( '/^=\s*([0-9][0-9A-Za-z.\-]*)\s*=$/', $line, $m ) ) {
307 $version = $m[1];
308 $releases[ $version ] = [ 'date' => '', 'items' => [] ];
309 continue;
310 }
311
312 if ( $version === null ) {
313 continue;
314 }
315
316 if ( preg_match( '/^\*\s*Release Date\s*[-–—:]\s*(.+?)\s*\*$/i', $line, $m ) ) {
317 $releases[ $version ]['date'] = $m[1];
318 continue;
319 }
320
321 if ( preg_match( '/^[\*\-]\s*(?:([A-Za-z]+)\s+-\s+)?(.+)$/', $line, $m ) ) {
322 $label = $m[1];
323 $body = trim( $m[2] );
324 $is_new = false;
325
326 if ( preg_match( '/^NEW:\s*/i', $body ) ) {
327 $is_new = true;
328 $body = preg_replace( '/^NEW:\s*/i', '', $body );
329 }
330
331 $releases[ $version ]['items'][] = [
332 'label' => $label,
333 'new' => $is_new,
334 'text' => $body,
335 ];
336 }
337 }
338
339 return $releases;
340 }
341 }
342
343 new WhatsNew();
344