diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 00000000..65f1ce16 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,10 @@ +last 2 versions +not dead +Chrome >= 111 +Edge >= 111 +Firefox >= 112 +Safari >= 16.4 +Android >= 111 +ChromeAndroid >= 111 +FirefoxAndroid >= 112 +iOS >= 16.4 \ No newline at end of file diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 1bf8e842..26675139 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -4,6 +4,10 @@ on: pull_request: types: [labeled] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: comment: if: contains(github.event.pull_request.labels.*.name, 'build') @@ -37,4 +41,4 @@ jobs: comment-id: ${{ needs.comment.outputs.comment_id }} body: | ### Download and install - 📦 [${{ needs.install.outputs.artifact_name }}.zip](${{ needs.install.outputs.artifact_url }}) + 📦 [${{ needs.install.outputs.artifact_name }}.${{ needs.install.outputs.version }}.zip](${{ needs.install.outputs.artifact_url }}) diff --git a/.travis.yml b/.travis.yml index 58f3c835..e2bcb26b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -55,5 +55,5 @@ script: - find . -maxdepth 1 \( -name '*.php' \) -exec php -lf {} \; - find src/php/ \( -name '*.php' \) -exec php -lf {} \; - # Run tests - - npm run test + # Run linters + - npm run lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 835f3dfb..99bd9f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [3.9.0] (2025-11-13) + +### Added +* Added contextual notices in the Snippets list table to surface action results and warnings in the UI +* Expanded Multisite Sharing settings for clearer control over network-wide snippet sharing + +### Changed +* Modernized browser support targets and polished admin UI (clearer row-action badges, improved Pro badge hover, refined active snippet name styling) + +### Fixed +* Fixed REST API pagination to return correct results and page counts +* Resolved styling selector so the active snippet name highlights reliably + +## [3.9.0-beta.2] (2025-11-10) + +### Added +* Added 'Snippets' row action to the Network Sites table +* Improved snippet name visibility for network users + +### Changed +* Refined badge styling and hover effects for row actions and badges +* Impoved icon style and color usage for network snippets for clearer differentiation + +### Fixed +* Improved network snippets management with better subsite menu permission checks +* Fixed status labels for shared network snippets +* Corrected network condition checks and improved snippet fetching logic +* Handled fatal errors in file-based snippets to prevent crashes + +## [3.9.0-beta.1] (2025-11-03) + +### Added +* Soft delete (Trash) functionality for snippets with ability to undo, restore or permanently delete. +* Bulk actions for trashing, restoring, and permanently deleting multiple snippets. +* Separate filtered view to manage trashed snippets. + ## [3.8.2] (2025-10-31) ### Fixed diff --git a/eslint.config.mjs b/eslint.config.mjs index 6a5cfefc..c66739dc 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,10 +30,10 @@ export default eslintTs.config( }, { languageOptions: { - ecmaVersion: 2018, + ecmaVersion: 2022, globals: { ...globals.browser }, parserOptions: { - ecmaVersion: 2018, + ecmaVersion: 2022, ecmaFeatures: { jsx: true }, tsconfigRootDir: import.meta.dirname, projectService: { allowDefaultProject: ['eslint.config.mjs'] } diff --git a/package-lock.json b/package-lock.json index 62733867..f9989b99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-snippets", - "version": "3.8.2", + "version": "3.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-snippets", - "version": "3.8.2", + "version": "3.9.0", "license": "GPL-2.0-or-later", "dependencies": { "@codemirror/fold": "^0.19.4", diff --git a/package.json b/package.json index 4fe72dda..590b003c 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,12 @@ "name": "code-snippets", "description": "Manage code snippets running on a WordPress-powered site through a graphical interface.", "homepage": "https://codesnippets.pro", - "version": "3.8.2", + "version": "3.9.0", "main": "src/dist/edit.js", "directories": { "test": "tests" }, "scripts": { - "test": "npm run stylelint && eslint && npm run phpcs", "test:playwright": "playwright test -c tests/playwright/playwright.config.ts", "test:playwright:debug": "npm run test:playwright -- --debug", "test:playwright:ui": "npm run test:playwright -- --ui", @@ -20,9 +19,13 @@ "build": "webpack", "watch": "webpack --watch", "bundle": "ts-node scripts/bundle.ts", - "phpcs": "src/vendor/bin/phpcs -s --colors ./src/phpcs.xml", - "phpcbf": "src/vendor/bin/phpcbf ./src/phpcs.xml", - "stylelint": "stylelint --fix 'src/css/**/*.scss'", + "lint": "npm run lint:styles && npm run lint:js && npm run lint:php", + "lint:styles": "stylelint 'src/css/**/*.scss'", + "lint:styles:fix": "stylelint --fix 'src/css/**/*.scss'", + "lint:js": "eslint", + "lint:js:fix": "eslint --fix", + "lint:php": "src/vendor/bin/phpcs -s --colors ./src/phpcs.xml", + "lint:php:fix": "src/vendor/bin/phpcbf ./src/phpcs.xml", "version": "ts-node scripts/version.ts", "version-dev": "npm version --git-tag-version=false --preid=dev", "version-alpha": "npm version --git-tag-version=false --preid=alpha", diff --git a/src/code-snippets.php b/src/code-snippets.php index 22c3778b..5c9cba12 100644 --- a/src/code-snippets.php +++ b/src/code-snippets.php @@ -8,11 +8,11 @@ * License: GPL-2.0-or-later * License URI: license.txt * Text Domain: code-snippets - * Version: 3.8.2 + * Version: 3.9.0 * Requires PHP: 7.4 * Requires at least: 5.0 * - * @version 3.8.2 + * @version 3.9.0 * @package Code_Snippets * @author Shea Bunge * @copyright 2012-2024 Code Snippets Pro @@ -37,7 +37,7 @@ * * @const string */ - define( 'CODE_SNIPPETS_VERSION', '3.8.2' ); + define( 'CODE_SNIPPETS_VERSION', '3.9.0' ); /** * The full path to the main file of this plugin. diff --git a/src/composer.lock b/src/composer.lock index 2914eafd..cd9b5b92 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -488,16 +488,16 @@ }, { "name": "phpcompatibility/phpcompatibility-paragonie", - "version": "1.3.3", + "version": "1.3.4", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", - "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac" + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/293975b465e0e709b571cbf0c957c6c0a7b9a2ac", - "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", "shasum": "" }, "require": { @@ -554,22 +554,26 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" } ], - "time": "2024-04-24T21:30:46+00:00" + "time": "2025-09-19T17:43:28+00:00" }, { "name": "phpcompatibility/phpcompatibility-wp", - "version": "2.1.7", + "version": "2.1.8", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", - "reference": "5bfbbfbabb3df2b9a83e601de9153e4a7111962c" + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/5bfbbfbabb3df2b9a83e601de9153e4a7111962c", - "reference": "5bfbbfbabb3df2b9a83e601de9153e4a7111962c", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", "shasum": "" }, "require": { @@ -631,26 +635,26 @@ "type": "thanks_dev" } ], - "time": "2025-05-12T16:38:37+00:00" + "time": "2025-10-18T00:05:59+00:00" }, { "name": "phpcsstandards/phpcsextra", - "version": "1.4.0", + "version": "1.4.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "fa4b8d051e278072928e32d817456a7fdb57b6ca" + "reference": "8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/fa4b8d051e278072928e32d817456a7fdb57b6ca", - "reference": "fa4b8d051e278072928e32d817456a7fdb57b6ca", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e", + "reference": "8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.0 || ^4.0" + "phpcsstandards/phpcsutils": "^1.1.2", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0", @@ -713,26 +717,26 @@ "type": "thanks_dev" } ], - "time": "2025-06-14T07:40:39+00:00" + "time": "2025-10-28T17:00:02+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.1.1", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd" + "reference": "8b8e17615d04f2fc2cd46fc1d2fd888fa21b3cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", - "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/8b8e17615d04f2fc2cd46fc1d2fd888fa21b3cf9", + "reference": "8b8e17615d04f2fc2cd46fc1d2fd888fa21b3cf9", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", "php": ">=5.4", - "squizlabs/php_codesniffer": "^3.13.0 || ^4.0" + "squizlabs/php_codesniffer": "^3.13.3 || ^4.0" }, "require-dev": { "ext-filter": "*", @@ -806,20 +810,20 @@ "type": "thanks_dev" } ], - "time": "2025-08-10T01:04:45+00:00" + "time": "2025-10-16T16:39:32+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.2", + "version": "3.13.5", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { @@ -836,11 +840,6 @@ "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -890,7 +889,7 @@ "type": "thanks_dev" } ], - "time": "2025-06-17T22:17:01+00:00" + "time": "2025-11-04T16:30:35+00:00" }, { "name": "wp-coding-standards/wpcs", diff --git a/src/css/common/_badges.scss b/src/css/common/_badges.scss index 6ebb3cde..4cfbe15f 100644 --- a/src/css/common/_badges.scss +++ b/src/css/common/_badges.scss @@ -20,6 +20,13 @@ gap: 5px; line-height: 1; + @at-root .row-actions & { + color: #8c8c8c; + padding-inline: 0px; + text-transform: capitalize; + font-weight: 500; + } + .dashicons { font-size: 18px; inline-size: 18px; @@ -27,6 +34,13 @@ } } +.network-shared { + color: #2271b1; + font-size: 22px; + width: 100%; + cursor: help; +} + .small-badge { block-size: auto; inline-size: auto; @@ -73,6 +87,33 @@ .inverted-badges .badge { color: #fff; background-color: #a7aaad; + border-color: #fff !important; + + .dashicons { + color: #fff; + } +} + +.nav-tab-inactive { + $colors: map.get(theme.$badges, 'pro'); + $text-color: list.nth($colors, 2); + $background-color: list.nth($colors, 1); + + .badge.pro-badge { + color: $text-color; + background-color: $background-color; + } + + &:hover { + &.button, .dashicons-external { + color: #3c434a; + } + + .badge.pro-badge { + color: $background-color; + background-color: $text-color; + } + } } .nav-tab-inactive { diff --git a/src/css/common/_switch.scss b/src/css/common/_switch.scss index 5e5d8269..0123a950 100644 --- a/src/css/common/_switch.scss +++ b/src/css/common/_switch.scss @@ -92,7 +92,7 @@ a.snippet-condition-count { &:hover { border-inline-start-color: theme.$accent; - transition: border-left-color 0.6s; + transition: border-inline-start-color 0.6s; &::before { border-color: theme.$accent; diff --git a/src/css/edit/_editor.scss b/src/css/edit/_editor.scss index 257f1628..8063c8f6 100644 --- a/src/css/edit/_editor.scss +++ b/src/css/edit/_editor.scss @@ -68,7 +68,7 @@ padding-inline-end: 0.5em; } - @media screen and (width >= 512px) { + @media (width >= 512px) { white-space: nowrap; } } diff --git a/src/css/manage.scss b/src/css/manage.scss index 0332fc59..0509be08 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -24,7 +24,7 @@ } } -.active-snippet .column-name > a { +.active-snippet .column-name > .snippet-name { font-weight: 600; } @@ -150,7 +150,7 @@ td.column-description { @include theme.link-colors(#579); } -@media screen and (width <= 782px) { +@media (width <= 782px) { p.search-box { float: inline-start; position: initial; @@ -205,7 +205,7 @@ td.column-description { } } -@media screen and (width <= 1190px) { +@media (width <= 1190px) { .nav-tab { .snippet-label { display: none; diff --git a/src/css/welcome.scss b/src/css/welcome.scss index cfceb7db..81c9872a 100644 --- a/src/css/welcome.scss +++ b/src/css/welcome.scss @@ -138,7 +138,7 @@ $breakpoint: 1060px; grid-template-columns: repeat(4, 1fr); gap: 40px 15px; - @media (max-width: $breakpoint) { + @media (width <= $breakpoint) { grid-template-columns: 1fr !important; } } diff --git a/src/js/components/EditorSidebar/actions/DeleteButton.tsx b/src/js/components/EditorSidebar/actions/DeleteButton.tsx index 8f5f7d3b..7d584e58 100644 --- a/src/js/components/EditorSidebar/actions/DeleteButton.tsx +++ b/src/js/components/EditorSidebar/actions/DeleteButton.tsx @@ -26,7 +26,7 @@ export const DeleteButton: React.FC = () => { setIsDialogOpen(false)} @@ -43,10 +43,9 @@ export const DeleteButton: React.FC = () => { }} >

