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 a1743666..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 ); @@ -443,11 +444,10 @@ private function create_snippet_flat_files(): void { $db->set_table_vars(); $site_data = $db->fetch_active_snippets( $scopes ); - foreach ( $site_data as $table_name => $active_snippets ) { - foreach ( $active_snippets as $snippet ) { - $snippet_obj = get_snippet( $snippet['id'], false ); - $this->handle_snippet( $snippet_obj, $table_name ); - } + foreach ( $site_data as $snippet ) { + $table_name = $snippet['table']; + $snippet_obj = get_snippet( $snippet['id'], false ); + $this->handle_snippet( $snippet_obj, $table_name ); } restore_current_blog(); diff --git a/src/php/rest-api/class-snippets-rest-controller.php b/src/php/rest-api/class-snippets-rest-controller.php index 18a1f827..085dbed1 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; @@ -282,7 +282,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. * @@ -290,7 +290,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/snippet-ops.php b/src/php/snippet-ops.php index d23ffe36..d4114d89 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -451,6 +451,70 @@ function delete_snippet( int $id, ?bool $network = null ): bool { return (bool) $result; } +/** + * Trashes a snippet from the database. + * Write operation. + * + * @param int $id ID of the snippet to trash. + * @param bool|null $network Trash from network-wide (true) or site-wide (false) table. + * + * @return bool Whether the snippet was trashed successfully. + * + * @since 3.8.0 + */ +function trash_snippet( int $id, ?bool $network = null ): bool { + global $wpdb; + $network = DB::validate_network_param( $network ); + $table = code_snippets()->db->get_table_name( $network ); + + $snippet = get_snippet( $id, $network ); + + $result = $wpdb->update( + $table, + array( 'active' => '-1' ), + array( 'id' => $id ), + array( '%d' ) + ); + + if ( $result ) { + do_action( 'code_snippets/trash_snippet', $snippet, $network ); + clean_snippets_cache( $table ); + code_snippets()->cloud_api->delete_snippet_from_transient_data( $id ); + } + + return (bool) $result; +} + +/** + * Restore a trashed snippet by setting its active status back to 0 (inactive). + * Write operation. + * + * @param int $id Snippet ID to restore. + * @param bool|null $network Whether the snippet is multisite-wide (true) or site-wide (false). + * + * @return bool Whether the restore was successful. + * + * @since 3.8.0 + */ +function restore_snippet( int $id, ?bool $network = null ): bool { + global $wpdb; + $network = DB::validate_network_param( $network ); + $table = code_snippets()->db->get_table_name( $network ); + + $result = $wpdb->update( + $table, + array( 'active' => '0' ), + array( 'id' => $id ), + array( '%d' ) + ); + + if ( $result ) { + do_action( 'code_snippets/restore_snippet', $id, $network ); + clean_snippets_cache( $table ); + } + + return (bool) $result; +} /** * Test snippet code for errors, augmenting the snippet object. diff --git a/src/php/views/partials/list-table-notices.php b/src/php/views/partials/list-table-notices.php index 0b981f89..c3d34561 100644 --- a/src/php/views/partials/list-table-notices.php +++ b/src/php/views/partials/list-table-notices.php @@ -44,25 +44,65 @@ $result = sanitize_key( $_REQUEST['result'] ); -$result_messages = apply_filters( - 'code_snippets/manage/result_messages', - [ - 'executed' => __( 'Snippet executed.', 'code-snippets' ), - 'activated' => __( 'Snippet activated.', 'code-snippets' ), - 'activated-multi' => __( 'Selected snippets activated.', 'code-snippets' ), - 'deactivated' => __( 'Snippet deactivated.', 'code-snippets' ), - 'deactivated-multi' => __( 'Selected snippets deactivated.', 'code-snippets' ), - 'deleted' => __( 'Snippet deleted.', 'code-snippets' ), - 'deleted-multi' => __( 'Selected snippets deleted.', 'code-snippets' ), - 'cloned' => __( 'Snippet cloned.', 'code-snippets' ), - 'cloned-multi' => __( 'Selected snippets cloned.', 'code-snippets' ), - 'cloud-refreshed' => __( 'Synced cloud data has been successfully refreshed.', 'code-snippets' ), - ] -); +$result_messages = [ + 'executed' => __( 'Snippet executed.', 'code-snippets' ), + 'activated' => __( 'Snippet activated.', 'code-snippets' ), + 'activated-multi' => __( 'Selected snippets activated.', 'code-snippets' ), + 'deactivated' => __( 'Snippet deactivated.', 'code-snippets' ), + 'deactivated-multi' => __( 'Selected snippets deactivated.', 'code-snippets' ), + 'deleted' => __( 'Snippet trashed.', 'code-snippets' ), + 'deleted-multi' => __( 'Selected snippets trashed.', 'code-snippets' ), + 'deleted_permanently' => __( 'Snippet permanently deleted.', 'code-snippets' ), + 'deleted-permanently-multi' => __( 'Selected snippets permanently deleted.', 'code-snippets' ), + 'restored' => __( 'Snippet restored.', 'code-snippets' ), + 'restored-multi' => __( 'Selected snippets restored.', 'code-snippets' ), + 'cloned' => __( 'Snippet cloned.', 'code-snippets' ), + 'cloned-multi' => __( 'Selected snippets cloned.', 'code-snippets' ), + 'cloud-refreshed' => __( 'Synced cloud data has been successfully refreshed.', 'code-snippets' ), +]; + +// Add undo link for single snippet trash action +if ( 'deleted' === $result && ! empty( $_REQUEST['ids'] ) ) { + $deleted_ids = sanitize_text_field( $_REQUEST['ids'] ); + $undo_url = wp_nonce_url( + add_query_arg( array( + 'action' => 'restore', + 'ids' => $deleted_ids + ) ), + 'bulk-snippets' + ); + + $result_messages['deleted'] = sprintf( + __( 'Snippet trashed. Undo', 'code-snippets' ), + esc_url( $undo_url ) + ); +} + +// Add undo link for bulk snippet trash action +if ( 'deleted-multi' === $result && ! empty( $_REQUEST['ids'] ) ) { + $deleted_ids = sanitize_text_field( $_REQUEST['ids'] ); + $undo_url = wp_nonce_url( + add_query_arg( array( + 'action' => 'restore', + 'ids' => $deleted_ids + ) ), + 'bulk-snippets' + ); + + $result_messages['deleted-multi'] = sprintf( + __( 'Selected snippets trashed. Undo', 'code-snippets' ), + esc_url( $undo_url ) + ); +} + +$result_messages = apply_filters( 'code_snippets/manage/result_messages', $result_messages ); if ( isset( $result_messages[ $result ] ) ) { $result_kses = [ 'strong' => [], + 'a' => [ + 'href' => [], + ], ]; printf(