From e9af96961506cc90964267ff4e433b208b396dd0 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:27:15 +0100 Subject: [PATCH 01/14] fix: keep safe mode on for the admin screens' REST requests --- .../SnippetsTable/SnippetsTable.tsx | 27 ++++++++++++++----- src/js/hooks/useRestAPI.tsx | 2 ++ src/js/types/Window.ts | 1 + src/js/utils/restAPI.ts | 15 +++++++++++ src/php/Integration/Evaluate_Functions.php | 6 ++--- src/php/Plugin.php | 1 + 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx index 7d6d1a81f..08731d527 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx @@ -63,6 +63,12 @@ const SnippetTypeTab: React.FC = ({ type, count }) => { } +const getUrlWithoutSafeMode = (): string => { + const url = new URL(window.location.href) + url.searchParams.delete('snippets-safe-mode') + return url.toString() +} + const SafeModeNotice = () => window.CODE_SNIPPETS_MANAGE?.isSafeModeActive ? @@ -70,12 +76,21 @@ const SafeModeNotice = () => {__('Warning:', 'code-snippets')}{'\n'} {__('Safe mode is active and snippets will not execute!', 'code-snippets')}{'\n'} - {createInterpolateElement( - __('Remove the CODE_SNIPPETS_SAFE_MODE constant from wp-config.php file to turn off safe mode.', 'code-snippets'), - { - code: - } - )}{'\n'} + {window.CODE_SNIPPETS?.safeModeRequested + ? createInterpolateElement( + __('It was turned on for this visit with snippets-safe-mode in the address. Leave safe mode.', 'code-snippets'), + { + code: , + // eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label + a: + } + ) + : createInterpolateElement( + __('Remove the CODE_SNIPPETS_SAFE_MODE constant from wp-config.php file to turn off safe mode.', 'code-snippets'), + { + code: + } + )}{'\n'} {__('Read more', 'code-snippets')} diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index 94021be36..e20514ffe 100644 --- a/src/js/hooks/useRestAPI.tsx +++ b/src/js/hooks/useRestAPI.tsx @@ -6,6 +6,7 @@ import { applyCacheBuster, applyMethodOverride, applyRestNonce, + applySafeMode, listenForNonceRefresh } from '../utils/restAPI' import type { PropsWithChildren } from 'react' @@ -69,6 +70,7 @@ export const WithRestAPIContext: React.FC = ({ children }) => instance.interceptors.request.use(applyRestNonce) instance.interceptors.request.use(applyMethodOverride) instance.interceptors.request.use(applyCacheBuster) + instance.interceptors.request.use(applySafeMode) listenForNonceRefresh() return instance }, []) diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index 274feafdd..d07cece5f 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -33,6 +33,7 @@ declare global { readonly CODE_SNIPPETS?: { debug: boolean isLicensed: boolean + safeModeRequested: boolean hideUpsell: boolean isCloudConnected: boolean snippetView: SnippetView diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 4cdfe2bc2..984153027 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -123,6 +123,21 @@ export const applyCacheBuster = (config: InternalAxiosRequestConfig): InternalAx return config } +/** + * Carry safe mode through to the REST API when the page was opened in it. + * + * Safe mode is requested per request. Without the flag, a call from a screen + * opened in safe mode runs every snippet, including the one that made safe + * mode necessary, and fails the same way the rest of the site does. + */ +export const applySafeMode = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + if (window.CODE_SNIPPETS?.safeModeRequested) { + config.params = { ... | undefined>config.params, 'snippets-safe-mode': 1 } + } + + return config +} + export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = { headers: { 'Access-Control': window.CODE_SNIPPETS?.restAPI.cloud.token diff --git a/src/php/Integration/Evaluate_Functions.php b/src/php/Integration/Evaluate_Functions.php index dd01920c7..8265c2865 100644 --- a/src/php/Integration/Evaluate_Functions.php +++ b/src/php/Integration/Evaluate_Functions.php @@ -41,7 +41,7 @@ public function __construct( DB $db ) { // undefined wp_get_current_user() and take the whole request down. // Site URLs are left alone: callers append paths to them, REST URLs // among them, and a query var in the middle breaks the result. - if ( $this->is_safe_mode_query_var_set() ) { + if ( self::is_safe_mode_query_var_set() ) { add_filter( 'admin_url', [ $this, 'add_safe_mode_query_var' ] ); } } @@ -53,7 +53,7 @@ public function __construct( DB $db ) { * * @return bool */ - public function is_safe_mode_query_var_set(): bool { + public static function is_safe_mode_query_var_set(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return ! empty( $_REQUEST['snippets-safe-mode'] ); } @@ -67,7 +67,7 @@ public function is_safe_mode_query_var_set(): bool { * @return bool */ public function is_safe_mode_requested(): bool { - return $this->is_safe_mode_query_var_set() && code_snippets()->current_user_can(); + return self::is_safe_mode_query_var_set() && code_snippets()->current_user_can(); } /** diff --git a/src/php/Plugin.php b/src/php/Plugin.php index fbc0e0880..ae9585da9 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -376,6 +376,7 @@ public function localize_script( string $handle ) { [ 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'isLicensed' => $this->licensing->is_licensed(), + 'safeModeRequested' => Evaluate_Functions::is_safe_mode_query_var_set() && $this->current_user_can(), 'isCloudConnected' => $this->cloud_connection->is_authenticated(), 'hideUpsell' => Settings\get_setting( 'general', 'hide_upgrade_menu' ), 'snippetView' => Snippet_View_REST_Controller::get_snippet_view(), From 5bc2fd9461533bf02783b0a1093f188c11162f1e Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:30:29 +0100 Subject: [PATCH 02/14] fix: stop snippet REST requests overwriting, misfiling or failing silently --- .../REST_API/Snippets/Snippets_REST_Controller.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/php/REST_API/Snippets/Snippets_REST_Controller.php b/src/php/REST_API/Snippets/Snippets_REST_Controller.php index f2f4c52ca..c7a5fa4ce 100644 --- a/src/php/REST_API/Snippets/Snippets_REST_Controller.php +++ b/src/php/REST_API/Snippets/Snippets_REST_Controller.php @@ -533,6 +533,9 @@ public function get_item( $request ) { */ public function create_item( $request ) { $snippet = $this->prepare_item_for_database( $request ); + + // An ID in the body names no snippet to create; saving it would overwrite that snippet. + $snippet->id = 0; $result = save_snippet( $snippet ); return $result @@ -594,6 +597,14 @@ public function delete_item( $request ) { ); } + if ( $snippet->locked ) { + return new WP_Error( + 'rest_snippet_locked', + __( 'This snippet is locked. Unlock it before trashing or deleting it.', 'code-snippets' ), + [ 'status' => 409 ] + ); + } + if ( $snippet->trashed ) { return delete_snippet( $snippet->id, $snippet->network ) ? new WP_REST_Response( null, 204 ) @@ -891,6 +902,7 @@ public function get_item_schema(): array { 'scope' => [ 'description' => esc_html__( 'Context in which the snippet is executable.', 'code-snippets' ), 'type' => 'string', + 'enum' => Snippet::get_all_scopes(), ], 'condition_id' => [ 'description' => esc_html__( 'Identifier of condition linked to this snippet.', 'code-snippets' ), From b845a07570fedb78d045b1cd4a0957e2931118ee Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:30:39 +0100 Subject: [PATCH 03/14] fix: keep snippet priority within the stored range --- .../EditMenu/EditorSidebar/controls/PriorityInput.tsx | 2 ++ src/js/components/common/snippets/SnippetPriorityInput.tsx | 2 ++ src/php/Model/Snippet.php | 5 ++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx b/src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx index 36ffb5e18..c9c172586 100644 --- a/src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx @@ -19,6 +19,8 @@ export const PriorityInput = () => { = ({ snip type="number" className="snippet-priority" value={value} + min={0} + max={32767} step="1" onBlur={handleUpdate} aria-label={__('Snippet priority', 'code-snippets')} diff --git a/src/php/Model/Snippet.php b/src/php/Model/Snippet.php index a849bb852..5aa2e79d1 100644 --- a/src/php/Model/Snippet.php +++ b/src/php/Model/Snippet.php @@ -111,8 +111,11 @@ class Snippet extends Model { */ protected function prepare_field( $value, string $field ) { switch ( $field ) { - case 'id': case 'priority': + // Stored as a SMALLINT; negative priorities are not supported. + return max( 0, min( 32767, intval( $value ) ) ); + + case 'id': case 'condition_id': case 'cloud_id': case 'revision': From 9ca11496468891681c5519a4892570663c60bfa1 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:30:56 +0100 Subject: [PATCH 04/14] test: cover snippet REST create, delete and priority handling --- tests/unit/Model/Snippet_Test.php | 12 +++ .../Snippets/REST_API_Snippets_Test.php | 74 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/tests/unit/Model/Snippet_Test.php b/tests/unit/Model/Snippet_Test.php index f99e7f789..1aab3ceea 100644 --- a/tests/unit/Model/Snippet_Test.php +++ b/tests/unit/Model/Snippet_Test.php @@ -91,4 +91,16 @@ public function test_get_modified_fields_handles_aliased_field_names(): void { $this->assertArrayHasKey( 'desc', $modified ); $this->assertSame( 'set through the alias', $modified['desc'] ); } + + /** + * Priority is kept within the range the database column stores. + * + * @return void + */ + public function test_priority_is_clamped_to_the_stored_range(): void { + $this->assertSame( 0, ( new Snippet( [ 'priority' => -5 ] ) )->priority ); + $this->assertSame( 0, ( new Snippet( [ 'priority' => -99999999999 ] ) )->priority ); + $this->assertSame( 32767, ( new Snippet( [ 'priority' => 99999999 ] ) )->priority ); + $this->assertSame( 20, ( new Snippet( [ 'priority' => '20' ] ) )->priority ); + } } diff --git a/tests/unit/REST_API/Snippets/REST_API_Snippets_Test.php b/tests/unit/REST_API/Snippets/REST_API_Snippets_Test.php index 24d5ea668..d9f69a177 100644 --- a/tests/unit/REST_API/Snippets/REST_API_Snippets_Test.php +++ b/tests/unit/REST_API/Snippets/REST_API_Snippets_Test.php @@ -8,6 +8,7 @@ use function Code_Snippets\code_snippets; use function Code_Snippets\get_snippet; use function Code_Snippets\save_snippet; +use function Code_Snippets\set_snippet_locked; /** * Tests for the Snippets REST API endpoint. @@ -371,4 +372,77 @@ public function test_create_active_snippet_returns_runtime_error_details() { $this->assertIsString( $response['code_error_trace'] ); $this->assertNotSame( '', $response['code_error_trace'] ); } + + /** + * Creating a snippet never overwrites the one named by an ID in the body. + * + * @return void + */ + public function test_create_ignores_an_id_in_the_body(): void { + $existing = save_snippet( + new Snippet( + [ + 'name' => 'Existing', + 'code' => '// existing', + ] + ) + ); + + $response = $this->make_mutating_request( + 'POST', + "/{$this->namespace}/{$this->base_route}", + [ + 'id' => $existing->id, + 'name' => 'Created', + 'code' => '// created', + ] + ); + + $this->assertNotSame( $existing->id, $response['id'] ); + $this->assertSame( 'Existing', get_snippet( $existing->id )->name ); + $this->assertSame( '// existing', get_snippet( $existing->id )->code ); + } + + /** + * A scope the plugin does not know is refused rather than stored as another. + * + * @return void + */ + public function test_create_rejects_an_unknown_scope(): void { + $response = $this->make_mutating_request( + 'POST', + "/{$this->namespace}/{$this->base_route}", + [ + 'name' => 'Unknown scope', + 'code' => '// code', + 'scope' => 'nonsense-scope', + ] + ); + + $this->assertSame( 'rest_invalid_param', $response['code'] ); + } + + /** + * Deleting a locked snippet is refused as a conflict, with a reason. + * + * @return void + */ + public function test_deleting_a_locked_snippet_is_refused(): void { + $snippet = save_snippet( + new Snippet( + [ + 'name' => 'Locked', + 'code' => '// locked', + ] + ) + ); + set_snippet_locked( $snippet->id, true ); + + $response = rest_do_request( new WP_REST_Request( 'DELETE', "/{$this->namespace}/{$this->base_route}/{$snippet->id}" ) ); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'rest_snippet_locked', $response->get_data()['code'] ); + $this->assertNotNull( get_snippet( $snippet->id )->id ); + $this->assertFalse( get_snippet( $snippet->id )->trashed ); + } } From 3f9278f2a612f5a6464c5e54a625526315665f6e Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:32:18 +0100 Subject: [PATCH 05/14] fix: report validation errors against the snippet line they are on --- src/php/Utils/Validator.php | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/php/Utils/Validator.php b/src/php/Utils/Validator.php index 6731650b0..c3308d3a8 100644 --- a/src/php/Utils/Validator.php +++ b/src/php/Utils/Validator.php @@ -116,6 +116,25 @@ private function peek() { return $this->end() ? null : $this->tokens[ $this->current ]; } + /** + * Line of the snippet the pointer has reached. + * + * Single-character tokens carry no line number, so the nearest earlier token + * that does is used. The code is parsed with an opening tag on a line of its + * own, which is not part of the snippet. + * + * @return int + */ + private function current_line(): int { + for ( $index = min( $this->current, $this->length - 1 ); $index >= 0; $index-- ) { + if ( is_array( $this->tokens[ $index ] ) ) { + return max( 1, (int) $this->tokens[ $index ][2] - 1 ); + } + } + + return 1; + } + /** * Move the pointer to the next token, if there is one. * @@ -305,7 +324,7 @@ public function validate() { if ( $this->end() ) { return array( 'message' => __( 'Parse error: syntax error, unexpected end of snippet.', 'code-snippets' ), - 'line' => $token[2], + 'line' => $this->current_line(), ); } @@ -334,7 +353,7 @@ public function validate() { return array( 'message' => sprintf( $message, $token[1] ), - 'line' => $token[2], + 'line' => $this->current_line(), ); } } @@ -368,7 +387,7 @@ public function validate() { if ( $depth > 0 ) { return array( 'message' => __( 'Parse error: syntax error, unexpected end of snippet.', 'code-snippets' ), - 'line' => $token[2], + 'line' => $this->current_line(), ); } } From 71ace1d4a6578bcbd87c7b7bd895c18009340d5e Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:32:29 +0100 Subject: [PATCH 06/14] test: cover the line numbers the validator reports --- tests/unit/Utils/Validator_Test.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/Utils/Validator_Test.php b/tests/unit/Utils/Validator_Test.php index 01e04191a..16fe93d30 100644 --- a/tests/unit/Utils/Validator_Test.php +++ b/tests/unit/Utils/Validator_Test.php @@ -67,4 +67,31 @@ public function test_relative_namespace_names_are_not_declarations(): void { $this->assertFalse( $validator->validate() ); $this->assertContains( 'acme\\helper', $validator->get_claimed_identifiers()[ T_FUNCTION ] ); } + + /** + * A syntax error is reported against the snippet line it is on. + * + * Warnings are converted to exceptions for this suite, so reading a line + * number from a single-character token fails here. + * + * @return void + */ + public function test_unfinished_declaration_reports_its_line(): void { + $result = ( new Validator( "// first line\nfunction (" ) )->validate(); + + $this->assertIsArray( $result ); + $this->assertSame( 2, $result['line'] ); + } + + /** + * A redeclaration is reported against the snippet line it is on. + * + * @return void + */ + public function test_redeclaration_reports_its_line(): void { + $result = ( new Validator( "\n\nfunction code_snippets_build_tags_array() {}" ) )->validate(); + + $this->assertIsArray( $result ); + $this->assertSame( 3, $result['line'] ); + } } From 2f6bb3c2698f21a45caacd7dece9951036586330 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:33:27 +0100 Subject: [PATCH 07/14] fix: keep non-ASCII letters in exported snippet filenames --- src/js/utils/files.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/js/utils/files.ts b/src/js/utils/files.ts index e3a0ee39a..2cc7f6927 100644 --- a/src/js/utils/files.ts +++ b/src/js/utils/files.ts @@ -41,7 +41,11 @@ export const downloadSnippetExportFile = ( { id, name, scope }: Snippet, type?: keyof typeof MIME_INFO ) => { - const sanitizedName = name.toLowerCase().replace(/[^\w-]+/g, '-').trim() + const sanitizedName = name + .normalize('NFC') + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}_-]+/gu, '-') + .replace(/^-+|-+$/g, '') const title = '' === sanitizedName ? `snippet-${id}` : sanitizedName if ('string' === typeof content) { From 562217b80c2001d50d6289328268292ca16f0837 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:36:03 +0100 Subject: [PATCH 08/14] fix: confirm a bulk permanent delete and report how many were deleted --- .../SnippetsTable/SnippetsListTable.tsx | 97 ++++++++++++++++++- .../SnippetsTable/useApplyBulkAction.ts | 29 ++++-- 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx index 11e83c0f7..b0a3a70da 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx @@ -1,9 +1,11 @@ -import { __ } from '@wordpress/i18n' +import { __, _n, sprintf } from '@wordpress/i18n' import React, { useEffect, useMemo, useState } from 'react' import classnames from 'classnames' import { getSnippetType, isSnippetActive } from '../../../utils/snippets/snippets' import { buildUrl } from '../../../utils/urls' +import { ConfirmDialog } from '../../common/ConfirmDialog' import { ListTable } from '../../common/ListTable' +import { DismissibleNotice } from '../../common/Notice' import { SnippetViewToggle } from '../../common/SnippetViewToggle' import { SnippetsCardGrid } from './SnippetsCardGrid' import { SnippetsTableNavigation, SnippetsTableToolbar } from './SnippetsTableControls' @@ -178,6 +180,88 @@ export interface SnippetsListTableProps { setSnippetView: (view: SnippetView) => void } +type ApplyBulkAction = ReturnType + +/** + * Hold a permanent bulk delete until it is confirmed, and count what it deleted. + */ +const useConfirmedBulkDelete = (applyBulkAction: ApplyBulkAction) => { + const [pendingDelete, setPendingDelete] = useState>() + const [deletedCount, setDeletedCount] = useState() + + const doAction = async (action: SnippetsTableAction | undefined, selected: Set): Promise => { + if ('delete' === action) { + setPendingDelete(selected) + } else { + await applyBulkAction(action, selected) + } + } + + const confirmDelete = () => { + const selected = pendingDelete + setPendingDelete(undefined) + + if (selected) { + void applyBulkAction('delete', selected).then(count => setDeletedCount(count ?? 0)) + } + } + + return { + doAction, + pendingDelete, + cancelDelete: () => setPendingDelete(undefined), + confirmDelete, + deletedCount, + dismissDeleted: () => setDeletedCount(undefined) + } +} + +interface BulkDeleteFeedbackProps { + pendingDelete: Set | undefined + cancelDelete: VoidFunction + confirmDelete: VoidFunction + deletedCount: number | undefined + dismissDeleted: VoidFunction +} + +const BulkDeleteFeedback: React.FC = ({ + pendingDelete, + cancelDelete, + confirmDelete, + deletedCount, + dismissDeleted +}) => + <> + {undefined !== deletedCount && + +