- {__('You are about to permanently delete this snippet.', 'code-snippets')}{' '} + {__('You are about to delete this snippet.', 'code-snippets')}{' '} {__('Are you sure?', 'code-snippets')}

-

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

) diff --git a/src/js/components/EditorSidebar/controls/MultisiteSharingSettings.tsx b/src/js/components/EditorSidebar/controls/MultisiteSharingSettings.tsx index 27d6c57d..cefc8e81 100644 --- a/src/js/components/EditorSidebar/controls/MultisiteSharingSettings.tsx +++ b/src/js/components/EditorSidebar/controls/MultisiteSharingSettings.tsx @@ -1,39 +1,41 @@ import React from 'react' import { __ } from '@wordpress/i18n' import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { Tooltip } from '../../common/Tooltip' export const MultisiteSharingSettings: React.FC = () => { const { snippet, setSnippet, isReadOnly } = useSnippetForm() return ( -
+

- + {__('Share with Subsites', 'code-snippets')}

-
- - { - __('Instead of running on every site, allow this snippet to be activated on individual sites on the network.', 'code-snippets') - } -
+ + {__('Instead of running on every site, allow this snippet to be activated on individual sites on the network.', 'code-snippets')} + - - setSnippet(previous => ({ - ...previous, - active: false, - shared_network: event.target.checked - }))} - /> +
) } diff --git a/src/js/utils/snippets/api.ts b/src/js/utils/snippets/api.ts index e9072fd9..64bfb155 100644 --- a/src/js/utils/snippets/api.ts +++ b/src/js/utils/snippets/api.ts @@ -35,6 +35,7 @@ const mapToSchema = ({ priority, active, network, + shared_network, conditionId }: Partial): WritableSnippetSchema => ({ name, @@ -45,6 +46,7 @@ const mapToSchema = ({ priority, active, network, + shared_network, condition_id: conditionId }) diff --git a/src/php/class-admin.php b/src/php/class-admin.php index 70c293a9..dfdf093d 100644 --- a/src/php/class-admin.php +++ b/src/php/class-admin.php @@ -63,6 +63,7 @@ public function run() { add_action( 'init', array( $this, 'load_classes' ), 11 ); add_filter( 'mu_menu_items', array( $this, 'mu_menu_items' ) ); + add_filter( 'manage_sites_action_links', array( $this, 'add_sites_row_action' ), 10, 2 ); add_filter( 'plugin_action_links_' . plugin_basename( PLUGIN_FILE ), array( $this, 'plugin_action_links' ), 10, 2 ); add_filter( 'plugin_row_meta', array( $this, 'plugin_row_meta' ), 10, 2 ); add_filter( 'debug_information', array( $this, 'debug_information' ) ); @@ -89,6 +90,29 @@ public function mu_menu_items( array $menu_items ): array { return $menu_items; } + /** + * Add a "Snippets" row action to the Network Sites table. + * + * @param array $actions Existing row actions. + * @param int $site_id Current site ID. + * + * @return array + */ + public function add_sites_row_action( array $actions, int $site_id ): array { + if ( ! is_multisite() || ! current_user_can( code_snippets()->get_network_cap_name() ) ) { + return $actions; + } + + $menu_slug = code_snippets()->get_menu_slug(); + $actions['code_snippets'] = sprintf( + '%s', + esc_url( get_admin_url( $site_id, 'admin.php?page=' . $menu_slug ) ), + esc_html__( 'Snippets', 'code-snippets' ) + ); + + return $actions; + } + /** * Modify the action links for this plugin. * diff --git a/src/php/class-list-table.php b/src/php/class-list-table.php index 0044fde1..41ed1379 100644 --- a/src/php/class-list-table.php +++ b/src/php/class-list-table.php @@ -37,7 +37,7 @@ class List_Table extends WP_List_Table { * * @var array */ - public array $statuses = [ 'all', 'active', 'inactive', 'recently_activated' ]; + public array $statuses = [ 'all', 'active', 'inactive', 'recently_activated', 'shared_network', 'trashed' ]; /** * Column name to use when ordering the snippets list. @@ -246,7 +246,43 @@ public function get_action_link( string $action, Snippet $snippet ): string { private function get_snippet_action_links( Snippet $snippet ): array { $actions = array(); - if ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) { + if ( $snippet->shared_network && ! $this->is_network ) { + $actions['network_shared'] = sprintf( + '%s', + esc_html__( 'Network Snippet', 'code-snippets' ) + ); + + if ( is_multisite() && is_super_admin() ) { + $actions['edit'] = sprintf( + '%s', + esc_url( $this->get_action_link( 'edit', $snippet ) ), + esc_html__( 'Edit', 'code-snippets' ) + ); + } + + return apply_filters( 'code_snippets/list_table/row_actions', $actions, $snippet ); + } + + if ( $snippet->is_trashed() ) { + $actions['restore'] = sprintf( + '%s', + esc_url( $this->get_action_link( 'restore', $snippet ) ), + esc_html__( 'Restore', 'code-snippets' ) + ); + + $actions['delete_permanently'] = sprintf( + '%1$s', + esc_html__( 'Delete Permanently', 'code-snippets' ), + esc_url( $this->get_action_link( 'delete_permanently', $snippet ) ), + esc_js( + sprintf( + 'return confirm("%s");', + esc_html__( 'You are about to permanently delete the selected item.', 'code-snippets' ) . "\n" . + esc_html__( "'Cancel' to stop, 'OK' to delete.", 'code-snippets' ) + ) + ) + ); + } elseif ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) { // Display special links if on a subsite and dealing with a network-active snippet. if ( $snippet->active ) { $actions['network_active'] = esc_html__( 'Network Active', 'code-snippets' ); @@ -267,16 +303,9 @@ private function get_snippet_action_links( Snippet $snippet ): array { } $actions['delete'] = sprintf( - '%1$s', - esc_html__( 'Delete', 'code-snippets' ), - esc_url( $this->get_action_link( 'delete', $snippet ) ), - esc_js( - sprintf( - 'return confirm("%s");', - esc_html__( 'You are about to permanently delete the selected item.', 'code-snippets' ) . "\n" . - esc_html__( "'Cancel' to stop, 'OK' to delete.", 'code-snippets' ) - ) - ) + '%1$s', + esc_html__( 'Trash', 'code-snippets' ), + esc_url( $this->get_action_link( 'delete', $snippet ) ) ); } @@ -291,7 +320,18 @@ private function get_snippet_action_links( Snippet $snippet ): array { * @return string Output for activation switch. */ protected function column_activate( Snippet $snippet ): string { - if ( $this->is_network && ( $snippet->shared_network || ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) ) ) { + if ( $snippet->is_trashed() ) { + return ''; + } + + // Show icon for shared network snippets on network admin. + if ( $snippet->shared_network && $this->is_network ) { + return ''; + } + + if ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) { return ''; } @@ -351,18 +391,17 @@ protected function column_name( Snippet $snippet ): string { ); $out = esc_html( $snippet->display_name ); + $user_can_manage_network = current_user_can( code_snippets()->get_network_cap_name() ); - // Add a link to the snippet if it isn't an unreadable network-only snippet. - if ( $this->is_network || ! $snippet->network || current_user_can( code_snippets()->get_network_cap_name() ) ) { + // Add a link to the snippet if it isn't an unreadable network-only snippet and isn't trashed. + if ( ! $snippet->is_trashed() && ( $this->is_network || ! $snippet->network || $user_can_manage_network ) ) { $out = sprintf( '%s', esc_attr( code_snippets()->get_snippet_edit_url( $snippet->id, $snippet->network ? 'network' : 'admin' ) ), $out ); - } - - if ( $snippet->shared_network ) { - $out .= ' ' . esc_html__( 'Shared on Network', 'code-snippets' ) . ''; + } else { + $out = sprintf( '%s', $out ); } $out = apply_filters( 'code_snippets/list_table/column_name', $out, $snippet ); @@ -482,14 +521,23 @@ public function get_sortable_columns(): array { * @return array An array of menu items with the ID paired to the label */ public function get_bulk_actions(): array { - $actions = [ - 'activate-selected' => $this->is_network ? __( 'Network Activate', 'code-snippets' ) : __( 'Activate', 'code-snippets' ), - 'deactivate-selected' => $this->is_network ? __( 'Network Deactivate', 'code-snippets' ) : __( 'Deactivate', 'code-snippets' ), - 'clone-selected' => __( 'Clone', 'code-snippets' ), - 'download-selected' => __( 'Export Code', 'code-snippets' ), - 'export-selected' => __( 'Export', 'code-snippets' ), - 'delete-selected' => __( 'Delete', 'code-snippets' ), - ]; + global $status; + + if ( 'trashed' === $status ) { + $actions = [ + 'restore-selected' => __( 'Restore', 'code-snippets' ), + 'delete-permanently-selected' => __( 'Delete Permanently', 'code-snippets' ), + ]; + } else { + $actions = [ + 'activate-selected' => $this->is_network ? __( 'Network Activate', 'code-snippets' ) : __( 'Activate', 'code-snippets' ), + 'deactivate-selected' => $this->is_network ? __( 'Network Deactivate', 'code-snippets' ) : __( 'Deactivate', 'code-snippets' ), + 'clone-selected' => __( 'Clone', 'code-snippets' ), + 'download-selected' => __( 'Export Code', 'code-snippets' ), + 'export-selected' => __( 'Export', 'code-snippets' ), + 'delete-selected' => __( 'Move to Trash', 'code-snippets' ), + ]; + } return apply_filters( 'code_snippets/list_table/bulk_actions', $actions ); } @@ -520,52 +568,88 @@ public function get_views(): array { // Loop through the view counts. foreach ( $totals as $type => $count ) { - $labels = []; - if ( ! $count ) { continue; } - // translators: %s: total number of snippets. - $labels['all'] = _n( - 'All (%s)', - 'All (%s)', - $count, - 'code-snippets' - ); + switch ( $type ) { + case 'all': + // translators: %s: total number of snippets. + $template = _n( + 'All (%s)', + 'All (%s)', + $count, + 'code-snippets' + ); + break; + + case 'active': + // translators: %s: total number of active snippets. + $template = _n( + 'Active (%s)', + 'Active (%s)', + $count, + 'code-snippets' + ); + break; + + case 'inactive': + // translators: %s: total number of inactive snippets. + $template = _n( + 'Inactive (%s)', + 'Inactive (%s)', + $count, + 'code-snippets' + ); + break; + + case 'recently_activated': + // translators: %s: total number of recently activated snippets. + $template = _n( + 'Recently Active (%s)', + 'Recently Active (%s)', + $count, + 'code-snippets' + ); + break; - // translators: %s: total number of active snippets. - $labels['active'] = _n( - 'Active (%s)', - 'Active (%s)', - $count, - 'code-snippets' - ); + case 'shared_network': + if ( ! is_multisite() ) { + continue 2; + } - // translators: %s: total number of inactive snippets. - $labels['inactive'] = _n( - 'Inactive (%s)', - 'Inactive (%s)', - $count, - 'code-snippets' - ); + $shared_label_template = $this->is_network + ? _n_noop( + 'Shared with Subsites (%s)', + 'Shared with Subsites (%s)', + 'code-snippets' + ) + : _n_noop( + 'Network Snippets (%s)', + 'Network Snippets (%s)', + 'code-snippets' + ); + + $template = translate_nooped_plural( $shared_label_template, $count, 'code-snippets' ); + break; + + case 'trashed': + // translators: %s: total number of trashed snippets. + $template = _n( + 'Trashed (%s)', + 'Trashed (%s)', + $count, + 'code-snippets' + ); + break; - // translators: %s: total number of recently activated snippets. - $labels['recently_activated'] = _n( - 'Recently Active (%s)', - 'Recently Active (%s)', - $count, - 'code-snippets' - ); + default: + continue 2; + } - // The page URL with the status parameter. $url = esc_url( add_query_arg( 'status', $type ) ); - - // Add a class if this view is currently being viewed. $class = $type === $status ? ' class="current"' : ''; - - // Add the view count to the label. - $text = sprintf( $labels[ $type ], number_format_i18n( $count ) ); + $text = sprintf( $template, number_format_i18n( $count ) ); $status_links[ $type ] = sprintf( '%s', $url, $class, $text ); } @@ -737,9 +821,17 @@ private function perform_action( int $id, string $action ) { return 'cloned'; case 'delete': - delete_snippet( $id, $this->is_network ); + trash_snippet( $id, $this->is_network ); return 'deleted'; + case 'restore': + restore_snippet( $id, $this->is_network ); + return 'restored'; + + case 'delete_permanently': + delete_snippet( $id, $this->is_network ); + return 'deleted_permanently'; + case 'export': $export = new Export_Attachment( [ $id ], $this->is_network ); $export->download_snippets_json(); @@ -789,7 +881,28 @@ public function process_requested_actions() { $result = $this->perform_action( $id, sanitize_key( $_GET['action'] ) ); if ( $result ) { - wp_safe_redirect( esc_url_raw( add_query_arg( 'result', $result ) ) ); + $redirect_args = array( 'result' => $result ); + + if ( 'deleted' === $result ) { + $redirect_args['ids'] = $id; + } + + wp_safe_redirect( esc_url_raw( add_query_arg( $redirect_args ) ) ); + exit; + } + } + + if ( isset( $_GET['action'] ) && 'restore' === $_GET['action'] && isset( $_GET['ids'] ) ) { + $ids = array_map( 'intval', explode( ',', sanitize_text_field( $_GET['ids'] ) ) ); + + if ( ! empty( $ids ) ) { + check_admin_referer( 'bulk-' . $this->_args['plural'] ); + + foreach ( $ids as $id ) { + restore_snippet( $id, $this->is_network ); + } + + wp_safe_redirect( esc_url_raw( add_query_arg( 'result', 'restored' ) ) ); exit; } } @@ -860,14 +973,35 @@ public function process_requested_actions() { case 'delete-selected': foreach ( $ids as $id ) { - delete_snippet( $id, $this->is_network ); + trash_snippet( $id, $this->is_network ); } $result = 'deleted-multi'; break; + + case 'restore-selected': + foreach ( $ids as $id ) { + restore_snippet( $id, $this->is_network ); + } + $result = 'restored-multi'; + break; + + case 'delete-permanently-selected': + foreach ( $ids as $id ) { + delete_snippet( $id, $this->is_network ); + } + $result = 'deleted-permanently-multi'; + break; } if ( isset( $result ) ) { - wp_safe_redirect( esc_url_raw( add_query_arg( 'result', $result ) ) ); + $redirect_args = array( 'result' => $result ); + + // Add snippet IDs for undo functionality on bulk delete + if ( 'deleted-multi' === $result && ! empty( $ids ) ) { + $redirect_args['ids'] = implode( ',', $ids ); + } + + wp_safe_redirect( esc_url_raw( add_query_arg( $redirect_args ) ) ); exit; } } @@ -903,46 +1037,43 @@ public function no_items() { /** * Fetch all shared network snippets for the current site. * - * @return void + * @param array $all_snippets List of snippets to merge with. + * + * @return array Updated list of snippets. */ - private function fetch_shared_network_snippets() { - /** - * Table data. - * - * @var $snippets array - */ - global $snippets; + private function fetch_shared_network_snippets( array $all_snippets ): array { + if ( ! is_multisite() ) { + return $all_snippets; + } - $ids = get_site_option( 'shared_network_snippets' ); + $shared_ids = get_site_option( 'shared_network_snippets' ); - if ( ! is_multisite() || ! $ids ) { - return; + if ( ! $shared_ids || ! is_array( $shared_ids ) ) { + return $all_snippets; } if ( $this->is_network ) { - $limit = count( $snippets['all'] ); - - for ( $i = 0; $i < $limit; $i++ ) { - $snippet = &$snippets['all'][ $i ]; - - if ( in_array( $snippet->id, $ids, true ) ) { + // Mark shared network snippets on the network admin page. + foreach ( $all_snippets as $snippet ) { + if ( in_array( $snippet->id, $shared_ids, true ) ) { $snippet->shared_network = true; - $snippet->tags = array_merge( $snippet->tags, array( 'shared on network' ) ); $snippet->active = false; } } } else { + // Fetch shared network snippets for subsites. $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - $shared_snippets = get_snippets( $ids, true ); + $shared_snippets = get_snippets( $shared_ids, true ); foreach ( $shared_snippets as $snippet ) { $snippet->shared_network = true; - $snippet->tags = array_merge( $snippet->tags, array( 'shared on network' ) ); $snippet->active = in_array( $snippet->id, $active_shared_snippets, true ); } - $snippets['all'] = array_merge( $snippets['all'], $shared_snippets ); + $all_snippets = array_merge( $all_snippets, $shared_snippets ); } + + return $all_snippets; } /** @@ -978,8 +1109,17 @@ public function prepare_items() { $this->process_requested_actions(); $snippets = array_fill_keys( $this->statuses, array() ); - $snippets['all'] = apply_filters( 'code_snippets/list_table/get_snippets', get_snippets() ); - $this->fetch_shared_network_snippets(); + $all_snippets = apply_filters( 'code_snippets/list_table/get_snippets', $this->fetch_shared_network_snippets( get_snippets() ) ); + + // Separate trashed snippets from the main collection + $snippets['trashed'] = array_filter( $all_snippets, function( $snippet ) { + return $snippet->is_trashed(); + }); + + // Filter out trashed snippets from the 'all' collection + $snippets['all'] = array_filter( $all_snippets, function( $snippet ) { + return ! $snippet->is_trashed(); + }); foreach ( $snippets['all'] as $snippet ) { if ( $snippet->active ) { @@ -997,23 +1137,52 @@ function ( Snippet $snippet ) use ( $type ) { return $type === $snippet->type; } ); + + // Filter trashed snippets by type + $snippets['trashed'] = array_filter( + $snippets['trashed'], + function ( Snippet $snippet ) use ( $type ) { + return $type === $snippet->type; + } + ); } - // Add scope tags. + // Add scope tags to all snippets (including trashed). foreach ( $snippets['all'] as $snippet ) { if ( 'global' !== $snippet->scope ) { $snippet->add_tag( $snippet->scope ); } } + + foreach ( $snippets['trashed'] as $snippet ) { + if ( 'global' !== $snippet->scope ) { + $snippet->add_tag( $snippet->scope ); + } + } // Filter snippets by tag. if ( ! empty( $_GET['tag'] ) ) { $snippets['all'] = array_filter( $snippets['all'], array( $this, 'tags_filter_callback' ) ); + $snippets['trashed'] = array_filter( $snippets['trashed'], array( $this, 'tags_filter_callback' ) ); } // Filter snippets based on search query. if ( $s ) { $snippets['all'] = array_filter( $snippets['all'], array( $this, 'search_by_line_callback' ) ); + $snippets['trashed'] = array_filter( $snippets['trashed'], array( $this, 'search_by_line_callback' ) ); + } + + if ( is_multisite() ) { + $snippets['shared_network'] = array_values( + array_filter( + $snippets['all'], + static function ( Snippet $snippet ) { + return $snippet->shared_network; + } + ) + ); + } else { + $snippets['shared_network'] = array(); } // Clear recently activated snippets older than a week. @@ -1037,6 +1206,11 @@ function ( Snippet $snippet ) use ( $type ) { * @var Snippet $snippet */ foreach ( $snippets['all'] as $snippet ) { + // Skip trashed snippets (they're already in their own section) + if ( $snippet->is_trashed() ) { + continue; + } + if ( $snippet->active || $this->is_condition_active( $snippet ) ) { $snippets['active'][] = $snippet; } else { @@ -1310,7 +1484,6 @@ public function search_notice() { */ public function single_row( $item ) { $status = $item->active || $this->is_condition_active( $item ) ? 'active' : 'inactive'; - $row_class = "snippet $status-snippet $item->type-snippet $item->scope-scope"; if ( $item->shared_network ) { diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index a38e216e..c05613e2 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -309,25 +309,52 @@ public function get_network_cap_name(): string { return apply_filters( 'code_snippets_network_cap', 'manage_network_options' ); } + /** + * Determine if a subsite user menu is enabled via *Network Settings > Enable administration menus*. + * + * @return bool + */ + public function is_subsite_menu_enabled(): bool { + if ( ! is_multisite() ) { + return true; + } + + $menu_perms = get_site_option( 'menu_items', array() ); + return ! empty( $menu_perms['snippets'] ); + } + + /** + * Determine if the current user should have the network snippets capability. + * + * @return bool + */ + public function user_can_manage_network_snippets(): bool { + return is_super_admin() || current_user_can( $this->get_network_cap_name() ); + } + + /** + * Determine whether the current request originates in the network admin. + * + * @return bool + */ + public function is_network_context(): bool { + return is_network_admin(); + } + /** * Get the required capability to perform a certain action on snippets. * Does not check if the user has this capability or not. * - * If multisite, checks if *Enable Administration Menus: Snippets* is active - * under the *Settings > Network Settings* network admin menu + * If multisite, adjusts the capability based on whether the user is viewing + * the network dashboard or a subsite and whether the menu is enabled for subsites. * * @return string The capability required to manage snippets. * * @since 2.0 */ public function get_cap(): string { - if ( is_multisite() ) { - $menu_perms = get_site_option( 'menu_items', array() ); - - // If multisite is enabled and the snippet menu is not activated, restrict snippet operations to super admins only. - if ( empty( $menu_perms['snippets'] ) ) { - return $this->get_network_cap_name(); - } + if ( is_multisite() && $this->is_network_context() ) { + return $this->get_network_cap_name(); } return $this->get_cap_name(); diff --git a/src/php/class-snippet.php b/src/php/class-snippet.php index bd3269ff..dc3c6a95 100644 --- a/src/php/class-snippet.php +++ b/src/php/class-snippet.php @@ -50,12 +50,25 @@ class Snippet extends Data_Item { */ public const DEFAULT_DATE = '0000-00-00 00:00:00'; + /** + * Raw active value from database before processing. + * + * @var mixed + */ + private $raw_active_value; + /** * Constructor function. * * @param array|object $initial_data Initial snippet data. */ public function __construct( $initial_data = null ) { + if ( is_array( $initial_data ) && isset( $initial_data['active'] ) ) { + $this->raw_active_value = $initial_data['active']; + } elseif ( is_object( $initial_data ) && isset( $initial_data->active ) ) { + $this->raw_active_value = $initial_data->active; + } + $default_values = array( 'id' => 0, 'name' => '', @@ -101,6 +114,15 @@ public function is_condition(): bool { return 'condition' === $this->scope; } + /** + * Determine if the snippet is trashed (soft deleted). + * + * @return bool + */ + public function is_trashed(): bool { + return -1 === (int) $this->raw_active_value; + } + /** * Prepare a value before it is stored. * @@ -120,7 +142,7 @@ protected function prepare_field( $value, string $field ) { return code_snippets_build_tags_array( $value ); case 'active': - return ( is_bool( $value ) ? $value : (bool) $value ) && ! $this->is_condition(); + return ( is_bool( $value ) ? $value : (bool) $value ) && ! $this->is_condition() && (int) $value != -1; default: return $value; diff --git a/src/php/flat-files/classes/class-snippet-files.php b/src/php/flat-files/classes/class-snippet-files.php index 0f28f711..ae75156a 100644 --- a/src/php/flat-files/classes/class-snippet-files.php +++ b/src/php/flat-files/classes/class-snippet-files.php @@ -62,6 +62,7 @@ public function register_hooks(): void { add_action( 'code_snippets/create_snippet', [ $this, 'handle_snippet' ], 10, 2 ); add_action( 'code_snippets/update_snippet', [ $this, 'handle_snippet' ], 10, 2 ); add_action( 'code_snippets/delete_snippet', [ $this, 'delete_snippet' ], 10, 2 ); + add_action( 'code_snippets/trash_snippet', [ $this, 'delete_snippet' ], 10, 2 ); add_action( 'code_snippets/activate_snippet', [ $this, 'activate_snippet' ], 10, 1 ); add_action( 'code_snippets/deactivate_snippet', [ $this, 'deactivate_snippet' ], 10, 2 ); add_action( 'code_snippets/activate_snippets', [ $this, 'activate_snippets' ], 10, 2 ); diff --git a/src/php/rest-api/class-snippets-rest-controller.php b/src/php/rest-api/class-snippets-rest-controller.php index 6fe58ba9..bf37e60b 100644 --- a/src/php/rest-api/class-snippets-rest-controller.php +++ b/src/php/rest-api/class-snippets-rest-controller.php @@ -12,7 +12,7 @@ use function Code_Snippets\activate_snippet; use function Code_Snippets\code_snippets; use function Code_Snippets\deactivate_snippet; -use function Code_Snippets\delete_snippet; +use function Code_Snippets\trash_snippet; use function Code_Snippets\get_snippet; use function Code_Snippets\get_snippets; use function Code_Snippets\save_snippet; @@ -198,22 +198,26 @@ public function register_routes() { public function get_items( $request ): WP_REST_Response { $network = $request->get_param( 'network' ); $all_snippets = get_snippets( [], $network ); + $all_snippets = $this->get_network_items( $all_snippets, $network ); - // Get collection params (page, per_page). - $collection_params = $this->get_collection_params(); - $per_page_request = (int) $request->get_param( 'per_page' ); - $per_page = max( 1, $per_page_request ? $per_page_request : (int) $collection_params['per_page']['default'] ); - - $page_request = (int) $request->get_param( 'page' ); - $page = max( 1, $page_request ? $page_request : (int) $collection_params['page']['default'] ); - - // Count total items $total_items = count( $all_snippets ); - $total_pages = (int) ceil( $total_items / $per_page ); - - // Slice the full list to the requested page. - $offset = ( $page - 1 ) * $per_page; - $snippets = array_slice( $all_snippets, $offset, $per_page ); + $query_params = $request->get_query_params(); + + if ( isset( $query_params['per_page'] ) || isset( $query_params['page'] ) ) { + $collection_params = $this->get_collection_params(); + $per_page = isset( $query_params['per_page'] ) + ? max( 1, (int) $query_params['per_page'] ) + : (int) $collection_params['per_page']['default']; + $page_request = (int) $request->get_param( 'page' ); + $page = max( 1, $page_request ? $page_request : (int) $collection_params['page']['default'] ); + $total_pages = (int) ceil( $total_items / $per_page ); + + $offset = ( $page - 1 ) * $per_page; + $snippets = array_slice( $all_snippets, $offset, $per_page ); + } else { + $snippets = $all_snippets; + $total_pages = 1; + } $snippets_data = []; @@ -229,6 +233,36 @@ public function get_items( $request ): WP_REST_Response { return $response; } + /** + * Retrieve and merge shared network snippets. + * + * @param array $all_snippets List of snippets to merge with. + * @param bool|null $network Whether fetching network snippets. + * + * @return array Modified list of snippets. + */ + private function get_network_items( array $all_snippets, $network ): array { + if ( ! is_multisite() || $network ) { + return $all_snippets; + } + + $shared_ids = get_site_option( 'shared_network_snippets' ); + + if ( ! $shared_ids || ! is_array( $shared_ids ) ) { + return $all_snippets; + } + + $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); + $shared_snippets = get_snippets( $shared_ids, true ); + + foreach ( $shared_snippets as $snippet ) { + $snippet->shared_network = true; + $snippet->active = in_array( $snippet->id, $active_shared_snippets, true ); + } + + return array_merge( $all_snippets, $shared_snippets ); + } + /** * Retrieves one item from the collection. * @@ -307,7 +341,7 @@ public function update_item( $request ) { } /** - * Delete one item from the collection + * Delete one item from the collection (trash) * * @param WP_REST_Request $request Full data about the request. * @@ -315,7 +349,7 @@ public function update_item( $request ) { */ public function delete_item( $request ) { $item = $this->prepare_item_for_database( $request ); - $result = delete_snippet( $item->id, $item->network ); + $result = trash_snippet( $item->id, $item->network ); return $result ? new WP_REST_Response( null, 204 ) : diff --git a/src/php/settings/class-version-switch.php b/src/php/settings/class-version-switch.php index 7738147d..b2486e1c 100644 --- a/src/php/settings/class-version-switch.php +++ b/src/php/settings/class-version-switch.php @@ -348,7 +348,7 @@ public static function ajax_refresh_versions(): void { public static function render_version_switch_warning(): void { ?> -