diff --git a/CHANGELOG.md b/CHANGELOG.md index 835f3dfb..38f67002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog + +## [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..99945c97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-snippets", - "version": "3.8.2", + "version": "3.9.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-snippets", - "version": "3.8.2", + "version": "3.9.0-beta.1", "license": "GPL-2.0-or-later", "dependencies": { "@codemirror/fold": "^0.19.4", diff --git a/package.json b/package.json index 4fe72dda..7d34e093 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "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-beta.1", "main": "src/dist/edit.js", "directories": { "test": "tests" diff --git a/src/code-snippets.php b/src/code-snippets.php index 22c3778b..6958ee5b 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-beta.1 * Requires PHP: 7.4 * Requires at least: 5.0 * - * @version 3.8.2 + * @version 3.9.0-beta.1 * @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-beta.1' ); /** * The full path to the main file of this plugin. 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/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/php/class-list-table.php b/src/php/class-list-table.php index 0044fde1..703765cf 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', 'trashed' ]; /** * Column name to use when ordering the snippets list. @@ -246,7 +246,26 @@ 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->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 +286,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,6 +303,10 @@ private function get_snippet_action_links( Snippet $snippet ): array { * @return string Output for activation switch. */ protected function column_activate( Snippet $snippet ): string { + if ( $snippet->is_trashed() ) { + return ''; + } + if ( $this->is_network && ( $snippet->shared_network || ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) ) ) { return ''; } @@ -352,8 +368,8 @@ protected function column_name( Snippet $snippet ): string { $out = esc_html( $snippet->display_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 || current_user_can( code_snippets()->get_network_cap_name() ) ) ) { $out = sprintf( '%s', esc_attr( code_snippets()->get_snippet_edit_url( $snippet->id, $snippet->network ? 'network' : 'admin' ) ), @@ -482,14 +498,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 ); } @@ -558,6 +583,14 @@ public function get_views(): array { 'code-snippets' ); + // translators: %s: total number of trashed snippets. + $labels['trashed'] = _n( + 'Trashed (%s)', + 'Trashed (%s)', + $count, + 'code-snippets' + ); + // The page URL with the status parameter. $url = esc_url( add_query_arg( 'status', $type ) ); @@ -737,9 +770,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 +830,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 +922,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; } } @@ -978,9 +1061,19 @@ 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() ); + $all_snippets = apply_filters( 'code_snippets/list_table/get_snippets', get_snippets() ); $this->fetch_shared_network_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 ) { $this->active_by_condition[ $snippet->condition_id ][] = $snippet; @@ -997,23 +1090,39 @@ 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' ) ); } // Clear recently activated snippets older than a week. @@ -1037,6 +1146,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 +1424,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-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..4027d46b 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; @@ -307,7 +307,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 +315,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 { ?> -