+ {sprintf( + // translators: %d: number of snippets. + _n('%d snippet permanently deleted.', '%d snippets permanently deleted.', deletedCount, 'code-snippets'), + deletedCount + )} +

+
} + + +

+ {sprintf( + // translators: %d: number of snippets. + _n('%d snippet will be permanently deleted.', '%d snippets will be permanently deleted.', pendingDelete?.size ?? 0, 'code-snippets'), + pendingDelete?.size ?? 0 + )} +

+

{__('This action cannot be undone.', 'code-snippets')}

+
+ + export const SnippetsListTable: React.FC = ({ snippetView, setSnippetView @@ -191,6 +275,7 @@ export const SnippetsListTable: React.FC = ({ [snippetsByStatus, currentStatus] ) const applyBulkAction = useApplyBulkAction(currentSnippets) + const { doAction, pendingDelete, cancelDelete, confirmDelete, deletedCount, dismissDeleted } = useConfirmedBulkDelete(applyBulkAction) useEffect(() => { if (INDEX_STATUS !== currentStatus && !snippetsByStatus.has(currentStatus)) { @@ -205,13 +290,21 @@ export const SnippetsListTable: React.FC = ({ <> + +
Promise | Promise, refresh: () => Promise -): Promise => { +): Promise => { + let succeeded = 0 + if (0 < targets.length) { for (const snippet of targets) { - await action(snippet).catch(handleUnknownError) + await action(snippet) + .then(() => { + succeeded += 1 + }) + .catch(handleUnknownError) } await refresh() } + + return succeeded } export const useApplyBulkAction = ( allSnippets: Snippet[] -): (action: SnippetsTableAction | undefined, selected: Set) => Promise => { +): (action: SnippetsTableAction | undefined, selected: Set) => Promise => { const api = useSnippetsAPI() const { refreshSnippetsList } = useSnippetsList() @@ -128,21 +141,23 @@ export const useApplyBulkAction = ( case 'download': { const selectedSnippets = allSnippets.filter(snippet => selected.has(snippet.id)) - return 1 < selectedSnippets.length && !window.CODE_SNIPPETS_MANAGE?.supportsZipDownloads + await (1 < selectedSnippets.length && !window.CODE_SNIPPETS_MANAGE?.supportsZipDownloads ? submitBulkSnippetDownloadsIndividually(selectedSnippets) - : submitBulkSnippetDownload(selectedSnippets) + : submitBulkSnippetDownload(selectedSnippets)) + break } case 'trash': case 'delete': - await applyAndRefresh( + return applyAndRefresh( allSnippets.filter(snippet => selected.has(snippet.id)), snippet => api.delete({ id: snippet.id, network: snippet.network }), refreshSnippetsList) - break case undefined: break } + + return undefined } } From 72558b1130554d8cc4f8d7d7ca5986cdfc92f9a1 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:36:39 +0100 Subject: [PATCH 09/14] fix: stop logging parser failures on half-typed code as console errors --- src/js/utils/Linter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/js/utils/Linter.ts b/src/js/utils/Linter.ts index db0369e4a..85bbf0938 100644 --- a/src/js/utils/Linter.ts +++ b/src/js/utils/Linter.ts @@ -95,7 +95,11 @@ export class Linter { this.visit(program) } catch (error) { - console.error(error) + // The parser can fail outright on code that is still being typed, even with + // errors suppressed. The next change lints again, so this is not an error. + if (window.CODE_SNIPPETS?.debug) { + console.debug(error) + } } } From b9810c6203c6ebd552afc5570db09bee34d792ff Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:38:06 +0100 Subject: [PATCH 10/14] fix: show each setting's reworded description once --- src/php/Settings/Settings_Layout.php | 31 ++++++++++++++++++++++++++++ src/php/Settings/settings.php | 9 +------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/php/Settings/Settings_Layout.php b/src/php/Settings/Settings_Layout.php index a38e461a1..78269be5b 100644 --- a/src/php/Settings/Settings_Layout.php +++ b/src/php/Settings/Settings_Layout.php @@ -212,4 +212,35 @@ public static function get_descriptions(): array { 'reset_caches' => __( 'Clears stored snippet data and refetches the list of plugin versions available to install. Worth doing before switching to an older version of the plugin.', 'code-snippets' ), ]; } + + /** + * Apply the replacement description, if any, to a field definition. + * + * A checkbox already reads its own label aloud and shows it beside the box, so + * its replacement takes the place of the label rather than repeating it below. + * A link the old label carried is kept. + * + * @param array $field Field definition. + * @param string $field_id Field identifier. + * + * @return array Field definition with the description applied. + */ + public static function apply_description( array $field, string $field_id ): array { + $descriptions = self::get_descriptions(); + + if ( ! isset( $descriptions[ $field_id ] ) ) { + return $field; + } + + if ( 'checkbox' === ( $field['type'] ?? '' ) ) { + $link = preg_match( '/]*>.*?<\/a>/s', (string) ( $field['label'] ?? '' ), $matches ) ? ' ' . $matches[0] : ''; + + $field['label'] = $descriptions[ $field_id ] . $link; + $field['desc'] = ''; + } else { + $field['desc'] = $descriptions[ $field_id ]; + } + + return $field; + } } diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php index 6f9a77c72..62ebd0a16 100644 --- a/src/php/Settings/settings.php +++ b/src/php/Settings/settings.php @@ -203,19 +203,12 @@ function register_plugin_settings() { // Register settings fields. The tab a field appears under comes from the // layout, while the section it saves into stays exactly as it was. $definitions = Settings_Fields::get_field_definitions(); - $descriptions = Settings_Layout::get_descriptions(); $headings = Settings_Layout::get_group_headings(); foreach ( get_settings_sections() as $tab_id => $tab_name ) { foreach ( Settings_Layout::get_visible_fields( $tab_id, $current_settings ) as $entry ) { list( $section_id, $field_id ) = $entry; - $field = $definitions[ $section_id ][ $field_id ]; - - // Reword the settings whose description names the control without - // saying what it buys you. - if ( isset( $descriptions[ $field_id ] ) ) { - $field['desc'] = $descriptions[ $field_id ]; - } + $field = Settings_Layout::apply_description( $definitions[ $section_id ][ $field_id ], $field_id ); $field_object = new Setting_Field( $section_id, $field_id, $field ); From 44f59f20eed7e73754df34d5e0cc2ccd12034329 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:38:16 +0100 Subject: [PATCH 11/14] test: cover how reworded setting descriptions are applied --- tests/unit/Settings/Settings_Layout_Test.php | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/unit/Settings/Settings_Layout_Test.php b/tests/unit/Settings/Settings_Layout_Test.php index fdc6b1175..56acc8fe2 100644 --- a/tests/unit/Settings/Settings_Layout_Test.php +++ b/tests/unit/Settings/Settings_Layout_Test.php @@ -158,4 +158,42 @@ public function test_version_switching_needs_no_setting_to_enable_it(): void { $this->assertNotContains( [ 'debug', 'enable_version_change' ], $fields ); $this->assertArrayNotHasKey( 'enable_version_change', Settings_Fields::get_field_definitions()['debug'] ); } + + /** + * A checkbox shows its replacement wording once, as its label. + * + * @return void + */ + public function test_checkbox_replacement_wording_is_shown_once(): void { + $field = Settings_Layout::apply_description( + [ + 'type' => 'checkbox', + 'label' => 'Old wording. Learn more', + 'desc' => '', + ], + 'enable_flat_files' + ); + + $replacement = Settings_Layout::get_descriptions()['enable_flat_files']; + + $this->assertSame( $replacement . ' Learn more', $field['label'] ); + $this->assertSame( '', $field['desc'] ); + } + + /** + * Other field types take the replacement as their description. + * + * @return void + */ + public function test_other_fields_take_the_replacement_as_description(): void { + $field = Settings_Layout::apply_description( + [ + 'type' => 'number', + 'desc' => 'Old wording.', + ], + 'max_revisions' + ); + + $this->assertSame( Settings_Layout::get_descriptions()['max_revisions'], $field['desc'] ); + } } From d1b1afe92e4237dee38c389ae92f18d154fdab87 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:39:56 +0100 Subject: [PATCH 12/14] fix: warn when an imported snippet loses the condition it had --- .../REST_API/Import/File_Import_REST_Controller.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/php/REST_API/Import/File_Import_REST_Controller.php b/src/php/REST_API/Import/File_Import_REST_Controller.php index 182e1c2eb..9686d6fc5 100644 --- a/src/php/REST_API/Import/File_Import_REST_Controller.php +++ b/src/php/REST_API/Import/File_Import_REST_Controller.php @@ -185,6 +185,18 @@ public function parse_uploaded_files( WP_REST_Request $request ) { 'message' => sprintf( $message, count( $all_snippets ) ), ]; + // Conditions are referenced by their ID on the site the snippet came from, + // which means nothing here, so they are left behind. + foreach ( $all_snippets as $snippet_data ) { + if ( absint( $snippet_data['condition_id'] ?? $snippet_data['conditionId'] ?? 0 ) ) { + $errors[] = sprintf( + /* translators: %s: snippet name */ + __( '"%s" was limited by a condition on the site it came from. Conditions are not imported, so once activated it will run wherever its location allows.', 'code-snippets' ), + $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ) + ); + } + } + if ( ! empty( $errors ) ) { $response['warnings'] = $errors; } From efd8e915eae88c9b2fdc75c1ec073d475208337e Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 13:40:06 +0100 Subject: [PATCH 13/14] test: cover the import warning for snippets that had a condition --- .../File_Import_REST_Controller_Test.php | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/unit/REST_API/Import/File_Import_REST_Controller_Test.php diff --git a/tests/unit/REST_API/Import/File_Import_REST_Controller_Test.php b/tests/unit/REST_API/Import/File_Import_REST_Controller_Test.php new file mode 100644 index 000000000..92c7bda5d --- /dev/null +++ b/tests/unit/REST_API/Import/File_Import_REST_Controller_Test.php @@ -0,0 +1,107 @@ +files, 'file_exists' ) ); + parent::tear_down(); + } + + /** + * Parse an export file holding the given snippets. + * + * @param array> $snippets Snippets in the export. + * + * @return WP_REST_Response + */ + private function parse_export( array $snippets ): WP_REST_Response { + $path = wp_tempnam( 'export.code-snippets.json' ); + file_put_contents( $path, wp_json_encode( [ 'snippets' => $snippets ] ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $this->files[] = $path; + + $request = new WP_REST_Request( 'POST', '/code-snippets/v1/import/file-upload/parse' ); + $request->set_header( 'X-WP-Nonce', wp_create_nonce( 'wp_rest' ) ); + $request->set_file_params( + [ + 'files' => [ + 'name' => 'export.code-snippets.json', + 'type' => 'application/json', + 'tmp_name' => $path, + 'error' => UPLOAD_ERR_OK, + 'size' => filesize( $path ), + ], + ] + ); + + return rest_do_request( $request ); + } + + /** + * A snippet that had a condition is flagged, as the condition is not imported. + * + * @return void + */ + public function test_snippet_with_a_condition_is_flagged(): void { + $response = $this->parse_export( + [ + [ + 'name' => 'Limited snippet', + 'code' => '// limited', + 'condition_id' => 12, + ], + [ + 'name' => 'Unlimited snippet', + 'code' => '// unlimited', + ], + ] + ); + + $warnings = $response->get_data()['warnings'] ?? []; + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $warnings ); + $this->assertStringContainsString( 'Limited snippet', $warnings[0] ); + } + + /** + * Snippets without conditions import without warnings. + * + * @return void + */ + public function test_snippets_without_conditions_are_not_flagged(): void { + $response = $this->parse_export( + [ + [ + 'name' => 'Plain snippet', + 'code' => '// plain', + ], + ] + ); + + $this->assertArrayNotHasKey( 'warnings', $response->get_data() ); + } +} From 4b67344e3ac679e5ff78e5a9b2fb0ce4fc25365b Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 20 Oct 2026 15:42:24 +0100 Subject: [PATCH 14/14] fix: fit the admin screens to narrow viewports --- src/css/common/_theme.scss | 7 +++++++ src/css/common/_toolbar.scss | 16 ++++++++++++++++ src/css/settings.scss | 1 + 3 files changed, 24 insertions(+) diff --git a/src/css/common/_theme.scss b/src/css/common/_theme.scss index 48c61acb3..23d5de55e 100644 --- a/src/css/common/_theme.scss +++ b/src/css/common/_theme.scss @@ -44,6 +44,13 @@ --cs-direction-multiplier: 1; // LTR = 1, RTL = -1 } +// WordPress halves the content area's start padding on narrow screens. +@media (width <= 782px) { + :root { + --cs-wpcontent-inline-start-indent: 10px; + } +} + html[dir="rtl"], body.rtl { --cs-direction-multiplier: -1; diff --git a/src/css/common/_toolbar.scss b/src/css/common/_toolbar.scss index 77a57a2c8..053547d83 100644 --- a/src/css/common/_toolbar.scss +++ b/src/css/common/_toolbar.scss @@ -182,6 +182,10 @@ } @media (width <= 782px) { + nav { + overflow-x: auto; + } + li a { inline-size: 100%; padding-inline: 8px; @@ -438,3 +442,15 @@ margin: 0; } } + +// On the narrowest screens WordPress makes room for its 46px admin bar by +// padding the body rather than the page, so the toolbar has to clear it itself. +@media (width <= 600px) { + #wpbody { + padding-block-start: calc(var(--cs-toolbar-block-size) + 46px); + } + + .code-snippets-toolbar { + inset-block-start: 46px; + } +} diff --git a/src/css/settings.scss b/src/css/settings.scss index 252753f91..182f305be 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -48,6 +48,7 @@ input[type='number'] { } .CodeMirror { + box-sizing: border-box; max-inline-size: 800px; inline-size: 100%; padding-inline-end: 1em;