From cbc909a590fb89c86bc4b305ef198168e2daf6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B4me=20Bakker?= Date: Mon, 11 Mar 2024 13:47:38 +0100 Subject: [PATCH] feat(core): added temporary bin --------- Co-authored-by: Finn Thomas Co-authored-by: SirDisappointing Co-authored-by: Yana Lazareva <526830@student.saxion.nl> Co-authored-by: Jumanah <506741@student.saxion.nl> Co-authored-by: bako <510944@student.saxion.nl> Co-authored-by: 493611 <493611@student.saxion.nl> Co-authored-by: finndthomas <94404156+finndthomas@users.noreply.github.com> Co-authored-by: SirDisappointing <496485@student.saxion.nl> --- actions/admin/site/settings.php | 8 + actions/admin/user/bulk/delete.php | 2 +- actions/admin/user/delete.php | 4 +- actions/entity/chooserestoredestination.php | 60 +++ actions/entity/delete.php | 9 +- actions/entity/restore.php | 41 ++ actions/entity/trash.php | 94 ++++ docs/appendix/upgrade-notes/5.x-to-6.0.rst | 1 + docs/design/database.rst | 4 + docs/tutorials/index.rst | 3 +- docs/tutorials/temporary_bin.rst | 505 ++++++++++++++++++ engine/actions.php | 3 + .../Elgg/Application/SystemEventHandlers.php | 1 + engine/classes/Elgg/Config.php | 4 + .../Database/Clauses/AccessWhereClause.php | 13 + .../Clauses/AnnotationWhereClause.php | 1 + .../Database/Clauses/EntityWhereClause.php | 12 + engine/classes/Elgg/Database/EntityTable.php | 201 +++++-- engine/classes/Elgg/Database/Metadata.php | 3 - engine/classes/Elgg/Database/Seeder.php | 4 +- .../Entity/RemoveDeletedEntitiesHandler.php | 74 +++ engine/classes/Elgg/EventsService.php | 4 +- engine/classes/Elgg/Export/Entity.php | 12 +- engine/classes/Elgg/Invoker.php | 26 +- engine/classes/Elgg/Menus/Entity.php | 118 +++- engine/classes/Elgg/Menus/Page.php | 11 + engine/classes/Elgg/Menus/Social.php | 4 + engine/classes/Elgg/SessionManagerService.php | 27 + engine/classes/Elgg/UpgradeService.php | 4 +- engine/classes/Elgg/Users/Accounts.php | 59 +- engine/classes/ElggComment.php | 10 +- engine/classes/ElggEntity.php | 167 +++++- engine/classes/ElggExtender.php | 20 +- engine/classes/ElggFile.php | 23 +- engine/classes/ElggPlugin.php | 2 +- engine/classes/ElggSite.php | 32 +- engine/classes/ElggUser.php | 6 +- engine/events.php | 5 + engine/lib/constants.php | 2 + engine/lib/elgglib.php | 4 + engine/lib/entities.php | 47 +- engine/routes.php | 16 + ..._add_delete_columns_to_entities_tables.php | 46 ++ .../Elgg/Mocks/Database/EntityTable.php | 54 +- .../Elgg/Integration/ElggCoreObjectTest.php | 4 + .../ElggCoreRegressionBugsTest.php | 4 +- .../Clauses/AccessWhereClauseUnitTest.php | 10 + .../Clauses/AnnotationWhereClauseUnitTest.php | 4 + .../Clauses/EntityWhereClauseUnitTest.php | 11 +- .../Elgg/Database/EntityTableUnitTest.php | 47 ++ .../unit/Elgg/InvokerServiceUnitTest.php | 142 +++++ .../Elgg/SessionManagerServiceUnitTest.php | 8 + .../tests/phpunit/unit/ElggEntityUnitTest.php | 5 + .../tests/phpunit/unit/ElggObjectUnitTest.php | 2 + .../tests/phpunit/unit/ElggPluginUnitTest.php | 2 + languages/en.php | 44 ++ mod/blog/elgg-plugin.php | 1 + mod/bookmarks/elgg-plugin.php | 1 + .../developers/entity_explorer_delete.php | 4 +- .../Elgg/Developers/Menus/EntityExplorer.php | 22 +- .../admin/develop_tools/entity_explorer.php | 2 +- .../entity_explorer/attributes.php | 8 +- .../admin/develop_tools/inspect/seeders.php | 2 +- mod/file/elgg-plugin.php | 1 + .../classes/Elgg/Groups/Menus/Entity.php | 6 +- mod/groups/classes/Elgg/Groups/Menus/Page.php | 11 + mod/groups/elgg-plugin.php | 1 + mod/groups/lib/functions.php | 4 +- mod/likes/classes/Elgg/Likes/Menus/Social.php | 4 + mod/messages/classes/Elgg/Messages/User.php | 11 +- mod/pages/classes/Elgg/Pages/Menus/Entity.php | 4 + mod/pages/classes/ElggPage.php | 4 +- mod/pages/elgg-plugin.php | 1 + .../Elgg/ReportedContent/Menus/Entity.php | 4 + .../classes/Elgg/SiteNotifications/Cron.php | 8 +- .../classes/Elgg/TheWire/Menus/Entity.php | 6 +- mod/web_services/classes/ElggApiKey.php | 4 +- .../default/admin/statistics/numentities.php | 61 ++- views/default/admin/upgrades.php | 2 +- views/default/admin/upgrades/finished.php | 2 +- .../core/settings/statistics/numentities.php | 16 +- views/default/forms/admin/site/settings.php | 1 + .../forms/admin/site/settings/comments.php | 55 ++ .../forms/admin/site/settings/content.php | 64 +-- .../forms/entity/chooserestoredestination.php | 81 +++ views/default/resources/trash/container.php | 20 + views/default/resources/trash/owner.php | 24 + views/default/trash/elements/imprint.php | 20 + .../default/trash/elements/imprint/actor.php | 43 ++ .../default/trash/elements/imprint/byline.php | 57 ++ .../trash/elements/imprint/contents.php | 35 ++ .../trash/elements/imprint/element.php | 26 + views/default/trash/elements/imprint/time.php | 39 ++ views/default/trash/elements/imprint/type.php | 40 ++ views/default/trash/elements/metadata.php | 33 ++ views/default/trash/elements/notice.php | 13 + views/default/trash/elements/subtitle.php | 22 + views/default/trash/elements/title.php | 27 + views/default/trash/entity.php | 26 + views/default/trash/entity/default.php | 30 ++ views/default/trash/listing/all.php | 40 ++ views/default/trash/listing/container.php | 22 + views/default/trash/listing/owner.php | 22 + 103 files changed, 2676 insertions(+), 283 deletions(-) create mode 100644 actions/entity/chooserestoredestination.php create mode 100644 actions/entity/restore.php create mode 100644 actions/entity/trash.php create mode 100644 docs/tutorials/temporary_bin.rst create mode 100644 engine/classes/Elgg/Entity/RemoveDeletedEntitiesHandler.php create mode 100644 engine/schema/migrations/20230606155735_add_delete_columns_to_entities_tables.php create mode 100644 engine/tests/phpunit/unit/Elgg/InvokerServiceUnitTest.php create mode 100644 views/default/forms/admin/site/settings/comments.php create mode 100644 views/default/forms/entity/chooserestoredestination.php create mode 100644 views/default/resources/trash/container.php create mode 100644 views/default/resources/trash/owner.php create mode 100644 views/default/trash/elements/imprint.php create mode 100644 views/default/trash/elements/imprint/actor.php create mode 100644 views/default/trash/elements/imprint/byline.php create mode 100644 views/default/trash/elements/imprint/contents.php create mode 100644 views/default/trash/elements/imprint/element.php create mode 100644 views/default/trash/elements/imprint/time.php create mode 100644 views/default/trash/elements/imprint/type.php create mode 100644 views/default/trash/elements/metadata.php create mode 100644 views/default/trash/elements/notice.php create mode 100644 views/default/trash/elements/subtitle.php create mode 100644 views/default/trash/elements/title.php create mode 100644 views/default/trash/entity.php create mode 100644 views/default/trash/entity/default.php create mode 100644 views/default/trash/listing/all.php create mode 100644 views/default/trash/listing/container.php create mode 100644 views/default/trash/listing/owner.php diff --git a/actions/admin/site/settings.php b/actions/admin/site/settings.php index ccfe6361477..0c4e81c58d7 100644 --- a/actions/admin/site/settings.php +++ b/actions/admin/site/settings.php @@ -76,6 +76,14 @@ elgg_save_config('pagination_behaviour', get_input('pagination_behaviour', 'ajax-replace')); elgg_save_config('mentions_display_format', get_input('mentions_display_format')); +$trash_retention = (int) get_input('trash_retention', 30); +if ($trash_retention < 0) { + $trash_retention = 30; +} + +elgg_save_config('trash_retention', $trash_retention); +elgg_save_config('trash_enabled', (bool) get_input('trash_enabled')); + elgg_save_config('user_joined_river', get_input('user_joined_river') === 'on'); elgg_save_config('can_change_username', get_input('can_change_username') === 'on'); diff --git a/actions/admin/user/bulk/delete.php b/actions/admin/user/bulk/delete.php index 08b3ffdd65a..961d39391bc 100644 --- a/actions/admin/user/bulk/delete.php +++ b/actions/admin/user/bulk/delete.php @@ -8,7 +8,7 @@ return elgg_error_response(elgg_echo('error:missing_data')); } -elgg_call(ELGG_SHOW_DISABLED_ENTITIES, function() use ($user_guids) { +elgg_call(ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($user_guids) { foreach ($user_guids as $user_guid) { $user = get_user($user_guid); if (empty($user)) { diff --git a/actions/admin/user/delete.php b/actions/admin/user/delete.php index d34051892e4..8bea01b5403 100644 --- a/actions/admin/user/delete.php +++ b/actions/admin/user/delete.php @@ -12,7 +12,7 @@ return elgg_error_response(elgg_echo('admin:user:self:delete:no')); } -$user = elgg_call(ELGG_SHOW_DISABLED_ENTITIES, function() use ($guid) { +$user = elgg_call(ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($guid) { return get_user($guid); }); if (!$user || !$user->canDelete()) { @@ -22,7 +22,7 @@ $name = $user->getDisplayName(); $username = $user->username; -$deleted = elgg_call(ELGG_SHOW_DISABLED_ENTITIES, function() use ($user) { +$deleted = elgg_call(ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($user) { return $user->delete(); }); if (!$deleted) { diff --git a/actions/entity/chooserestoredestination.php b/actions/entity/chooserestoredestination.php new file mode 100644 index 00000000000..8479e703896 --- /dev/null +++ b/actions/entity/chooserestoredestination.php @@ -0,0 +1,60 @@ +deleted !== 'yes') { + return elgg_error_response(elgg_echo('entity:restore:item_not_found')); +} + +$new_container = get_entity($destination_container_guid[0]); +if (!$new_container instanceof \ElggEntity || !$new_container->canWriteToContainer(0, $entity->type, $entity->subtype)) { + return elgg_error_response(elgg_echo('actionunauthorized')); +} + +// determine what name to show on success +$display_name = $entity->getDisplayName() ?: elgg_echo('entity:restore:item'); + +if (!$entity->restore()) { + return elgg_error_response(elgg_echo('entity:restore:fail', [$display_name])); +} + +$entity->container_guid = $new_container->guid; +if (!$entity->save()) { + return elgg_error_response(elgg_echo('entity:restore:fail', [$display_name])); +} + +$success_keys = [ + "entity:restore:{$entity->type}:{$entity->subtype}:success", + "entity:restore:{$entity->type}:success", + 'entity:restore:success', +]; + +$message = ''; +if (get_input('show_success', true)) { + foreach ($success_keys as $success_key) { + if (elgg_language_key_exists($success_key)) { + $message = elgg_echo($success_key, [$display_name]); + break; + } + } +} + +return elgg_ok_response('', $message); diff --git a/actions/entity/delete.php b/actions/entity/delete.php index 136b1d351f4..cf97d1e7af0 100644 --- a/actions/entity/delete.php +++ b/actions/entity/delete.php @@ -4,8 +4,9 @@ */ $guid = (int) get_input('guid'); - -$entity = get_entity($guid); +$entity = elgg_call(ELGG_SHOW_DELETED_ENTITIES, function() use ($guid) { + return get_entity($guid); +}); if (!$entity instanceof \ElggEntity) { return elgg_error_response(elgg_echo('entity:delete:item_not_found')); } @@ -14,8 +15,6 @@ return elgg_error_response(elgg_echo('entity:delete:permission_denied')); } -set_time_limit(0); - // determine what name to show on success $display_name = $entity->getDisplayName() ?: elgg_echo('entity:delete:item'); @@ -23,7 +22,7 @@ $subtype = $entity->getSubtype(); $container = $entity->getContainerEntity(); -if (!$entity->delete()) { +if (!$entity->delete(true, true)) { return elgg_error_response(elgg_echo('entity:delete:fail', [$display_name])); } diff --git a/actions/entity/restore.php b/actions/entity/restore.php new file mode 100644 index 00000000000..1babdd086b7 --- /dev/null +++ b/actions/entity/restore.php @@ -0,0 +1,41 @@ +deleted !== 'yes') { + return elgg_error_response(elgg_echo('entity:restore:item_not_found')); +} + +if (!$entity->canEdit()) { + return elgg_error_response(elgg_echo('actionunauthorized')); +} + +// determine what name to show on success +$display_name = $entity->getDisplayName() ?: elgg_echo('entity:restore:item'); + +if (!$entity->restore()) { + return elgg_error_response(elgg_echo('entity:restore:fail', [$display_name])); +} + +$success_keys = [ + "entity:restore:{$entity->type}:{$entity->subtype}:success", + "entity:restore:{$entity->type}:success", + 'entity:restore:success', +]; + +$message = ''; +if (get_input('show_success', true)) { + foreach ($success_keys as $success_key) { + if (elgg_language_key_exists($success_key)) { + $message = elgg_echo($success_key, [$display_name]); + break; + } + } +} + +return elgg_ok_response('', $message); diff --git a/actions/entity/trash.php b/actions/entity/trash.php new file mode 100644 index 00000000000..e708e83462b --- /dev/null +++ b/actions/entity/trash.php @@ -0,0 +1,94 @@ +canDelete() || !$entity->hasCapability('restorable') || $entity instanceof \ElggPlugin || $entity instanceof \ElggSite || $entity instanceof \ElggUser) { + return elgg_error_response(elgg_echo('entity:delete:permission_denied')); +} + +// determine what name to show on success +$display_name = $entity->getDisplayName() ?: elgg_echo('entity:delete:item'); + +$type = $entity->getType(); +$subtype = $entity->getSubtype(); +$container = $entity->getContainerEntity(); + +if (!$entity->delete(true, false)) { + return elgg_error_response(elgg_echo('entity:delete:fail', [$display_name])); +} + +// determine forward URL +$forward_url = get_input('forward_url'); +if (!empty($forward_url)) { + $forward_url = elgg_normalize_site_url((string) $forward_url); +} + +if (empty($forward_url)) { + $forward_url = REFERRER; + $referrer_url = elgg_extract('HTTP_REFERER', $_SERVER, ''); + $site_url = elgg_get_site_url(); + + $find_forward_url = function (\ElggEntity $container = null) use ($type, $subtype) { + $routes = _elgg_services()->routes; + + // check if there is a collection route (eg. blog/owner/username) + $route_name = false; + if ($container instanceof \ElggUser) { + $route_name = "collection:{$type}:{$subtype}:owner"; + } elseif ($container instanceof \ElggGroup) { + $route_name = "collection:{$type}:{$subtype}:group"; + } + + if ($route_name && $routes->get($route_name)) { + $params = $routes->resolveRouteParameters($route_name, $container); + + return elgg_generate_url($route_name, $params); + } + + // no route found, fallback to container url + if ($container instanceof \ElggEntity) { + return $container->getURL(); + } + + // no container + return ''; + }; + + if (!empty($referrer_url) && elgg_strpos($referrer_url, $site_url) === 0) { + // referer is on current site + $referrer_path = elgg_substr($referrer_url, elgg_strlen($site_url)); + $segments = explode('/', $referrer_path); + + if (in_array($guid, $segments)) { + // referrer URL contains a reference to the entity that will be deleted + $forward_url = $find_forward_url($container); + } + } elseif ($container instanceof \ElggEntity) { + $forward_url = $find_forward_url($container); + } +} + +$success_keys = [ + "entity:delete:{$type}:{$subtype}:success", + "entity:delete:{$type}:success", + 'entity:delete:success', +]; + +$message = ''; +if (get_input('show_success', true)) { + foreach ($success_keys as $success_key) { + if (elgg_language_key_exists($success_key)) { + $message = elgg_echo($success_key, [$display_name]); + break; + } + } +} + +return elgg_ok_response('', $message, $forward_url); diff --git a/docs/appendix/upgrade-notes/5.x-to-6.0.rst b/docs/appendix/upgrade-notes/5.x-to-6.0.rst index 274cf4aea57..bb79b16be47 100644 --- a/docs/appendix/upgrade-notes/5.x-to-6.0.rst +++ b/docs/appendix/upgrade-notes/5.x-to-6.0.rst @@ -120,6 +120,7 @@ Removed class functions Lib functions function parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* ``elgg_get_entity_statistics()`` now requires an ``array`` of ``$options`` to be used by ``elgg_get_entities()``. * ``elgg_get_simplecache_url()`` has the second argument (``$subview``) removed. The full ``$view`` name needs to be provided as the first argument. Miscellaneous API changes diff --git a/docs/design/database.rst b/docs/design/database.rst index f8db326cc5e..c19b9da5846 100644 --- a/docs/design/database.rst +++ b/docs/design/database.rst @@ -700,6 +700,10 @@ It contains the following fields: - **enabled** If this is 'yes' an entity is accessible, if 'no' the entity has been disabled (Elgg treats it as if it were deleted without actually removing it from the database) +- **deleted** If this is 'yes' an entity is marked for deletion, + if 'no' (default) the entity is visible within the regular site. + If the bin plugin is enabled, soft deleted content is stored in the Temporary Bin. +- **time\_deleted** Unix timestamp of when the entity was soft deleted. Table: metadata ~~~~~~~~~~~~~~~ diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index 9301abb37de..fb53e712ee7 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -12,4 +12,5 @@ The instructions are detailed enough that you don't need much previous experienc indexpage blog wysiwyg - widget \ No newline at end of file + widget + temporary_bin \ No newline at end of file diff --git a/docs/tutorials/temporary_bin.rst b/docs/tutorials/temporary_bin.rst new file mode 100644 index 00000000000..3f1befbea98 --- /dev/null +++ b/docs/tutorials/temporary_bin.rst @@ -0,0 +1,505 @@ +Temporary Bin +############# + +The Temporary Bin temporarily stores content that has been deleted from a site. +When an entity with the restorable capability is deleted, it is marked as soft deleted in the database and hidden from view. + +.. contents:: Contents + :local: + :depth: 1 + +Database changes +---------------- + +To accommodate the temporary bin, new columns have been added to the Elgg database. +The Entities and the Annotations tables have been given two extra columns: + +- **soft\_deleted** If this is 'yes' an entity is marked for soft deletion, + if 'no' (default) the entity is visible within the regular site. + If the bin plugin is enabled, soft deleted content is stored in the Temporary Bin. +- **time\_soft\_deleted** Unix timestamp of when the entity was soft deleted. + +Columns have been added in the migration file ``/engine/schema/migrations/20230606155735_add_columns_to_entities_and_annotations_tables.php``. + +Defining an entity as soft deletable +------------------------------------ + +Entities can be eligible for soft deletion by giving them the relevant capability. +The entities with restorable capability are as follows: + + - group + - blog + - bookmarks + - file + - page + +This can be set ``/mod/{entity}/elgg-plugin.php``. Like in Blog, in the following example: + +.. code-block:: php + + [ + [ + 'type' => 'object', + 'subtype' => 'blog', + 'class' => 'ElggBlog', + 'capabilities' => [ + 'commentable' => true, + 'searchable' => true, + 'likable' => true, + 'restorable' => true + ], + ], + ] + ] + +Giving an entity the restorable capability will reroute its ``delete()`` action +to the ``softDelete()`` action in ``/actions/entity/delete.php``. + +.. code-block:: php + + deleted === 'no' && $entity->hasCapability('restorable')) { + if (!$entity->softDelete($deleter_guid)) { + return elgg_error_response(elgg_echo('entity:delete:fail', [$display_name])); + } + } else { + if (!$entity->delete($non_recursive_delete)) { + return elgg_error_response(elgg_echo('entity:delete:fail', [$display_name])); + } + } + +This will check to see if an entity can and is not already soft deleted. If so, it will set the **soft\_deleted** column +in the database to 'yes' and update the value of **time\_soft\_deleted** to a Unix timestamp of the current time. + +Otherwise it will delete the entity with a ``delete()`` action, either recursively or non-recursively. + +Soft Delete +----------- + +The ``softDelete()`` method of ``/engine/classes/ElggEntity.php/`` is responsible for the actual database update of the +**soft\_deleted** and **time\_soft\_deleted** columns. These are set to 'yes' and to a current Unix timestamp, respectively. + +.. code-block:: php + + guid; + + if ($recursive) { + elgg_call(ELGG_IGNORE_ACCESS | ELGG_HIDE_DISABLED_ENTITIES, function () use ($deleter_guid, $guid) { + $base_options = [ + 'wheres' => [ + function(QueryBuilder $qb, $main_alias) use ($guid) { + return $qb->compare("{$main_alias}.guid", '!=', $guid, ELGG_VALUE_GUID); + }, + ], + 'limit' => false, + 'batch' => true, + 'batch_inc_offset' => false, + ]; + + foreach (['owner_guid', 'container_guid'] as $db_column) { + $options = $base_options; + $options[$db_column] = $guid; + + $subentities = elgg_get_entities($options); + /* @var $subentity \ElggEntity */ + foreach ($subentities as $subentity) { + $subentity->addRelationship($guid, 'deleted_with'); + get_entity($deleter_guid)->addRelationship($subentity->guid, 'deleted_by'); + $subentity->softDelete($deleter_guid, true); + } + } + }); + } + + get_entity($deleter_guid)->addRelationship($this->guid, 'deleted_by'); + + $this->disableAnnotations(); + + $deleted = _elgg_services()->entityTable->softDelete($this); + + $this->updateTimeDeleted(); + + if ($deleted) { + $this->invalidateCache(); + + $this->attributes['deleted'] = 'yes'; + + _elgg_services()->events->triggerAfter('soft_delete', $this->type, $this); + } + + return $deleted; + +If ``$recurvise`` is true, base options for retrieving subentities linked to the entity are setup. +Iterations over the columns 'owner_guid' and 'container_guid' are done and ``elgg_get_entities()`` +is called to find linked subentities to the current entity based on the options set. For each found subentity, +``deleted_with`` and ``deleted_by`` relationships to the current entity and logged in user are added. +The **deleted** and **time\_deleted** values of linked subentities and the entity itself are then updated +and the ``deleted`` attribute set. + +Temporary Bin page +------------------ + +The Temporary Bin page is populated by soft deleted content of which the logged in user is the owner. + +To display content on the Temporary Bin page, the page fetches a list of all entities that have the relationship of +'deleted_by' attached to the current user + +.. code-block:: php + + $list_params = [ + 'relationship' => 'deleted_by', + 'type_subtype_pairs' => elgg_entity_types_with_capability('restorable'), + 'inverse_relationship' => false, + 'no_results' => true + ]; + + if (!elgg_is_admin_logged_in()) { + $list_params['owner_guid'] = elgg_get_logged_in_user_guid(); + } + + $content = elgg_call(ELGG_SHOW_DELETED_ENTITIES, function () use ($list_params) { + return elgg_list_entities($list_params); + }); + +This call will fetch all existing entities that are soft deleted and should be viewable in the temporary bin page. + +.. code-block:: php + + echo elgg_view_page( + elgg_echo('collection:object:bin'), + elgg_view_layout('admin', [ + 'title' => elgg_echo('collection:object:bin'), + 'content' => $content, + 'filter_id' => 'admin', + ]) + ); + +The content will then be passed through ``elgg_view_page()`` to display the content properly on the page + +There are several actions that can be done by the user to restore or permanently delete content. +These actions are defined by whether the entity is a group or not. +These actions are created in the generic ``/engine/classes/Elgg/Menus/Entity.php`` class + +- Restore: this action is created for every entity and for every entity which their container is not soft deleted + +.. code-block:: php + + if ($container->deleted !== 'yes') { + $return[] = \ElggMenuItem::factory([ + 'name' => 'restore', + 'icon' => 'settings', + 'text' => elgg_echo('Restore'), + 'title' => elgg_echo('restore:this'), + 'href' => elgg_generate_action_url('entity/restore', [ + 'deleter_guid' => elgg_get_logged_in_user_guid(), + 'guid' => $entity->guid, + ]), + 'confirm' => elgg_echo('restoreconfirm'), + 'priority' => 900, + ]); + } + +- Delete: the basic action for every entity. this uses the default delete action to permanently delete entities + +.. code-block:: php + + $return[] = \ElggMenuItem::factory([ + 'name' => 'delete', + 'icon' => 'delete', + 'text' => elgg_echo('Delete'), + 'title' => elgg_echo('delete:this'), + 'href' => elgg_generate_action_url('entity/delete', [ + 'deleter_guid' => elgg_get_logged_in_user_guid(), + 'guid' => $entity->guid, + ]), + 'confirm' => elgg_echo('deleteconfirm'), + 'priority' => 950, + ]); + +- Restore and Move: specifically for entities that belong to a group (either active or deleted) + +This option is always there for group owned entities, but is forced whenever the owning group is soft deleted + +.. code-block:: php + + if (!$container instanceof \ElggUser) { + $return[] = \ElggMenuItem::factory([ + 'name' => 'restore and move', + 'icon' => 'arrow-up', + 'text' => elgg_echo('Restore and Move'), + 'title' => elgg_echo('restore:this'), + 'href' => elgg_http_add_url_query_elements('ajax/form/entity/chooserestoredestination', [ + 'address' => $entity->getURL(), + 'title' => $entity->getDisplayName(), + 'entity_guid' => $entity->guid, + 'deleter_guid' => elgg_get_logged_in_user_guid(), + 'entity_owner_guid' => $entity->owner_guid, + ]), + 'link_class' => 'elgg-lightbox', // !! + 'priority' => 800, + ]); + } + +- Restore Non-Recursively: to restore a group but still leave the owned content soft deleted + +.. code-block:: php + + if ($entity instanceof \ElggGroup) { + $return[] = \ElggMenuItem::factory([ + 'name' => 'restore non-recursive', + 'icon' => 'arrow-up', + 'text' => elgg_echo('Restore Non-Recursively'), + 'title' => elgg_echo('restore:this'), + 'href' => elgg_generate_action_url('entity/restore', [ + 'deleter_guid' => elgg_get_logged_in_user_guid(), + 'guid' => $entity->guid, + 'recursive' => false + ]), + 'confirm' => elgg_echo('restoreconfirm'), + 'priority' => 800, + ]); + } + +Restore / Restore Non-Recursively +--------------------------------- + +Clicking the restore button on an entity in the temporary bin will invoke ``/actions/entity/restore.php``. + +.. code-block:: php + + deleted === 'yes') { + if (!$entity->restore($recursive)) { + return elgg_error_response(elgg_echo('entity:restore:fail', [$display_name])); + } + } + +If ``deleted`` confirms that the entity is deleted, the entity will be restored either recursively or non-recursively. +The ``restore()`` function of ``\ElggEntity`` is then called. + +The ``restore()`` function is responsible for the resetting the **deleted** and **time\_deleted** database +columns to 'no' and '0', respectively. + +.. code-block:: php + + entityTable->restore($this); + + $this->enableAnnotations(); + + if ($recursive) { + $deleted_with_it = elgg_get_entities([ + 'relationship' => 'deleted_with', + 'relationship_guid' => $this->guid, + 'inverse_relationship' => true, + 'limit' => false, + 'batch' => true, + 'batch_inc_offset' => false, + ]); + + foreach ($deleted_with_it as $e) { + $e->restore($recursive); + $e->removeRelationship($this->guid, 'deleted_with'); + $e->removeAllRelationships('deleted_by', true); + } + } + + return $result; + }); + + $this->removeAllRelationships('deleted_by', true); + + if ($result) { + $this->attributes['deleted'] = 'no'; + _elgg_services()->events->triggerAfter('restore', $this->type, $this); + } + + return $result; + +The ``restore($this)`` of the ``entityTable`` updates the **deleted** and **time\_deleted** database +values for the current entity. If ``$recursive`` is true, entities with a ``deleted_with`` relationship +to the current entity are also called and restored. +Relationships ``deleted_with`` and ``deleted_by``are then removed and attributes reset. + +Restore and Move +---------------- + +Clicking the restore-and-move button on an entity in the temporary bin will call the form ``views/default/forms/entity/chooserestoredestination.php``. +This form is registered as an Ajax view within ``engine/classes/Elgg/Application/SystemEventHandlers.php``: + +.. code-block:: php + + 'assign back to creator']; + +If the user is an admin, he will have the rights to all active groups. If the user is a mere user, he will have the rights +to only the groups that he had joined. + +.. code-block:: php + + 'group', + 'inverse_relationship' => false, + 'sort_by' => [ + 'property' => 'name', + 'direction' => 'ASC', + ], + 'no_results' => elgg_echo('groups:none'), + ]); + } else { + $deleted_groups = elgg_get_entities([ + 'type' => 'group', + 'relationship' => 'member', + 'relationship_guid' => elgg_get_logged_in_user_guid(), + 'inverse_relationship' => false, + 'sort_by' => [ + 'property' => 'name', + 'direction' => 'ASC', + ], + 'no_results' => elgg_echo('groups:none'), + ]); + } + +This options, when appended together, will be displayed on the form and saved as ``destination_container_guid``. +Also passed in the form are GUIDs of the entity and the deleter. + +.. code-block:: php + + 'select', + '#label' => elgg_echo('Destination group'), + 'required' => true, + 'name' => 'destination_container_guid', + 'options_values' => $destination_container_names, + ], + [ + '#type' => 'hidden', + 'name' => 'entity_guid', + 'value' => $entity_guid, + ], + [ + '#type' => 'hidden', + 'name' => 'deleter_guid', + 'value' => $deleter_guid, + ], + ]; + +When the user clicks 'Confirm', the form forwards its variables to the corresponding restore-and-move action at +``actions/entity/chooserestoredestination.php``. The action reads these variables using ``get_input`` function: + +.. code-block:: php + + restore(false)) { + return elgg_error_response(elgg_echo('entity:restore:fail', [$display_name])); + } + + if (!$entity->overrideEntityContainerID($destination_container_guid)) { + return elgg_error_response(elgg_echo('entity:restore:fail', [$display_name])); + } + +Delete +------ + +Clicking the delete action on an entity from the temporary bin will invoke the ``/actions/entity/delete.php`` action. +As discussed in the 'Defining an entity as soft deletable section', a check is done to see +if the entity is soft deleted. As it always will be when the action is called from the temporary bin, the ``delete()`` method +of ``/engine/classes/ElggEntity.php/`` will be called. Since this is a core Elgg feature it will not be further elaborated on here. +Entities are then permanently deleted from the database. + +Automated Cron Clean-up / Retention Period +------------------------------------------ + +In ``/engine/events.php``, the cron tasks for the Elgg system are defined. Added to the daily tasks is a call which cleans up +aged soft deleted content, older than an admin defined period. + +.. code-block:: php + + [ + 'daily' => [ + \Elgg\Entity\RemoveDeletedEntitiesHandler::class => [], + ], + ] + +This invokes ``\Elgg\Database\RemoveDeletedEntitiesHandler`` which contains the cleanup logic. + +.. code-block:: php + + elgg_entity_types_with_capability('restorable'), + 'limit' => false, + 'wheres' => [ + function(QueryBuilder $qb, $main_alias) { + return $qb->compare("{$main_alias}.deleted", '=', 'yes', ELGG_VALUE_STRING); + }, + function(QueryBuilder $qb, $main_alias) { + $grace_period = elgg_get_config('bin_cleanup_grace_period',30); + return $qb->compare("{$main_alias}.time_deleted", '<', \Elgg\Values::normalizeTimestamp('-'.$grace_period.' days')); + } + ], + ]); + }); + + foreach ($entities as $entity) { + $entity->delete(); + } + +An ``elgg_call()`` is performed to retrieve all entities which have the ``restorable`` capability, and which +have a **soft\_deleted** value of 'yes' and **time\_soft\_deleted** Unix value which is aged more than retention (grace) period. +These entities are then deleted from the database. + +The retention period can be edited from the administrators Site Settings page. It is saved as a config setting in the elgg_config table. +It has a default value of 30 days. +This is done in ``actions/admin/site/settings.php`` + +.. code-block:: php + + $bin_cleanup_grace_period = get_input('bin_cleanup_grace_period', 30); + if ($bin_cleanup_grace_period === '') { + $bin_cleanup_grace_period = 30; + } + + elgg_save_config('bin_cleanup_grace_period', (int) $bin_cleanup_grace_period); diff --git a/engine/actions.php b/engine/actions.php index f7b17e9bd75..a7765814991 100644 --- a/engine/actions.php +++ b/engine/actions.php @@ -39,9 +39,12 @@ 'avatar/upload' => [], 'comment/save' => [], 'diagnostics/download' => ['access' => 'admin'], + 'entity/chooserestoredestination' => [], 'entity/delete' => [], 'entity/mute' => [], + 'entity/restore' => [], 'entity/subscribe' => [], + 'entity/trash' => [], 'entity/unmute' => [], 'entity/unsubscribe' => [], 'login' => ['access' => 'logged_out'], diff --git a/engine/classes/Elgg/Application/SystemEventHandlers.php b/engine/classes/Elgg/Application/SystemEventHandlers.php index 400391b1b40..12568cc28b7 100644 --- a/engine/classes/Elgg/Application/SystemEventHandlers.php +++ b/engine/classes/Elgg/Application/SystemEventHandlers.php @@ -50,6 +50,7 @@ public static function init() { elgg_register_ajax_view('admin/users/listing/details'); elgg_register_ajax_view('core/ajax/edit_comment'); elgg_register_ajax_view('forms/admin/user/change_email'); + elgg_register_ajax_view('forms/entity/chooserestoredestination'); elgg_register_ajax_view('navigation/menu/user_hover/contents'); elgg_register_ajax_view('notifications/subscriptions/details'); elgg_register_ajax_view('object/plugin/details'); diff --git a/engine/classes/Elgg/Config.php b/engine/classes/Elgg/Config.php index 0a2d90d780c..16a8a40dbb5 100644 --- a/engine/classes/Elgg/Config.php +++ b/engine/classes/Elgg/Config.php @@ -123,6 +123,8 @@ * @property bool $system_cache_enabled Is the system cache enabled? * @property bool $testing_mode Is the current application running (PHPUnit) tests * @property string $time_format Preferred PHP time format + * @property bool $trash_enabled Is the trash feature enabled + * @property int $trash_retention Number of days before trashed content is removed from the database * @property bool $user_joined_river Do we need to create a river event when a user joins the site * @property string $view Default viewtype (usually not set) * @property bool $walled_garden Is current site in walled garden mode? @@ -235,6 +237,8 @@ class Config { 'subresource_integrity_enabled' => false, 'system_cache_enabled' => false, 'testing_mode' => false, + 'trash_enabled' => false, + 'trash_retention' => 30, 'user_joined_river' => false, 'webp_enabled' => true, 'who_can_change_language' => 'everyone', diff --git a/engine/classes/Elgg/Database/Clauses/AccessWhereClause.php b/engine/classes/Elgg/Database/Clauses/AccessWhereClause.php index f28f45306a4..7187b80c79b 100644 --- a/engine/classes/Elgg/Database/Clauses/AccessWhereClause.php +++ b/engine/classes/Elgg/Database/Clauses/AccessWhereClause.php @@ -17,9 +17,13 @@ class AccessWhereClause extends WhereClause { public string $enabled_column = 'enabled'; + public string $deleted_column = 'deleted'; + public ?bool $ignore_access = null; public ?bool $use_enabled_clause = null; + + public ?bool $show_deleted = null; public ?int $viewer_guid = null; @@ -43,6 +47,10 @@ public function prepare(QueryBuilder $qb, $table_alias = null) { $this->use_enabled_clause = !_elgg_services()->session_manager->getDisabledEntityVisibility(); } + if (!isset($this->show_deleted)) { + $this->show_deleted = !_elgg_services()->session_manager->getDeletedEntityVisibility(); + } + $ors = []; $ands = []; @@ -63,6 +71,10 @@ public function prepare(QueryBuilder $qb, $table_alias = null) { $ands[] = $qb->compare($alias($this->enabled_column), '=', 'yes', ELGG_VALUE_STRING); } + if ($this->show_deleted) { + $ands[] = $qb->compare($alias($this->deleted_column), '=', 'no', ELGG_VALUE_STRING); + } + $params = [ 'table_alias' => $table_alias, 'user_guid' => $this->viewer_guid, @@ -72,6 +84,7 @@ public function prepare(QueryBuilder $qb, $table_alias = null) { 'owner_guid_column' => $this->owner_guid_column, 'guid_column' => $this->guid_column, 'enabled_column' => $this->enabled_column, + 'deleted_column' => $this->deleted_column, 'query_builder' => $qb, ]; diff --git a/engine/classes/Elgg/Database/Clauses/AnnotationWhereClause.php b/engine/classes/Elgg/Database/Clauses/AnnotationWhereClause.php index c612eecdb43..c414d56b2dd 100644 --- a/engine/classes/Elgg/Database/Clauses/AnnotationWhereClause.php +++ b/engine/classes/Elgg/Database/Clauses/AnnotationWhereClause.php @@ -78,6 +78,7 @@ public function prepare(QueryBuilder $qb, $table_alias = null) { $access = new AccessWhereClause(); $access->ignore_access = $this->ignore_access; + $access->show_deleted = false; $access->use_enabled_clause = false; $access->viewer_guid = $this->viewer_guid; $access->guid_column = 'entity_guid'; diff --git a/engine/classes/Elgg/Database/Clauses/EntityWhereClause.php b/engine/classes/Elgg/Database/Clauses/EntityWhereClause.php index de9b8c91e0e..dbfa8fc48a3 100644 --- a/engine/classes/Elgg/Database/Clauses/EntityWhereClause.php +++ b/engine/classes/Elgg/Database/Clauses/EntityWhereClause.php @@ -70,6 +70,11 @@ class EntityWhereClause extends WhereClause { */ public $enabled; + /** + * @var string + */ + public $deleted; + /** * @var bool */ @@ -80,6 +85,11 @@ class EntityWhereClause extends WhereClause { */ public $use_enabled_clause; + /** + * @var bool + */ + public $show_deleted; + /** * @var int */ @@ -99,6 +109,7 @@ public function prepare(QueryBuilder $qb, $table_alias = '') { $access = new AccessWhereClause(); $access->use_enabled_clause = $this->use_enabled_clause; + $access->show_deleted = $this->show_deleted; $access->ignore_access = $this->ignore_access; $access->viewer_guid = $this->viewer_guid; $wheres[] = $access->prepare($qb, $table_alias); @@ -114,6 +125,7 @@ public function prepare(QueryBuilder $qb, $table_alias = '') { $wheres[] = $qb->between($alias('time_updated'), $this->updated_after, $this->updated_before, ELGG_VALUE_TIMESTAMP); $wheres[] = $qb->between($alias('last_action'), $this->last_action_after, $this->last_action_before, ELGG_VALUE_TIMESTAMP); $wheres[] = $qb->compare($alias('enabled'), '=', $this->enabled, ELGG_VALUE_STRING); + $wheres[] = $qb->compare($alias('deleted'), '=', $this->deleted, ELGG_VALUE_STRING); $wheres[] = $qb->compare($alias('access_id'), '=', $this->access_ids, ELGG_VALUE_ID); return $qb->merge($wheres); diff --git a/engine/classes/Elgg/Database/EntityTable.php b/engine/classes/Elgg/Database/EntityTable.php index 77c19664338..dcad2ee083c 100644 --- a/engine/classes/Elgg/Database/EntityTable.php +++ b/engine/classes/Elgg/Database/EntityTable.php @@ -53,6 +53,8 @@ class EntityTable { protected array $deleted_guids = []; + protected array $trashed_guids = []; + protected array $entity_classes = []; /** @@ -272,7 +274,7 @@ public function getFromCache(int $guid): ?\ElggEntity { * @return void */ public function invalidateCache(int $guid): void { - elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($guid) { + elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($guid) { $entity = $this->get($guid); if ($entity instanceof \ElggEntity) { $entity->invalidateCache(); @@ -340,7 +342,7 @@ public function get(int $guid, string $type = null, string $subtype = null): ?\E * @return bool */ public function exists(int $guid): bool { - return elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($guid) { + return elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($guid) { // need to ignore access and show hidden entities to check existence return !empty($this->getRow($guid)); }); @@ -388,6 +390,28 @@ public function fetch(QueryBuilder $query, array $options = []): array { return $results; } + /** + * Update the time_deleted column in the entities table for $entity. + * + * @param \ElggEntity $entity Entity to update + * @param int $deleted Timestamp of soft delete + * + * @return int + */ + public function updateTimeDeleted(\ElggEntity $entity, int $deleted = null): int { + if ($deleted === null) { + $deleted = $this->getCurrentTime()->getTimestamp(); + } + + $update = Update::table(self::TABLE_NAME); + $update->set('time_deleted', $update->param($deleted, ELGG_VALUE_TIMESTAMP)) + ->where($update->compare('guid', '=', $entity->guid, ELGG_VALUE_GUID)); + + $this->db->updateData($update); + + return (int) $deleted; + } + /** * Update the last_action column in the entities table for $entity. * @@ -426,7 +450,7 @@ public function getUserForPermissionsCheck(int $guid = null): ?\ElggUser { return $this->session_manager->getLoggedInUser(); } - $user = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($guid) { + $user = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($guid) { // need to ignore access and show hidden entities for potential hidden/disabled users return $this->get($guid, 'user'); }); @@ -442,6 +466,22 @@ public function getUserForPermissionsCheck(int $guid = null): ?\ElggUser { return $user; } + /** + * Restore entity + * + * @param \ElggEntity $entity Entity to restore + * + * @return bool + */ + public function restore(\ElggEntity $entity): bool { + $qb = Update::table(self::TABLE_NAME); + $qb->set('deleted', $qb->param('no', ELGG_VALUE_STRING)) + ->set('time_deleted', $qb->param(0, ELGG_VALUE_TIMESTAMP)) + ->where($qb->compare('guid', '=', $entity->guid, ELGG_VALUE_GUID)); + + return $this->db->updateData($qb); + } + /** * Enables entity * @@ -481,39 +521,95 @@ public function disable(\ElggEntity $entity): bool { * @return bool */ public function delete(\ElggEntity $entity, bool $recursive = true): bool { - $guid = $entity->guid; - if (!$guid) { + if (!$entity->guid) { return false; } - - if (!$this->events->triggerBefore('delete', $entity->type, $entity)) { + + set_time_limit(0); + + return $this->events->triggerSequence('delete', $entity->type, $entity, function(\ElggEntity $entity) use ($recursive) { + if ($entity instanceof \ElggUser) { + // ban to prevent using the site during delete + $entity->ban(); + } + + // we're going to delete this entity, log the guid to prevent deadloops + $this->deleted_guids[] = $entity->guid; + + if ($recursive) { + $this->deleteRelatedEntities($entity); + } + + $this->deleteEntityProperties($entity); + + $qb = Delete::fromTable(self::TABLE_NAME); + $qb->where($qb->compare('guid', '=', $entity->guid, ELGG_VALUE_GUID)); + + return (bool) $this->db->deleteData($qb); + }); + } + + /** + * Trash an entity (not quite delete but close) + * + * @param \ElggEntity $entity Entity + * @param bool $recursive Trash all owned and contained entities + * + * @return bool + */ + public function trash(\ElggEntity $entity, bool $recursive = true): bool { + if (!$entity->guid) { return false; } - $this->events->trigger('delete', $entity->type, $entity); - - if ($entity instanceof \ElggUser) { - // ban to prevent using the site during delete - $entity->ban(); + if (!$this->config->trash_enabled) { + return $this->delete($entity, $recursive); } - - // we're going to delete this entity, log the guid to prevent deadloops - $this->deleted_guids[] = $entity->guid; - if ($recursive) { - $this->deleteRelatedEntities($entity); + if ($entity->deleted === 'yes') { + // already trashed + return true; } - - $this->deleteEntityProperties($entity); - - $qb = Delete::fromTable(self::TABLE_NAME); - $qb->where($qb->compare('guid', '=', $guid, ELGG_VALUE_GUID)); - - $this->db->deleteData($qb); - - $this->events->triggerAfter('delete', $entity->type, $entity); - - return true; + + return $this->events->triggerSequence('trash', $entity->type, $entity, function(\ElggEntity $entity) use ($recursive) { + $unban_after = false; + if ($entity instanceof \ElggUser && !$entity->isBanned()) { + // temporarily ban to prevent using the site during disable + $entity->ban(); + $unban_after = true; + } + + $this->trashed_guids[] = $entity->guid; + + if ($recursive) { + set_time_limit(0); + + $this->trashRelatedEntities($entity); + } + + $deleter_guid = elgg_get_logged_in_user_guid(); + if (!empty($deleter_guid)) { + $entity->addRelationship($deleter_guid, 'delete_by'); + } + + $qb = Update::table(self::TABLE_NAME); + $qb->set('deleted', $qb->param('yes', ELGG_VALUE_STRING)) + ->where($qb->compare('guid', '=', $entity->guid, ELGG_VALUE_GUID)); + + $trashed = $this->db->updateData($qb); + + $entity->updateTimeDeleted(); + + if ($unban_after) { + $entity->unban(); + } + + if ($trashed) { + $entity->invalidateCache(); + } + + return $trashed; + }); } /** @@ -525,7 +621,7 @@ public function delete(\ElggEntity $entity, bool $recursive = true): bool { */ protected function deleteRelatedEntities(\ElggEntity $entity): void { // Temporarily overriding access controls - elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($entity) { + elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($entity) { /* @var $batch \ElggBatch */ $batch = elgg_get_entities([ 'wheres' => function (QueryBuilder $qb, $main_alias) use ($entity) { @@ -559,6 +655,51 @@ protected function deleteRelatedEntities(\ElggEntity $entity): void { }); } + /** + * Trash entities owned or contained by the entity being trashed + * + * @param \ElggEntity $entity Entity + * + * @return void + */ + protected function trashRelatedEntities(\ElggEntity $entity): void { + // Temporarily overriding access controls + elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($entity) { + /* @var $batch \ElggBatch */ + $batch = elgg_get_entities([ + 'wheres' => function (QueryBuilder $qb, $main_alias) use ($entity) { + $ors = $qb->merge([ + $qb->compare("{$main_alias}.owner_guid", '=', $entity->guid, ELGG_VALUE_GUID), + $qb->compare("{$main_alias}.container_guid", '=', $entity->guid, ELGG_VALUE_GUID), + ], 'OR'); + + return $qb->merge([ + $ors, + $qb->compare("{$main_alias}.guid", 'neq', $entity->guid, ELGG_VALUE_GUID), + ]); + }, + 'limit' => false, + 'batch' => true, + 'batch_inc_offset' => false, + ]); + + /* @var $e \ElggEntity */ + foreach ($batch as $e) { + if (in_array($e->guid, $this->trashed_guids)) { + // prevent deadloops, doing this here in case of large deletes which could cause query length issues + $batch->reportFailure(); + continue; + } + + if (!$this->trash($e, true)) { + $batch->reportFailure(); + } + + $e->addRelationship($entity->guid, 'deleted_with'); + } + }); + } + /** * Clear data from secondary tables * @@ -568,7 +709,7 @@ protected function deleteRelatedEntities(\ElggEntity $entity): void { */ protected function deleteEntityProperties(\ElggEntity $entity): void { // Temporarily overriding access controls and disable system_log to save performance - elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($entity) { + elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($entity) { $entity->removeAllRelatedRiverItems(); $entity->deleteOwnedAccessCollections(); $entity->deleteAccessCollectionMemberships(); diff --git a/engine/classes/Elgg/Database/Metadata.php b/engine/classes/Elgg/Database/Metadata.php index ee2d8b8575a..a8eae0a0098 100644 --- a/engine/classes/Elgg/Database/Metadata.php +++ b/engine/classes/Elgg/Database/Metadata.php @@ -66,9 +66,6 @@ public function calculate($function, $property, $property_type = null) { throw new DomainException("'{$property}' is not a valid attribute"); } - /** - * @todo When no entity constraints are present, do we need to ensure that entity access clause is added? - */ $alias = $qb->joinEntitiesTable($qb->getTableAlias(), 'entity_guid', 'inner', 'e'); $qb->addSelect("{$function}({$alias}.{$property}) AS calculation"); break; diff --git a/engine/classes/Elgg/Database/Seeder.php b/engine/classes/Elgg/Database/Seeder.php index 9492f616538..9fe289f10e8 100644 --- a/engine/classes/Elgg/Database/Seeder.php +++ b/engine/classes/Elgg/Database/Seeder.php @@ -60,7 +60,7 @@ public function __construct( * @return void */ public function seed(array $options = []): void { - $this->invoker->call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($options) { + $this->invoker->call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($options) { $defaults = [ 'limit' => null, 'image_folder' => elgg_get_config('seeder_local_image_folder'), @@ -131,7 +131,7 @@ public function seed(array $options = []): void { * @return void */ public function unseed(array $options = []): void { - $this->invoker->call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($options) { + $this->invoker->call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($options) { $defaults = [ 'type' => '', ]; diff --git a/engine/classes/Elgg/Entity/RemoveDeletedEntitiesHandler.php b/engine/classes/Elgg/Entity/RemoveDeletedEntitiesHandler.php new file mode 100644 index 00000000000..ec5e8072f7f --- /dev/null +++ b/engine/classes/Elgg/Entity/RemoveDeletedEntitiesHandler.php @@ -0,0 +1,74 @@ + false, + 'batch' => true, + 'batch_inc_offset' => false, + 'wheres' => [ + function(QueryBuilder $qb, $main_alias) { + // only deleted items + return $qb->compare("{$main_alias}.deleted", '=', 'yes', ELGG_VALUE_STRING); + }, + function(QueryBuilder $qb, $main_alias) use ($retention) { + // past the retention period + return $qb->compare("{$main_alias}.time_deleted", '<', \Elgg\Values::normalizeTimestamp("-{$retention} days"), ELGG_VALUE_TIMESTAMP); + }, + function(QueryBuilder $qb, $main_alias) { + // get only the root deleted items (not the related/sub items) + // the related items will be deleted with the root item + $sub = $qb->subquery(RelationshipsTable::TABLE_NAME); + $sub->select('guid_one') + ->where($qb->compare('relationship', '=', 'deleted_with')); + + return $qb->compare("{$main_alias}.guid", 'not in', $sub->getSQL()); + } + ], + 'sort_by' => [ + 'property' => 'time_deleted', + 'direction' => 'ASC', + ], + ]); + + // this could take a while + set_time_limit(0); + $starttime = microtime(true); + + /* @var $entity \ElggEntity */ + foreach ($entities as $entity) { + if ((microtime(true) - $starttime) > 300) { + // limit the cleanup to 5 minutes per hour + break; + } + + if (!$entity->delete(true, true)) { + $entities->reportFailure(); + } + } + }); + } +} diff --git a/engine/classes/Elgg/EventsService.php b/engine/classes/Elgg/EventsService.php index 574c9003e35..2fd381ce105 100644 --- a/engine/classes/Elgg/EventsService.php +++ b/engine/classes/Elgg/EventsService.php @@ -216,7 +216,9 @@ public function triggerSequence(string $name, string $type, $object = null, call $result = call_user_func($callable, $object); } - $this->triggerAfter($name, $type, $object, $options); + if ($result) { + $this->triggerAfter($name, $type, $object, $options); + } return $result; } diff --git a/engine/classes/Elgg/Export/Entity.php b/engine/classes/Elgg/Export/Entity.php index 92c53062630..defcef49ba4 100644 --- a/engine/classes/Elgg/Export/Entity.php +++ b/engine/classes/Elgg/Export/Entity.php @@ -2,8 +2,6 @@ namespace Elgg\Export; -use DateTime; - /** * Entity export representation * @@ -15,19 +13,21 @@ * @property string $time_updated * @property string $url * @property int $read_access - * + * @property string $deleted + * @property string $time_deleted */ class Entity extends Data { /** - * Get updated tme - * @return DateTime|null + * Get updated time + * + * @return \DateTime|null */ public function getTimeUpdated() { if (!$this->time_updated) { return null; } - return new DateTime($this->time_created); + return new \DateTime($this->time_created); } } diff --git a/engine/classes/Elgg/Invoker.php b/engine/classes/Elgg/Invoker.php index 5df5a209082..66d27777ebf 100644 --- a/engine/classes/Elgg/Invoker.php +++ b/engine/classes/Elgg/Invoker.php @@ -27,26 +27,35 @@ public function __construct(protected SessionManagerService $session_manager, pr * ELGG_ENFORCE_ACCESS * ELGG_SHOW_DISABLED_ENTITIES * ELGG_HIDE_DISABLED_ENTITIES + * ELGG_SHOW_DELETED_ENTITIES + * ELGG_HIDE_DELETED_ENTITIES * @param \Closure $closure Callable to call * * @return mixed - * @throws \Exception + * @throws \Throwable */ public function call(int $flags, \Closure $closure) { - $ia = $this->session_manager->getIgnoreAccess(); + $access = $this->session_manager->getIgnoreAccess(); if ($flags & ELGG_IGNORE_ACCESS) { $this->session_manager->setIgnoreAccess(true); - } else if ($flags & ELGG_ENFORCE_ACCESS) { + } elseif ($flags & ELGG_ENFORCE_ACCESS) { $this->session_manager->setIgnoreAccess(false); } - $ha = $this->session_manager->getDisabledEntityVisibility(); + $disabled = $this->session_manager->getDisabledEntityVisibility(); if ($flags & ELGG_SHOW_DISABLED_ENTITIES) { $this->session_manager->setDisabledEntityVisibility(true); - } else if ($flags & ELGG_HIDE_DISABLED_ENTITIES) { + } elseif ($flags & ELGG_HIDE_DISABLED_ENTITIES) { $this->session_manager->setDisabledEntityVisibility(false); } + + $deleted = $this->session_manager->getDeletedEntityVisibility(); + if ($flags & ELGG_SHOW_DELETED_ENTITIES) { + $this->session_manager->setDeletedEntityVisibility(true); + } elseif ($flags & ELGG_HIDE_DELETED_ENTITIES) { + $this->session_manager->setDeletedEntityVisibility(false); + } $system_log_enabled = null; $system_log_service = null; @@ -65,9 +74,10 @@ public function call(int $flags, \Closure $closure) { } } - $restore = function () use ($ia, $ha, $system_log_service, $system_log_enabled) { - $this->session_manager->setIgnoreAccess($ia); - $this->session_manager->setDisabledEntityVisibility($ha); + $restore = function () use ($access, $disabled, $deleted, $system_log_service, $system_log_enabled) { + $this->session_manager->setIgnoreAccess($access); + $this->session_manager->setDisabledEntityVisibility($disabled); + $this->session_manager->setDeletedEntityVisibility($deleted); if (isset($system_log_service)) { if ($system_log_enabled) { diff --git a/engine/classes/Elgg/Menus/Entity.php b/engine/classes/Elgg/Menus/Entity.php index 27c72adca96..8a3ef06235b 100644 --- a/engine/classes/Elgg/Menus/Entity.php +++ b/engine/classes/Elgg/Menus/Entity.php @@ -31,7 +31,11 @@ public static function registerEdit(\Elgg\Event $event) { $return = $event->getValue(); if ($return->get('edit')) { - // a menu item for editting already exists + // a menu item for editing already exists + return; + } + + if ($entity->deleted === 'yes') { return; } @@ -72,24 +76,22 @@ public static function registerDelete(\Elgg\Event $event) { // upgrades deleting has no point, they'll be rediscovered again return; } - - $delete_url = elgg_generate_action_url('entity/delete', [ - 'guid' => $entity->guid, - ]); - - if (empty($delete_url) || !$entity->canDelete()) { + + if (!$entity->canDelete()) { return; } /* @var $return MenuItems */ $return = $event->getValue(); - + $return[] = \ElggMenuItem::factory([ 'name' => 'delete', 'icon' => 'delete', 'text' => elgg_echo('delete'), 'title' => elgg_echo('delete:this'), - 'href' => $delete_url, + 'href' => elgg_generate_action_url('entity/delete', [ + 'guid' => $entity->guid, + ]), 'confirm' => elgg_echo('deleteconfirm'), 'priority' => 950, ]); @@ -97,6 +99,104 @@ public static function registerDelete(\Elgg\Event $event) { return $return; } + /** + * Register the trash menu item + * + * @param \Elgg\Event $event 'register', 'menu:entity' + * + * @return null|MenuItems + */ + public static function registerTrash(\Elgg\Event $event): ?MenuItems { + if (!elgg_get_config('trash_enabled')) { + return null; + } + + $entity = $event->getEntityParam(); + if (!$entity instanceof \ElggEntity || $entity instanceof \ElggUser || $entity instanceof \ElggPlugin || $entity instanceof \ElggUpgrade) { + // users mostly use the hover menu for their actions + // plugins can't be removed + // upgrades deleting has no point, they'll be rediscovered again + return null; + } + + if ($entity->deleted !== 'no' || !$entity->canDelete() || !$entity->hasCapability('restorable')) { + return null; + } + + /* @var $return MenuItems */ + $return = $event->getValue(); + + // replace the delete menu item with the trash action + $return->remove('delete'); + + $return[] = \ElggMenuItem::factory([ + 'name' => 'trash', + 'icon' => 'trash-alt', + 'text' => elgg_echo('trash'), + 'title' => elgg_echo('trash:this'), + 'href' => elgg_generate_action_url('entity/trash', [ + 'guid' => $entity->guid, + ]), + 'confirm' => elgg_echo('trashconfirm'), + 'priority' => 950, + ]); + + return $return; + } + + /** + * Register the restore menu item + * + * @param \Elgg\Event $event 'register', 'menu:entity' + * + * @return null|MenuItems + */ + public static function registerRestore(\Elgg\Event $event): ?MenuItems { + if (!elgg_get_config('trash_enabled')) { + return null; + } + + $entity = $event->getEntityParam(); + if ($entity->deleted !== 'yes' || !$entity->canEdit() || !$entity->hasCapability('restorable')) { + return null; + } + + /* @var $return MenuItems */ + $return = $event->getValue(); + + $container = elgg_call(ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($entity) { + return $entity->getContainerEntity(); + }); + if (!$container instanceof \ElggEntity || ($container instanceof \ElggGroup && $container->deleted === 'yes')) { + $return[] = \ElggMenuItem::factory([ + 'name' => 'restore_and_move', + 'icon' => 'trash-restore-alt', + 'text' => elgg_echo('restore:this:move'), + 'title' => elgg_echo('restore:this'), + 'href' => elgg_http_add_url_query_elements('ajax/form/entity/chooserestoredestination', [ + 'entity_guid' => $entity->guid, + ]), + 'link_class' => 'elgg-lightbox', + 'priority' => 800, + ]); + } + + if ($container?->deleted !== 'yes') { + $return[] = \ElggMenuItem::factory([ + 'name' => 'restore', + 'icon' => 'trash-restore-alt', + 'text' => elgg_echo('restore:this'), + 'href' => elgg_generate_action_url('entity/restore', [ + 'guid' => $entity->guid, + ]), + 'confirm' => elgg_echo('restoreconfirm'), + 'priority' => 900, + ]); + } + + return $return; + } + /** * Registers menu items for the entity menu of a comment * diff --git a/engine/classes/Elgg/Menus/Page.php b/engine/classes/Elgg/Menus/Page.php index 09f43d5fe72..fc9fa5923cd 100644 --- a/engine/classes/Elgg/Menus/Page.php +++ b/engine/classes/Elgg/Menus/Page.php @@ -120,6 +120,17 @@ public static function registerUserSettings(\Elgg\Event $event) { 'section' => 'configure', ]); + if (elgg_get_config('trash_enabled')) { + $return[] = \ElggMenuItem::factory([ + 'name' => '1_trash', + 'text' => elgg_echo('trash:menu:page'), + 'href' => elgg_generate_url('trash:owner', [ + 'username' => $user->username, + ]), + 'section' => 'configure', + ]); + } + return $return; } diff --git a/engine/classes/Elgg/Menus/Social.php b/engine/classes/Elgg/Menus/Social.php index a1feb857ac8..2b9a9d0a137 100644 --- a/engine/classes/Elgg/Menus/Social.php +++ b/engine/classes/Elgg/Menus/Social.php @@ -28,6 +28,10 @@ public static function registerComments(\Elgg\Event $event) { if (!$entity->hasCapability('commentable')) { return; } + + if ($entity->deleted === 'yes') { + return; + } /* @var $return MenuItems */ $return = $event->getValue(); diff --git a/engine/classes/Elgg/SessionManagerService.php b/engine/classes/Elgg/SessionManagerService.php index f9fe957b232..4df2d15d584 100644 --- a/engine/classes/Elgg/SessionManagerService.php +++ b/engine/classes/Elgg/SessionManagerService.php @@ -20,6 +20,8 @@ class SessionManagerService { protected ?\ElggUser $logged_in_user = null; protected bool $show_disabled_entities = false; + + protected bool $show_deleted_entities = false; /** * Constructor @@ -86,6 +88,31 @@ public function setDisabledEntityVisibility(bool $show = true): bool { return $prev; } + + /** + * Are deleted entities shown? + * + * @return bool + * @since 6.0 + */ + public function getDeletedEntityVisibility(): bool { + return $this->show_deleted_entities; + } + + /** + * Include deleted entities in queries + * + * @param bool $show Visibility status + * + * @return bool Previous setting + * @since 6.0 + */ + public function setDeletedEntityVisibility(bool $show = true): bool { + $prev = $this->show_deleted_entities; + $this->show_deleted_entities = $show; + + return $prev; + } /** * Set a user specific token in the session for the currently logged in user diff --git a/engine/classes/Elgg/UpgradeService.php b/engine/classes/Elgg/UpgradeService.php index 73283133ad4..b1349634bc6 100644 --- a/engine/classes/Elgg/UpgradeService.php +++ b/engine/classes/Elgg/UpgradeService.php @@ -222,7 +222,7 @@ public function getPendingUpgrades(bool $async = true): array { public function getCompletedUpgrades(bool $async = true): array { // make sure always to return all upgrade entities return elgg_call( - ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, + ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function () use ($async) { $completed = []; @@ -276,7 +276,7 @@ public function executeUpgrade(\ElggUpgrade $upgrade, int $max_duration = null): // Upgrade also disabled data, so the compatibility is // preserved in case the data ever gets enabled again return elgg_call( - ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, + ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function () use ($upgrade, $max_duration) { return $this->events->triggerResultsSequence('upgrade:execute', 'system', ['object' => $upgrade], null, function() use ($upgrade, $max_duration) { $result = new Result(); diff --git a/engine/classes/Elgg/Users/Accounts.php b/engine/classes/Elgg/Users/Accounts.php index ec2b9f1adb5..3eb161b6abc 100644 --- a/engine/classes/Elgg/Users/Accounts.php +++ b/engine/classes/Elgg/Users/Accounts.php @@ -53,43 +53,40 @@ public function __construct( * @return ValidationResults */ public function validateAccountData(string $username, string|array $password, string $name, string $email, bool $allow_multiple_emails = false): ValidationResults { + $results = new ValidationResults(); - return elgg_call(ELGG_SHOW_DISABLED_ENTITIES, function () use ($username, $email, $password, $name, $allow_multiple_emails) { - $results = new ValidationResults(); - - if (empty($name)) { - $error = $this->translator->translate('registration:noname'); - $results->fail('name', $name, $error); - } else { - $results->pass('name', $name); - } + if (empty($name)) { + $error = $this->translator->translate('registration:noname'); + $results->fail('name', $name, $error); + } else { + $results->pass('name', $name); + } - try { - $this->assertValidEmail($email, !$allow_multiple_emails); + try { + $this->assertValidEmail($email, !$allow_multiple_emails); - $results->pass('email', $email); - } catch (RegistrationException $ex) { - $results->fail('email', $email, $ex->getMessage()); - } + $results->pass('email', $email); + } catch (RegistrationException $ex) { + $results->fail('email', $email, $ex->getMessage()); + } - try { - $this->assertValidPassword($password); + try { + $this->assertValidPassword($password); - $results->pass('password', $password); - } catch (RegistrationException $ex) { - $results->fail('password', $password, $ex->getMessage()); - } + $results->pass('password', $password); + } catch (RegistrationException $ex) { + $results->fail('password', $password, $ex->getMessage()); + } - try { - $this->assertValidUsername($username, true); + try { + $this->assertValidUsername($username, true); - $results->pass('username', $username); - } catch (RegistrationException $ex) { - $results->fail('username', $username, $ex->getMessage()); - } + $results->pass('username', $username); + } catch (RegistrationException $ex) { + $results->fail('username', $username, $ex->getMessage()); + } - return $results; - }); + return $results; } /** @@ -245,7 +242,7 @@ public function assertValidUsername(string $username, bool $assert_unregistered } if ($assert_unregistered) { - $exists = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function () use ($username) { + $exists = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function () use ($username) { return elgg_get_user_by_username($username); }); @@ -331,7 +328,7 @@ public function assertValidEmail(string $address, bool $assert_unregistered = fa } if ($assert_unregistered) { - $exists = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function () use ($address) { + $exists = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function () use ($address) { return elgg_get_user_by_email($address); }); diff --git a/engine/classes/ElggComment.php b/engine/classes/ElggComment.php index e1ea50e4cab..a2243c19806 100644 --- a/engine/classes/ElggComment.php +++ b/engine/classes/ElggComment.php @@ -24,14 +24,14 @@ protected function initializeAttributes() { } /** - * {@inheritDoc} + * {@inheritdoc} */ - public function delete(bool $recursive = true): bool { - $result = parent::delete($recursive); + public function persistentDelete(bool $recursive = true): bool { + $result = parent::persistentDelete($recursive); if ($result) { // remove the threaded comments directly below this comment - elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($recursive) { + elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($recursive) { $children = elgg_get_entities([ 'type' => 'object', 'subtype' => 'comment', @@ -46,7 +46,7 @@ public function delete(bool $recursive = true): bool { /* @var $child \ElggComment */ foreach ($children as $child) { - $child->delete($recursive); + $child->delete($recursive, true); } }); } diff --git a/engine/classes/ElggEntity.php b/engine/classes/ElggEntity.php index 90be2fbc64a..7e1300d7de2 100644 --- a/engine/classes/ElggEntity.php +++ b/engine/classes/ElggEntity.php @@ -29,16 +29,18 @@ * * @tip Plugin authors will want to extend the \ElggObject class, not this class. * - * @property-read string $type object, user, group, or site (read-only after save) - * @property-read string $subtype Further clarifies the nature of the entity - * @property-read int $guid The unique identifier for this entity (read only) - * @property int $owner_guid The GUID of the owner of this entity (usually the creator) - * @property int $container_guid The GUID of the entity containing this entity - * @property int $access_id Specifies the visibility level of this entity - * @property int $time_created A UNIX timestamp of when the entity was created - * @property-read int $time_updated A UNIX timestamp of when the entity was last updated (automatically updated on save) - * @property-read int $last_action A UNIX timestamp of when the entity was last acted upon - * @property-read string $enabled Is this entity enabled ('yes' or 'no') + * @property-read string $type object, user, group, or site (read-only after save) + * @property-read string $subtype Further clarifies the nature of the entity + * @property-read int $guid The unique identifier for this entity (read only) + * @property int $owner_guid The GUID of the owner of this entity (usually the creator) + * @property int $container_guid The GUID of the entity containing this entity + * @property int $access_id Specifies the visibility level of this entity + * @property int $time_created A UNIX timestamp of when the entity was created + * @property-read int $time_updated A UNIX timestamp of when the entity was last updated (automatically updated on save) + * @property-read int $last_action A UNIX timestamp of when the entity was last acted upon + * @property-read int $time_deleted A UNIX timestamp of when the entity was deleted + * @property-read string $deleted Is this entity deleted ('yes' or 'no') + * @property-read string $enabled Is this entity enabled ('yes' or 'no') * * Metadata (the above are attributes) * @property string $location A location of the entity @@ -59,6 +61,8 @@ abstract class ElggEntity extends \ElggData { 'time_updated', 'last_action', 'enabled', + 'deleted', + 'time_deleted', ]; /** @@ -72,6 +76,7 @@ abstract class ElggEntity extends \ElggData { 'time_created', 'time_updated', 'last_action', + 'time_deleted', ]; /** @@ -161,6 +166,8 @@ protected function initializeAttributes() { $this->attributes['time_updated'] = null; $this->attributes['last_action'] = null; $this->attributes['enabled'] = 'yes'; + $this->attributes['deleted'] = 'no'; + $this->attributes['time_deleted'] = 0; } /** @@ -201,7 +208,7 @@ public function __clone() { $metadata_names[] = $metadata->name; } - // arrays are stored with multiple enties per name + // arrays are stored with multiple entries per name $metadata_names = array_unique($metadata_names); // move the metadata over @@ -249,6 +256,7 @@ public function __set($name, $value) { switch ($name) { case 'guid': case 'last_action': + case 'time_deleted': case 'time_updated': case 'type': return; @@ -256,6 +264,8 @@ public function __set($name, $value) { throw new ElggInvalidArgumentException(elgg_echo('ElggEntity:Error:SetSubtype', ['setSubtype()'])); case 'enabled': throw new ElggInvalidArgumentException(elgg_echo('ElggEntity:Error:SetEnabled', ['enable() / disable()'])); + case 'deleted': + throw new ElggInvalidArgumentException(elgg_echo('ElggEntity:Error:SetDeleted', ['delete() / restore()'])); case 'access_id': case 'owner_guid': case 'container_guid': @@ -1153,6 +1163,8 @@ protected function create() { $access_id = (int) $this->attributes['access_id']; $now = $this->getCurrentTime()->getTimestamp(); $time_created = isset($this->attributes['time_created']) ? (int) $this->attributes['time_created'] : $now; + $deleted = $this->attributes['deleted']; + $time_deleted = (int) $this->attributes['time_deleted']; $container_guid = $this->attributes['container_guid']; if ($container_guid == 0) { @@ -1219,6 +1231,8 @@ protected function create() { 'time_created' => $time_created, 'time_updated' => $now, 'last_action' => $now, + 'deleted' => $deleted, + 'time_deleted' => $time_deleted ], $this->attributes); if (!$guid) { @@ -1231,6 +1245,8 @@ protected function create() { $this->attributes['time_updated'] = (int) $now; $this->attributes['last_action'] = (int) $now; $this->attributes['container_guid'] = (int) $container_guid; + $this->attributes['deleted'] = $deleted; + $this->attributes['time_deleted'] = (int) $time_deleted; // We are writing this new entity to cache to make sure subsequent calls // to get_entity() load the entity from cache and not from the DB. This @@ -1296,11 +1312,13 @@ protected function update(): bool { $container_guid = (int) $this->container_guid; $time_created = (int) $this->time_created; $time = $this->getCurrentTime()->getTimestamp(); + $deleted = $this->deleted; + $time_deleted = (int) $this->time_deleted; if ($access_id == ACCESS_DEFAULT) { throw new ElggInvalidArgumentException('ACCESS_DEFAULT is not a valid access level. See its documentation in constants.php'); } - + if ($access_id == ACCESS_FRIENDS) { throw new ElggInvalidArgumentException('ACCESS_FRIENDS is not a valid access level. See its documentation in constants.php'); } @@ -1313,6 +1331,8 @@ protected function update(): bool { 'time_created' => $time_created, 'time_updated' => $time, 'guid' => $guid, + 'deleted' => $deleted, + 'time_deleted' => $time_deleted ]); if ($ret === false) { return false; @@ -1364,6 +1384,62 @@ protected function load(stdClass $row): bool { return true; } + /** + * Restore the entity + * + * @param bool $recursive Recursively restores all entities trashed with the entity? + * + * @return bool + */ + public function restore(bool $recursive = true): bool { + if ($this->deleted === 'no') { + return true; + } + + if (empty($this->guid) || !$this->canEdit()) { + return false; + } + + return _elgg_services()->events->triggerSequence('restore', $this->type, $this, function () use ($recursive) { + return elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($recursive) { + $result = _elgg_services()->entityTable->restore($this); + + if ($recursive) { + set_time_limit(0); + + /* @var $deleted_with_it \ElggBatch */ + $deleted_with_it = elgg_get_entities([ + 'relationship' => 'deleted_with', + 'relationship_guid' => $this->guid, + 'inverse_relationship' => true, + 'limit' => false, + 'batch' => true, + 'batch_inc_offset' => false, + ]); + + /* @var $e \ElggEntity */ + foreach ($deleted_with_it as $e) { + if (!$e->restore($recursive)) { + $deleted_with_it->reportFailure(); + continue; + } + + $e->removeRelationship($this->guid, 'deleted_with'); + } + } + + $this->removeAllRelationships('deleted_by', true); + + if ($result) { + $this->attributes['deleted'] = 'no'; + $this->attributes['time_deleted'] = 0; + } + + return $result; + }); + }); + } + /** * Disable this entity. * @@ -1410,7 +1486,7 @@ public function disable(string $reason = '', bool $recursive = true): bool { $guid = (int) $this->guid; if ($recursive) { - elgg_call(ELGG_IGNORE_ACCESS | ELGG_HIDE_DISABLED_ENTITIES, function () use ($guid, $reason) { + elgg_call(ELGG_IGNORE_ACCESS | ELGG_HIDE_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function () use ($guid, $reason) { $base_options = [ 'wheres' => [ function(QueryBuilder $qb, $main_alias) use ($guid) { @@ -1476,9 +1552,9 @@ public function enable(bool $recursive = true): bool { return false; } - $result = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($recursive) { + $result = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($recursive) { $result = _elgg_services()->entityTable->enable($this); - + $this->deleteMetadata('disable_reason'); if ($recursive) { @@ -1529,12 +1605,12 @@ public function isEnabled(): bool { * the entity. That means that if the container_guid = $this->guid, the item will * be deleted regardless of who owns it. * - * @param bool $recursive If true (default) then all entities which are - * owned or contained by $this will also be deleted. + * @param bool $recursive If true (default) then all entities which are owned or contained by $this will also be deleted. + * @param bool|null $persistent persistently delete the entity (default: check the 'restorable' capability) * * @return bool */ - public function delete(bool $recursive = true): bool { + public function delete(bool $recursive = true, bool $persistent = null): bool { // first check if we can delete this entity // NOTE: in Elgg <= 1.10.3 this was after the delete event, // which could potentially remove some content if the user didn't have access @@ -1542,13 +1618,45 @@ public function delete(bool $recursive = true): bool { return false; } + if (!isset($persistent)) { + $persistent = !$this->hasCapability('restorable'); + } + try { - return _elgg_services()->entityTable->delete($this, $recursive); + if (empty($this->guid) || $persistent) { + return $this->persistentDelete($recursive); + } else { + return $this->trash($recursive); + } } catch (DatabaseException $ex) { elgg_log($ex, 'ERROR'); return false; } } + + /** + * Permanently delete the entity from the database + * + * @param bool $recursive If true (default) then all entities which are owned or contained by $this will also be deleted. + * + * @return bool + * @since 6.0 + */ + protected function persistentDelete(bool $recursive = true): bool { + return _elgg_services()->entityTable->delete($this, $recursive); + } + + /** + * Move the entity to the trash + * + * @param bool $recursive If true (default) then all entities which are owned or contained by $this will also be trashed. + * + * @return bool + * @since 6.0 + */ + protected function trash(bool $recursive = true): bool { + return _elgg_services()->entityTable->trash($this, $recursive); + } /** * Export an entity @@ -1580,6 +1688,8 @@ protected function prepareObject(\Elgg\Export\Entity $object) { $object->container_guid = $this->getContainerGUID(); $object->time_created = date('c', $this->getTimeCreated()); $object->time_updated = date('c', $this->getTimeUpdated()); + $object->deleted = $this->deleted; + $object->time_deleted = $this->time_deleted; $object->url = $this->getURL(); $object->read_access = (int) $this->access_id; return $object; @@ -1711,10 +1821,27 @@ public function updateLastAction(int $posted = null): int { $this->attributes['last_action'] = $posted; $this->cache(); - + return $posted; } + /** + * Update the time_deleted column in the entities table. + * + * @param int $deleted Timestamp of deletion + * + * @return int + * @internal + */ + public function updateTimeDeleted(int $deleted = null): int { + $deleted = _elgg_services()->entityTable->updateTimeDeleted($this, $deleted); + + $this->attributes['time_deleted'] = $deleted; + $this->cache(); + + return $deleted; + } + /** * Disable runtime caching for entity * diff --git a/engine/classes/ElggExtender.php b/engine/classes/ElggExtender.php index 88d29c6b691..fabe19821d7 100644 --- a/engine/classes/ElggExtender.php +++ b/engine/classes/ElggExtender.php @@ -13,16 +13,16 @@ * @see \ElggAnnotation * @see \ElggMetadata * - * @property string $type annotation or metadata (read-only after save) - * @property int $id The unique identifier (read-only) - * @property int $entity_guid The GUID of the entity that this extender describes - * @property int $owner_guid The GUID of the owner of this extender - * @property int $access_id Specifies the visibility level of this extender - * @property string $name The name of this extender - * @property mixed $value The value of the extender (int or string) - * @property int $time_created A UNIX timestamp of when the extender was created (read-only, set on first save) - * @property string $value_type 'integer' or 'text' - * @property string $enabled Is this extender enabled ('yes' or 'no') + * @property string $type annotation or metadata (read-only after save) + * @property int $id The unique identifier (read-only) + * @property int $entity_guid The GUID of the entity that this extender describes + * @property int $owner_guid The GUID of the owner of this extender + * @property int $access_id Specifies the visibility level of this extender + * @property string $name The name of this extender + * @property mixed $value The value of the extender (int or string) + * @property int $time_created A UNIX timestamp of when the extender was created (read-only, set on first save) + * @property string $value_type 'integer' or 'text' + * @property string $enabled Is this extender enabled ('yes' or 'no') */ abstract class ElggExtender extends \ElggData { diff --git a/engine/classes/ElggFile.php b/engine/classes/ElggFile.php index 13a16369ee3..e79d2e36db0 100644 --- a/engine/classes/ElggFile.php +++ b/engine/classes/ElggFile.php @@ -231,20 +231,19 @@ public function close(): bool { } /** - * Delete this file. - * - * @param bool $follow_symlinks If true, will also delete the target file if the current file is a symlink - * - * @return bool + * {@inheritdoc} */ - public function delete(bool $follow_symlinks = true): bool { - $result = $this->getFilestore()->delete($this, $follow_symlinks); - - if ($this->getGUID() && $result) { - $result = parent::delete(); + protected function persistentDelete(bool $recursive = true): bool { + if ($this->guid) { + $result = parent::persistentDelete($recursive); + if ($result) { + $this->getFilestore()->delete($this); + } + + return $result; } - - return $result; + + return $this->getFilestore()->delete($this); } /** diff --git a/engine/classes/ElggPlugin.php b/engine/classes/ElggPlugin.php index e2ecbb77c8d..4370eaa8ad9 100644 --- a/engine/classes/ElggPlugin.php +++ b/engine/classes/ElggPlugin.php @@ -617,7 +617,7 @@ public function assertCanDeactivate(): void { if (!array_key_exists($this->getID(), $dependencies)) { continue; } - + if (elgg_extract('must_be_active', $dependencies[$this->getID()], true)) { $dependents[$plugin->getID()] = $plugin; } diff --git a/engine/classes/ElggSite.php b/engine/classes/ElggSite.php index b07ff59eefb..40df6ceb148 100644 --- a/engine/classes/ElggSite.php +++ b/engine/classes/ElggSite.php @@ -45,6 +45,8 @@ protected function initializeAttributes() { $this->attributes['time_updated'] = null; $this->attributes['last_action'] = null; $this->attributes['enabled'] = 'yes'; + $this->attributes['deleted'] = 'no'; + $this->attributes['time_deleted'] = 0; } /** @@ -77,23 +79,27 @@ public function save(): bool { } /** - * Delete the site. - * - * @note You cannot delete the current site. - * - * @param bool $recursive If true (default) then all entities which are owned or contained by $this will also be deleted. - * - * @return bool - * @throws SecurityException + * {@inheritdoc} */ - public function delete(bool $recursive = true): bool { - if ($this->guid == 1) { + protected function persistentDelete(bool $recursive = true): bool { + if ($this->guid === 1) { throw new SecurityException('You cannot delete the current site'); } - - return parent::delete($recursive); + + return parent::persistentDelete($recursive); } - + + /** + * {@inheritdoc} + */ + protected function trash(bool $recursive = true): bool { + if ($this->guid === 1) { + throw new SecurityException('You cannot delete the current site'); + } + + return parent::trash($recursive); + } + /** * Disable the site * diff --git a/engine/classes/ElggUser.php b/engine/classes/ElggUser.php index 442d3151835..5d48fa80518 100644 --- a/engine/classes/ElggUser.php +++ b/engine/classes/ElggUser.php @@ -448,10 +448,10 @@ public function getNotificationSettings(string $purpose = 'default'): array { } /** - * {@inheritDoc} + * {@inheritdoc} */ - public function delete(bool $recursive = true): bool { - $result = parent::delete($recursive); + public function persistentDelete(bool $recursive = true): bool { + $result = parent::persistentDelete($recursive); if ($result) { // cleanup remember me cookie records _elgg_services()->users_remember_me_cookies_table->deleteAllHashes($this); diff --git a/engine/events.php b/engine/events.php index ac558bfc9d5..d6a4e0e81b4 100644 --- a/engine/events.php +++ b/engine/events.php @@ -110,6 +110,9 @@ 'Elgg\Users\Validation::notifyAdminsAboutPendingUsers' => [], \Elgg\Users\CleanupPersistentLoginHandler::class => [], ], + 'hourly' => [ + \Elgg\Entity\RemoveDeletedEntitiesHandler::class => [], + ], 'minute' => [ \Elgg\Notifications\ProcessQueueCronHandler::class => ['priority' => 100], ], @@ -268,6 +271,8 @@ 'menu:entity' => [ 'Elgg\Menus\Entity::registerDelete' => [], 'Elgg\Menus\Entity::registerEdit' => [], + 'Elgg\Menus\Entity::registerTrash' => ['priority' => 501], // needs to be after registerDelete + 'Elgg\Menus\Entity::registerRestore' => [], 'Elgg\Menus\Entity::registerUserHoverAdminSection' => [], 'Elgg\Menus\UserHover::registerLoginAs' => [], ], diff --git a/engine/lib/constants.php b/engine/lib/constants.php index dba96a19770..5960dced535 100644 --- a/engine/lib/constants.php +++ b/engine/lib/constants.php @@ -133,3 +133,5 @@ const ELGG_HIDE_DISABLED_ENTITIES = 8; const ELGG_DISABLE_SYSTEM_LOG = 16; const ELGG_ENABLE_SYSTEM_LOG = 32; +const ELGG_SHOW_DELETED_ENTITIES = 64; +const ELGG_HIDE_DELETED_ENTITIES = 128; diff --git a/engine/lib/elgglib.php b/engine/lib/elgglib.php index f37104fef61..7f864f0f798 100644 --- a/engine/lib/elgglib.php +++ b/engine/lib/elgglib.php @@ -292,6 +292,10 @@ function elgg_extract_class(array $array, $existing = [], $extract_key = 'class' * ELGG_ENFORCE_ACCESS * ELGG_SHOW_DISABLED_ENTITIES * ELGG_HIDE_DISABLED_ENTITIES + * ELGG_DISABLE_SYSTEM_LOG + * ELGG_ENABLE_SYSTEM_LOG + * ELGG_SHOW_DELETED_ENTITIES + * ELGG_HIDE_DELETED_ENTITIES * @param Closure $closure Callable to call * * @return mixed diff --git a/engine/lib/entities.php b/engine/lib/entities.php index 6bea136d39e..d06b1c55f00 100644 --- a/engine/lib/entities.php +++ b/engine/lib/entities.php @@ -3,7 +3,9 @@ * Procedural code for creating, loading, and modifying \ElggEntity objects. */ +use Elgg\Database\Clauses\OrderByClause; use Elgg\Database\EntityTable; +use Elgg\Database\QueryBuilder; use Elgg\Database\Select; /** @@ -654,24 +656,41 @@ function elgg_search(array $options = []) { * * @return array * @since 4.3 + * @see elgg_get_entities() */ -function elgg_get_entity_statistics(int $owner_guid = 0): array { - $select = Select::fromTable(EntityTable::TABLE_NAME); - $select->select('type') - ->addSelect('subtype') - ->addSelect('count(*) AS total') - ->where($select->compare('enabled', '=', 'yes', ELGG_VALUE_STRING)) - ->groupBy('type') - ->addGroupBy('subtype') - ->orderBy('total', 'desc'); +function elgg_get_entity_statistics(array $options = []): array { + $required = [ + 'selects' => [ + 'count(*) AS total', + ], + 'group_by' => [ + function(QueryBuilder $qb, $main_alias) { + return "{$main_alias}.type"; + }, + function(QueryBuilder $qb, $main_alias) { + return "{$main_alias}.subtype"; + }, + ], + 'order_by' => [ + new OrderByClause('total', 'desc'), + ], + 'callback' => function($row) { + return (object) [ + 'type' => $row->type, + 'subtype' => $row->subtype, + 'total' => $row->total, + ]; + }, + 'limit' => false, + ]; - if (!empty($owner_guid)) { - $select->andWhere($select->compare('owner_guid', '=', $owner_guid, ELGG_VALUE_GUID)); - } + $options = array_merge($options, $required); - $entity_stats = []; + $rows = elgg_call(ELGG_IGNORE_ACCESS, function() use ($options) { + return elgg_get_entities($options); + }); - $rows = _elgg_services()->db->getData($select); + $entity_stats = []; foreach ($rows as $row) { $type = $row->type; if (!isset($entity_stats[$type]) || !is_array($entity_stats[$type])) { diff --git a/engine/routes.php b/engine/routes.php index 17b7c307b5d..4f0f7dc5f07 100644 --- a/engine/routes.php +++ b/engine/routes.php @@ -244,4 +244,20 @@ ], 'walled' => false, ], + 'trash:owner' => [ + 'path' => '/settings/trash/{username}', + 'resource' => 'trash/owner', + 'middleware' => [ + \Elgg\Router\Middleware\Gatekeeper::class, + \Elgg\Router\Middleware\UserPageOwnerCanEditGatekeeper::class, + ], + ], + 'trash:container' => [ + 'path' => '/trash/container/{guid}', + 'resource' => 'trash/container', + 'middleware' => [ + \Elgg\Router\Middleware\Gatekeeper::class, + \Elgg\Router\Middleware\GroupPageOwnerCanEditGatekeeper::class, + ], + ], ]; diff --git a/engine/schema/migrations/20230606155735_add_delete_columns_to_entities_tables.php b/engine/schema/migrations/20230606155735_add_delete_columns_to_entities_tables.php new file mode 100644 index 00000000000..86c0b86fae5 --- /dev/null +++ b/engine/schema/migrations/20230606155735_add_delete_columns_to_entities_tables.php @@ -0,0 +1,46 @@ +table('entities'); + if ($table->hasColumn('deleted')) { + return; + } + + $table->addColumn('deleted', 'enum', [ + 'null' => false, + 'default' => 'no', + 'limit' => 3, + 'values' => [ + 'yes', + 'no', + ], + ]); + + $table->addIndex(['deleted'], [ + 'name' => 'deleted', + 'unique' => false, + ]); + + $table->addColumn('time_deleted', 'integer', [ + 'null' => false, + 'default' => '0', + 'limit' => MysqlAdapter::INT_REGULAR, + 'precision' => 11, + ]); + + $table->addIndex(['time_deleted'], [ + 'name' => 'time_deleted', + 'unique' => false, + ]); + + $table->update(); + } +} diff --git a/engine/tests/classes/Elgg/Mocks/Database/EntityTable.php b/engine/tests/classes/Elgg/Mocks/Database/EntityTable.php index ee788bb875a..33ccb50483f 100644 --- a/engine/tests/classes/Elgg/Mocks/Database/EntityTable.php +++ b/engine/tests/classes/Elgg/Mocks/Database/EntityTable.php @@ -51,6 +51,8 @@ public function getRow(int $guid, int $user_guid = null): ?\stdClass { 'time_updated' => time(), 'last_action' => time(), 'enabled' => 'yes', + 'deleted' => 'no', + 'time_deleted' => 0, ]; } @@ -152,6 +154,8 @@ public function setup($guid, $type, $subtype, array $attributes = []) { 'time_updated' => $time, 'last_action' => $time, 'enabled' => 'yes', + 'deleted' => 'no', + 'time_deleted' => 0, ]; $map = array_merge($primary_attributes, $attributes); @@ -361,7 +365,7 @@ protected function validateRowAccess($row) { } $access_array = _elgg_services()->accessCollections->getAccessArray($user->guid); - return in_array($row->access_id, $access_array);; + return in_array($row->access_id, $access_array); } /** @@ -382,6 +386,8 @@ protected function addInsertQuerySpecs(\stdClass $row) { 'time_created' => $insert->param($row->time_created, ELGG_VALUE_TIMESTAMP), 'time_updated' => $insert->param($row->time_updated, ELGG_VALUE_TIMESTAMP), 'last_action' => $insert->param($row->last_action, ELGG_VALUE_TIMESTAMP), + 'deleted' => $insert->param($row->deleted, ELGG_VALUE_STRING), + 'time_deleted' => $insert->param($row->time_deleted, ELGG_VALUE_TIMESTAMP), ]); $this->query_specs[$row->guid][] = _elgg_services()->db->addQuerySpec([ @@ -405,6 +411,8 @@ protected function addUpdateQuerySpecs(\stdClass $row) { ->set('access_id', $update->param($row->access_id, ELGG_VALUE_ID)) ->set('time_created', $update->param($row->time_created, ELGG_VALUE_TIMESTAMP)) ->set('time_updated', $update->param($row->time_updated, ELGG_VALUE_TIMESTAMP)) + ->set('deleted', $update->param($row->deleted, ELGG_VALUE_STRING)) + ->set('time_deleted', $update->param($row->time_deleted, ELGG_VALUE_TIMESTAMP)) ->where($update->compare('guid', '=', $row->guid, ELGG_VALUE_GUID)); $this->query_specs[$row->guid][] = $this->db->addQuerySpec([ @@ -443,6 +451,28 @@ protected function addUpdateQuerySpecs(\stdClass $row) { 'times' => 1, ]); + // soft delete + $qb = Update::table(self::TABLE_NAME); + $qb->set('deleted', $qb->param('yes', ELGG_VALUE_STRING)) + ->where($qb->compare('guid', '=', $row->guid, ELGG_VALUE_GUID)); + + $this->query_specs[$row->guid][] = $this->db->addQuerySpec([ + 'sql' => $qb->getSQL(), + 'params' => $qb->getParameters(), + 'results' => function () use ($row) { + if (isset($this->rows[$row->guid])) { + $row->deleted = 'yes'; + $this->rows[$row->guid] = $row; + $this->addQuerySpecs($row); + + return [$row->guid]; + } + + return []; + }, + 'times' => 1, + ]); + // Enable $qb = Update::table(self::TABLE_NAME); $qb->set('enabled', $qb->param('yes', ELGG_VALUE_STRING)) @@ -465,6 +495,28 @@ protected function addUpdateQuerySpecs(\stdClass $row) { 'times' => 1, ]); + // restore + $qb = Update::table(self::TABLE_NAME); + $qb->set('deleted', $qb->param('no', ELGG_VALUE_STRING)) + ->where($qb->compare('guid', '=', $row->guid, ELGG_VALUE_GUID)); + + $this->query_specs[$row->guid][] = $this->db->addQuerySpec([ + 'sql' => $qb->getSQL(), + 'params' => $qb->getParameters(), + 'results' => function () use ($row) { + if (isset($this->rows[$row->guid])) { + $row->deleted = 'no'; + $this->rows[$row->guid] = $row; + $this->addQuerySpecs($row); + + return [$row->guid]; + } + + return []; + }, + 'times' => 1, + ]); + // Update last action $time = $this->getCurrentTime()->getTimestamp(); diff --git a/engine/tests/phpunit/integration/Elgg/Integration/ElggCoreObjectTest.php b/engine/tests/phpunit/integration/Elgg/Integration/ElggCoreObjectTest.php index 28028c932f0..efac9d99b2b 100644 --- a/engine/tests/phpunit/integration/Elgg/Integration/ElggCoreObjectTest.php +++ b/engine/tests/phpunit/integration/Elgg/Integration/ElggCoreObjectTest.php @@ -51,6 +51,8 @@ public function testElggObjectConstructor() { $attributes['time_updated'] = null; $attributes['last_action'] = null; $attributes['enabled'] = 'yes'; + $attributes['deleted'] = 'no'; + $attributes['time_deleted'] = 0; ksort($attributes); $entity_attributes = $this->entity->expose_attributes(); @@ -136,6 +138,8 @@ public function testElggObjectToObject() { 'title', 'description', 'tags', + 'deleted', + 'time_deleted', ]; sort($keys); diff --git a/engine/tests/phpunit/integration/Elgg/Integration/ElggCoreRegressionBugsTest.php b/engine/tests/phpunit/integration/Elgg/Integration/ElggCoreRegressionBugsTest.php index 017d16b33c9..c749e491934 100644 --- a/engine/tests/phpunit/integration/Elgg/Integration/ElggCoreRegressionBugsTest.php +++ b/engine/tests/phpunit/integration/Elgg/Integration/ElggCoreRegressionBugsTest.php @@ -280,7 +280,9 @@ function testOwnedGetEntityStatistics() { 'owner_guid' => $user->guid, ]); - $stats = elgg_get_entity_statistics($user->guid); + $stats = elgg_get_entity_statistics([ + 'owner_guid' => $user->guid, + ]); $this->assertEquals(1, $stats['object'][$subtype]); } diff --git a/engine/tests/phpunit/unit/Elgg/Database/Clauses/AccessWhereClauseUnitTest.php b/engine/tests/phpunit/unit/Elgg/Database/Clauses/AccessWhereClauseUnitTest.php index 529a405275d..d27b07ea89b 100644 --- a/engine/tests/phpunit/unit/Elgg/Database/Clauses/AccessWhereClauseUnitTest.php +++ b/engine/tests/phpunit/unit/Elgg/Database/Clauses/AccessWhereClauseUnitTest.php @@ -39,6 +39,7 @@ public function testCanBuildAccessSqlClausesWithIgnoredAccess() { $query = new AccessWhereClause(); $query->ignore_access = true; + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -53,6 +54,7 @@ public function testCanBuildAccessSqlClausesWithIgnoredAccessWithoutDisabledEnti $query = new AccessWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -77,6 +79,7 @@ public function testCanBuildAccessSqlForLoggedInUser() { $expected = $this->qb->merge($parts); $query = new AccessWhereClause(); + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -99,6 +102,7 @@ public function testCanBuildAccessSqlWithNoTableAlias() { $expected = $this->qb->merge($parts); $query = new AccessWhereClause(); + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, ''); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -122,6 +126,7 @@ public function testCanBuildAccessSqlWithCustomGuidColumn() { $query = new AccessWhereClause(); $query->owner_guid_column = 'unit_test'; + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -146,6 +151,7 @@ public function testCanBuildAccessSqlForLoggedOutUser() { $expected = $this->qb->merge($parts); $query = new AccessWhereClause(); + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -172,6 +178,7 @@ public function testAccessEventRemoveEnabled() { $query = new AccessWhereClause(); $query->ignore_access = true; + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -198,6 +205,7 @@ public function testAccessEventRemoveOrs() { $query = new AccessWhereClause(); $query->ignore_access = true; + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -233,6 +241,7 @@ public function testAccessEventAddOr() { $query = new AccessWhereClause(); $query->ignore_access = true; + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -266,6 +275,7 @@ public function testAccessEventAddAnd() { $query = new AccessWhereClause(); $query->ignore_access = true; + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); diff --git a/engine/tests/phpunit/unit/Elgg/Database/Clauses/AnnotationWhereClauseUnitTest.php b/engine/tests/phpunit/unit/Elgg/Database/Clauses/AnnotationWhereClauseUnitTest.php index 93f180b015f..e7421b87032 100644 --- a/engine/tests/phpunit/unit/Elgg/Database/Clauses/AnnotationWhereClauseUnitTest.php +++ b/engine/tests/phpunit/unit/Elgg/Database/Clauses/AnnotationWhereClauseUnitTest.php @@ -190,6 +190,7 @@ public function testBuildQueryWithAccessConstraint() { $access = new AccessWhereClause(); $access->viewer_guid = 5; + $access->show_deleted = false; $access->use_enabled_clause = false; $parts[] = $access->prepare($this->qb, 'alias'); @@ -218,6 +219,7 @@ public function testBuildSortByCalculationQuery() { $access = new AccessWhereClause(); $access->viewer_guid = 5; + $access->show_deleted = false; $access->use_enabled_clause = false; $parts[] = $access->prepare($this->qb, 'alias'); @@ -259,6 +261,7 @@ public function testCanSortByTextValue() { $access = new AccessWhereClause(); $access->viewer_guid = 5; + $access->show_deleted = false; $access->use_enabled_clause = false; $parts[] = $access->prepare($this->qb, 'alias'); @@ -286,6 +289,7 @@ public function testCanSortByIntegerValue() { $access = new AccessWhereClause(); $access->viewer_guid = 5; + $access->show_deleted = false; $access->use_enabled_clause = false; $parts[] = $access->prepare($this->qb, $this->qb->getTableAlias()); diff --git a/engine/tests/phpunit/unit/Elgg/Database/Clauses/EntityWhereClauseUnitTest.php b/engine/tests/phpunit/unit/Elgg/Database/Clauses/EntityWhereClauseUnitTest.php index 920991e7867..0461fcae37d 100644 --- a/engine/tests/phpunit/unit/Elgg/Database/Clauses/EntityWhereClauseUnitTest.php +++ b/engine/tests/phpunit/unit/Elgg/Database/Clauses/EntityWhereClauseUnitTest.php @@ -25,6 +25,7 @@ public function testBuildEmptyQuery() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); $actual = $query->prepare($qb, $qb->getTableAlias()); @@ -43,6 +44,7 @@ public function testBuildQueryFromGuid() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $query->guids = 1; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); @@ -64,6 +66,7 @@ public function testBuildQueryFromOwnerAndContainerGuid() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $query->owner_guids = [2, 3]; $query->container_guids = [4, 5, 6]; @@ -93,6 +96,7 @@ public function testBuildQueryFromTimeCreated() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $query->created_after = $after; $query->created_before = $before; @@ -123,6 +127,7 @@ public function testBuildQueryFromTimeUpdated() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $query->updated_after = $after; $query->updated_before = $before; @@ -153,6 +158,7 @@ public function testBuildQueryFromLastAction() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $query->last_action_after = $after; $query->last_action_before = $before; @@ -174,6 +180,7 @@ public function testBuildQueryFromEnabled() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $query->enabled = 'no'; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); @@ -193,6 +200,7 @@ public function testBuildQueryFromAccessId() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $query->access_ids = ACCESS_PUBLIC; $qb = Select::fromTable(EntityTable::TABLE_NAME, 'alias'); @@ -231,6 +239,7 @@ public function testBuildQueryFromTypeSubtypePairs() { $query = new EntityWhereClause(); $query->ignore_access = true; $query->use_enabled_clause = false; + $query->show_deleted = false; $query->type_subtype_pairs = [ 'object' => ['blog', 'file'], 'group' => ['community'], @@ -252,7 +261,7 @@ public function testBuildQueryWithAccessContraint() { $access->viewer_guid = 5; $parts[] = $access->prepare($this->qb, 'alias'); - $parts[] = $this->qb->expr()->eq('alias.guid', ':qb4'); + $parts[] = $this->qb->expr()->eq('alias.guid', ':qb5'); $this->qb->param(1, ELGG_VALUE_INTEGER); $expected = $this->qb->merge($parts); diff --git a/engine/tests/phpunit/unit/Elgg/Database/EntityTableUnitTest.php b/engine/tests/phpunit/unit/Elgg/Database/EntityTableUnitTest.php index e3e4fb279d1..e87503cf04a 100644 --- a/engine/tests/phpunit/unit/Elgg/Database/EntityTableUnitTest.php +++ b/engine/tests/phpunit/unit/Elgg/Database/EntityTableUnitTest.php @@ -63,6 +63,53 @@ public function testCanUpdateLastAction() { $this->assertEquals($last_action, $new_last_action); $this->assertEquals($last_action, $object->last_action); } + + public function testCanUpdateTimeDeleted() { + // Set up the current time + _elgg_services()->entityTable->setCurrentTime(); + $currentTime = _elgg_services()->entityTable->getCurrentTime()->getTimestamp(); + + // Create an object + $object = $this->createObject(); + + // Create the update query for empty params + $update = Update::table(EntityTable::TABLE_NAME); + $update->set('time_deleted', $update->param($currentTime, ELGG_VALUE_TIMESTAMP)) + ->where($update->compare('guid', '=', $object->guid, ELGG_VALUE_GUID)); + + // Add the testing query specification + $updateQuerySpec = [ + 'sql' => $update->getSQL(), + 'params' => $update->getParameters(), + 'row_count' => 1, + ]; + _elgg_services()->db->addQuerySpec($updateQuerySpec); + + // Call the updateTimeSoftDeleted function without passing a timestamp + $time_deleted = $object->updateTimeDeleted(); + $this->assertEquals($currentTime, $time_deleted); + $this->assertEquals($currentTime, $object->time_deleted); + + // Call the updateTimeSoftDeleted function with a new timestamp + $new_time_deleted = $currentTime + 3600; // Add 1 hour + + // Create the update query + $update = Update::table(EntityTable::TABLE_NAME); + $update->set('time_deleted', $update->param($new_time_deleted, ELGG_VALUE_TIMESTAMP)) + ->where($update->compare('guid', '=', $object->guid, ELGG_VALUE_GUID)); + + // Add the testing query specification + $updateQuerySpec = [ + 'sql' => $update->getSQL(), + 'params' => $update->getParameters(), + 'row_count' => 1, + ]; + _elgg_services()->db->addQuerySpec($updateQuerySpec); + + $time_deleted = $object->updateTimeDeleted($new_time_deleted); + $this->assertEquals($new_time_deleted, $time_deleted); + $this->assertEquals($new_time_deleted, $object->time_deleted); + } public function testGetRowWithNonExistingGUID() { $this->assertNull(_elgg_services()->entityTable->getRow(-1)); diff --git a/engine/tests/phpunit/unit/Elgg/InvokerServiceUnitTest.php b/engine/tests/phpunit/unit/Elgg/InvokerServiceUnitTest.php new file mode 100644 index 00000000000..c95021dd5ae --- /dev/null +++ b/engine/tests/phpunit/unit/Elgg/InvokerServiceUnitTest.php @@ -0,0 +1,142 @@ +invoker = _elgg_services()->invoker; + $this->session_manager = _elgg_services()->session_manager; + } + + /** + * @dataProvider callFlagProvider + */ + public function testCallFlags($flag1, $flag2, $getter_function, $setter_function, $default_value) { + // test default value + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + // test first flag + $test1 = $this->invoker->call($flag1, function() use ($getter_function, $default_value) { + $this->assertEquals(!$default_value, $this->session_manager->$getter_function()); + + return true; + }); + $this->assertTrue($test1); + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + // test second flag + $test2 = $this->invoker->call($flag2, function() use ($getter_function, $default_value) { + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + return true; + }); + $this->assertTrue($test2); + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + // test nesting of calls + $test3 = $this->invoker->call($flag1, function() use ($flag2, $getter_function, $default_value) { + $this->assertEquals(!$default_value, $this->session_manager->$getter_function()); + + $test4 = $this->invoker->call($flag2, function() use ($getter_function, $default_value) { + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + return true; + }); + $this->assertTrue($test4); + + return true; + }); + $this->assertTrue($test3); + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + } + + /** + * @dataProvider callFlagProvider + */ + public function testCallFlagsWithExceptions($flag1, $flag2, $getter_function, $setter_function, $default_value) { + // test default value + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + // test first flag + $e = null; + $called = false; + try { + $this->invoker->call($flag1, function () use ($getter_function, $default_value, &$called) { + $this->assertEquals(!$default_value, $this->session_manager->$getter_function()); + + $called = true; + + throw new InvalidArgumentException(); + }); + } catch (InvalidArgumentException $e) { + } + + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertTrue($called); + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + // test second flag + $e = null; + $called = false; + try { + $this->invoker->call($flag2, function () use ($getter_function, $default_value, &$called) { + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + $called = true; + + throw new InvalidArgumentException(); + }); + } catch (InvalidArgumentException $e) { + } + + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertTrue($called); + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + // test nesting of calls + $e = null; + $called = 0; + try { + $this->invoker->call($flag1, function () use ($flag2, $getter_function, $default_value, &$called) { + $this->assertEquals(!$default_value, $this->session_manager->$getter_function()); + + $called++; + + try { + $this->invoker->call($flag2, function () use ($getter_function, $default_value, &$called) { + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + $called++; + + throw new InvalidArgumentException(); + }); + } catch (InvalidArgumentException $e) { + } + + throw new InvalidArgumentException(); + }); + } catch (InvalidArgumentException $e) { + } + + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertEquals(2, $called); + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + + // at the end the default should be restored + $this->assertEquals($default_value, $this->session_manager->$getter_function()); + } + + public static function callFlagProvider(): array { + return [ + [ELGG_IGNORE_ACCESS, ELGG_ENFORCE_ACCESS, 'getIgnoreAccess', 'setIgnoreAccess', false], + [ELGG_SHOW_DISABLED_ENTITIES, ELGG_HIDE_DISABLED_ENTITIES, 'getDisabledEntityVisibility', 'setDisabledEntityVisibility', false], + [ELGG_SHOW_DELETED_ENTITIES, ELGG_HIDE_DELETED_ENTITIES, 'getDeletedEntityVisibility', 'setDeletedEntityVisibility', false], + ]; + } +} diff --git a/engine/tests/phpunit/unit/Elgg/SessionManagerServiceUnitTest.php b/engine/tests/phpunit/unit/Elgg/SessionManagerServiceUnitTest.php index c9238beb837..6d841440386 100644 --- a/engine/tests/phpunit/unit/Elgg/SessionManagerServiceUnitTest.php +++ b/engine/tests/phpunit/unit/Elgg/SessionManagerServiceUnitTest.php @@ -29,6 +29,14 @@ function testDisabledEntityVisibility() { $this->assertTrue($this->service->setDisabledEntityVisibility(false)); // returns old state $this->assertFalse($this->service->getDisabledEntityVisibility()); } + + function testDeletedEntityVisibility() { + $this->assertFalse($this->service->getDeletedEntityVisibility()); // service inits false + $this->assertFalse($this->service->setDeletedEntityVisibility(true)); // returns old state + $this->assertTrue($this->service->getDeletedEntityVisibility()); + $this->assertTrue($this->service->setDeletedEntityVisibility(false)); // returns old state + $this->assertFalse($this->service->getDeletedEntityVisibility()); + } function testSettingLoggedInUser() { $this->assertNull($this->service->getLoggedInUser()); // service inits null diff --git a/engine/tests/phpunit/unit/ElggEntityUnitTest.php b/engine/tests/phpunit/unit/ElggEntityUnitTest.php index bb15737d092..e1d4ba9533a 100644 --- a/engine/tests/phpunit/unit/ElggEntityUnitTest.php +++ b/engine/tests/phpunit/unit/ElggEntityUnitTest.php @@ -25,7 +25,9 @@ public function testDefaultAttributes() { $this->assertEquals(null, $this->obj->time_created); $this->assertEquals(null, $this->obj->time_updated); $this->assertEquals(null, $this->obj->last_action); + $this->assertEquals(null, $this->obj->time_deleted); $this->assertEquals('yes', $this->obj->enabled); + $this->assertEquals('no', $this->obj->deleted); } /** @@ -53,6 +55,7 @@ public static function protectedAttributeProvider() { return [ ['subtype'], ['enabled'], + ['deleted'], ]; } @@ -213,6 +216,8 @@ public function testToObject() { 'time_updated', 'container_guid', 'owner_guid', + 'deleted', + 'time_deleted', 'url', 'read_access', ); diff --git a/engine/tests/phpunit/unit/ElggObjectUnitTest.php b/engine/tests/phpunit/unit/ElggObjectUnitTest.php index 5b7ea8cbfb4..8e0b6977a6b 100644 --- a/engine/tests/phpunit/unit/ElggObjectUnitTest.php +++ b/engine/tests/phpunit/unit/ElggObjectUnitTest.php @@ -158,6 +158,8 @@ public function testCanExportObject() { $prep->container_guid = $object->getContainerGUID(); $prep->time_created = date('c', $object->getTimeCreated()); $prep->time_updated = date('c', $object->getTimeUpdated()); + $prep->deleted = $object->deleted; + $prep->time_deleted = $object->time_deleted; $prep->url = $object->getURL(); $prep->read_access = (int) $object->access_id; diff --git a/engine/tests/phpunit/unit/ElggPluginUnitTest.php b/engine/tests/phpunit/unit/ElggPluginUnitTest.php index 3651d21a24c..8dca8c26fea 100644 --- a/engine/tests/phpunit/unit/ElggPluginUnitTest.php +++ b/engine/tests/phpunit/unit/ElggPluginUnitTest.php @@ -31,6 +31,8 @@ public function testConstructsFromDatabaseRow() { $row->enabled = 'yes'; $row->time_created = $plugin->time_created; $row->time_updated = null; + $row->deleted = 'no'; + $row->time_deleted = 0; $constructed = new ElggPlugin($row); diff --git a/languages/en.php b/languages/en.php index 26892c80f53..d988bf73951 100644 --- a/languages/en.php +++ b/languages/en.php @@ -54,6 +54,7 @@ 'ElggEntity:Error:SetSubtype' => 'Use %s instead of the magic setter for "subtype"', 'ElggEntity:Error:SetEnabled' => 'Use %s instead of the magic setter for "enabled"', + 'ElggEntity:Error:SetDeleted' => 'Use %s instead of the magic setter for "deleted"', 'ElggUser:Error:SetAdmin' => 'Use %s instead of the magic setter for "admin"', 'ElggUser:Error:SetBanned' => 'Use %s instead of the magic setter for "banned"', @@ -1129,6 +1130,7 @@ 'preview' => "Preview", 'edit' => "Edit", 'delete' => "Delete", + 'trash' => "Trash", 'accept' => "Accept", 'reject' => "Reject", 'decline' => "Decline", @@ -1256,6 +1258,8 @@ 'status:unavailable' => 'Unavailable', 'status:active' => 'Active', 'status:inactive' => 'Inactive', + 'status:deleted' => 'Deleted', + 'status:trashed' => 'Trashed', /** * Generic sorts @@ -1304,6 +1308,9 @@ 'edit:this' => 'Edit this', 'delete:this' => 'Delete this', + 'trash:this' => 'Trash this', + 'restore:this' => 'Restore this', + 'restore:this:move' => 'Restore and move this', 'comment:this' => 'Comment on this', /** @@ -1311,6 +1318,9 @@ */ 'deleteconfirm' => "Are you sure you want to delete this item?", + 'trashconfirm' => "Are you sure you want to trash this item?", + 'restoreconfirm' => "Are you sure you want to restore this item?", + 'restoreandmoveconfirm'=> "Are you sure you want to restore and move this item?", 'deleteconfirm:plural' => "Are you sure you want to delete these items?", 'fileexists' => "A file has already been uploaded. To replace it, select a new one below", 'input:file:upload_limit' => 'Maximum allowed file size is %s', @@ -1480,6 +1490,7 @@ 'admin:legend:system' => 'System', 'admin:legend:caching' => 'Caching', 'admin:legend:content' => 'Content', + 'admin:legend:comments' => 'Comments', 'admin:legend:content_access' => 'Content Access', 'admin:legend:site_access' => 'Site Access', 'admin:legend:debug' => 'Debugging and Logging', @@ -1521,6 +1532,10 @@ 'config:content:mentions_display_format:help' => "This decides how a mentioned user will be visible in your content", 'config:content:mentions_display_format:username' => "Username", 'config:content:mentions_display_format:display_name' => "Display name", + 'config:content:trash_enabled:label' => "Enable trash", + 'config:content:trash_enabled:help' => "When deleting an item it can be moved to the trash before it's permanently deleted. Trashed items can be restored by a user.", + 'config:content:trash_retention:label' => "Number of days content will remain in the trash once deleted", + 'config:content:trash_retention:help' => "You can configure how many days deleted entities are stored in the trash. After the retention period the item in the trash will be permanently deleted. Use 0 to keep trashed items indefinitely.", 'config:email' => "Email", 'config:email_html_part:label' => "Enable HTML mail", 'config:email_html_part:help' => "Outgoing mail will be wrapped in a HTML template", @@ -1714,6 +1729,12 @@ 'entity:delete:permission_denied' => 'You do not have permissions to delete this item.', 'entity:delete:success' => '%s has been deleted.', 'entity:delete:fail' => '%s could not be deleted.', + + 'entity:restore:item' => 'Item', + 'entity:restore:item_not_found' => 'Item not found.', + 'entity:restore:permission_denied' => 'You do not have permissions to restore this item.', + 'entity:restore:success' => '%s has been restored.', + 'entity:restore:fail' => '%s could not be restored.', 'entity:subscribe' => "Subscribe", 'entity:subscribe:disabled' => "Your default notification settings prevent you from subscribing to this content", @@ -1786,6 +1807,29 @@ %s ------------------------------------------------------------------------', +/** + * Trash + */ + 'trash:menu:page' => "Trash", + + 'trash:imprint:actor' => "Deleted by: %s", + 'trash:imprint:type' => "Type: %s", + + 'trash:owner:title' => "Trash", + 'trash:owner:title_owner' => "%s's trash", + 'trash:container:title' => "%s's trash", + + 'trash:no_results' => "No items found in the trash", + + 'trash:notice:retention' => "Trashed items will automatically be removed after %s days.", + + 'trash:restore:container:owner' => "You can restore this trashed item to your personal section since the original group has also been removed.", + 'trash:restore:container:choose' => "Since the original group for this item has been removed, you can choose where to restore the item.", + 'trash:restore:container:group' => "Restore in a different group", + 'trash:restore:group' => "Search for a group", + 'trash:restore:group:help' => "Make sure the selected group has the feature active for the item or an error may occure.", + 'trash:restore:owner' => "Restore to the owner (%s)", + /** * Miscellaneous */ diff --git a/mod/blog/elgg-plugin.php b/mod/blog/elgg-plugin.php index 3971ed1d1f2..f126e6c8f56 100644 --- a/mod/blog/elgg-plugin.php +++ b/mod/blog/elgg-plugin.php @@ -18,6 +18,7 @@ 'commentable' => true, 'searchable' => true, 'likable' => true, + 'restorable' => true, ], ], ], diff --git a/mod/bookmarks/elgg-plugin.php b/mod/bookmarks/elgg-plugin.php index 40d8bcbeaf2..4a9f382915a 100644 --- a/mod/bookmarks/elgg-plugin.php +++ b/mod/bookmarks/elgg-plugin.php @@ -18,6 +18,7 @@ 'commentable' => true, 'searchable' => true, 'likable' => true, + 'restorable' => true, ], ], ], diff --git a/mod/developers/actions/developers/entity_explorer_delete.php b/mod/developers/actions/developers/entity_explorer_delete.php index d9eddeacb57..21881077b6d 100644 --- a/mod/developers/actions/developers/entity_explorer_delete.php +++ b/mod/developers/actions/developers/entity_explorer_delete.php @@ -1,6 +1,6 @@ delete(); + $entity->delete(true, true); } break; case 'metadata': diff --git a/mod/developers/classes/Elgg/Developers/Menus/EntityExplorer.php b/mod/developers/classes/Elgg/Developers/Menus/EntityExplorer.php index 8081a06c943..174441d49cc 100644 --- a/mod/developers/classes/Elgg/Developers/Menus/EntityExplorer.php +++ b/mod/developers/classes/Elgg/Developers/Menus/EntityExplorer.php @@ -26,16 +26,18 @@ public static function register(\Elgg\Event $event): ?MenuItems { $result = $event->getValue(); // link to entity - $url = $entity->getURL(); - if (!empty($url) && $url !== elgg_get_site_url()) { - $result[] = \ElggMenuItem::factory([ - 'name' => 'view', - 'icon' => 'eye', - 'text' => elgg_echo('developers:entity_explorer:view_entity'), - 'href' => $url, - 'link_class' => ['elgg-button', 'elgg-button-action'], - 'priority' => 50, - ]); + if ($entity->deleted !== 'yes') { + $url = $entity->getURL(); + if (!empty($url) && $url !== elgg_get_site_url()) { + $result[] = \ElggMenuItem::factory([ + 'name' => 'view', + 'icon' => 'eye', + 'text' => elgg_echo('developers:entity_explorer:view_entity'), + 'href' => $url, + 'link_class' => ['elgg-button', 'elgg-button-action'], + 'priority' => 50, + ]); + } } // delete entity diff --git a/mod/developers/views/default/admin/develop_tools/entity_explorer.php b/mod/developers/views/default/admin/develop_tools/entity_explorer.php index ae867eec985..2c417dbc3f1 100644 --- a/mod/developers/views/default/admin/develop_tools/entity_explorer.php +++ b/mod/developers/views/default/admin/develop_tools/entity_explorer.php @@ -14,7 +14,7 @@ return; } -echo elgg_call(ELGG_SHOW_DISABLED_ENTITIES, function() use ($guid) { +echo elgg_call(ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($guid) { $entity = get_entity($guid); if (!$entity) { return elgg_echo('notfound'); diff --git a/mod/developers/views/default/admin/develop_tools/entity_explorer/attributes.php b/mod/developers/views/default/admin/develop_tools/entity_explorer/attributes.php index a7322fa4258..64de98e3a44 100644 --- a/mod/developers/views/default/admin/develop_tools/entity_explorer/attributes.php +++ b/mod/developers/views/default/admin/develop_tools/entity_explorer/attributes.php @@ -12,10 +12,8 @@ return; } -$entity_rows = ['type', 'subtype', 'owner_guid', 'container_guid', 'access_id', 'time_created', 'time_updated', 'last_action', 'enabled']; - $rows = []; -foreach ($entity_rows as $entity_row) { +foreach (\ElggEntity::PRIMARY_ATTR_NAMES as $entity_row) { $row = []; $row[] = elgg_format_element('td', [], $entity_row); @@ -23,6 +21,8 @@ $is_text = true; $value = $entity->$entity_row; switch ($entity_row) { + case 'guid': + continue(2); case 'owner_guid': case 'container_guid': $is_text = false; @@ -40,9 +40,9 @@ break; case 'access_id': $title = elgg_get_readable_access_level($value); - break; case 'time_created': + case 'time_deleted': case 'time_updated': case 'last_action': $title = Values::normalizeTime($value)->formatLocale(elgg_echo('friendlytime:date_format')); diff --git a/mod/developers/views/default/admin/develop_tools/inspect/seeders.php b/mod/developers/views/default/admin/develop_tools/inspect/seeders.php index 5613ce59df1..418bc18060a 100644 --- a/mod/developers/views/default/admin/develop_tools/inspect/seeders.php +++ b/mod/developers/views/default/admin/develop_tools/inspect/seeders.php @@ -23,7 +23,7 @@ $rows = []; -elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($seeders, &$rows) { +elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($seeders, &$rows) { foreach ($seeders as $seeder) { $row = []; /* @var $seed Seed */ diff --git a/mod/file/elgg-plugin.php b/mod/file/elgg-plugin.php index 9e5086e5f66..0b5de044858 100644 --- a/mod/file/elgg-plugin.php +++ b/mod/file/elgg-plugin.php @@ -19,6 +19,7 @@ 'commentable' => true, 'searchable' => true, 'likable' => true, + 'restorable' => true, ], ], ], diff --git a/mod/groups/classes/Elgg/Groups/Menus/Entity.php b/mod/groups/classes/Elgg/Groups/Menus/Entity.php index 1bac88aeee8..04b272e5283 100644 --- a/mod/groups/classes/Elgg/Groups/Menus/Entity.php +++ b/mod/groups/classes/Elgg/Groups/Menus/Entity.php @@ -55,7 +55,11 @@ public static function registerFeature(\Elgg\Event $event) { if (!$entity instanceof \ElggGroup) { return; } - + + if ($entity->deleted === 'yes') { + return; + } + if (!elgg_is_admin_logged_in()) { return; } diff --git a/mod/groups/classes/Elgg/Groups/Menus/Page.php b/mod/groups/classes/Elgg/Groups/Menus/Page.php index 86e1b1a1575..8ec1a25e551 100644 --- a/mod/groups/classes/Elgg/Groups/Menus/Page.php +++ b/mod/groups/classes/Elgg/Groups/Menus/Page.php @@ -66,6 +66,17 @@ public static function registerGroupProfile(\Elgg\Event $event) { ]); } + // add link to group trash + if (elgg_get_config('trash_enabled')) { + $return[] = \ElggMenuItem::factory([ + 'name' => 'trash', + 'text' => elgg_echo('trash:menu:page'), + 'href' => elgg_generate_url('trash:container', [ + 'guid' => $page_owner->guid, + ]), + ]); + } + return $return; } diff --git a/mod/groups/elgg-plugin.php b/mod/groups/elgg-plugin.php index 2b7d2cf8b69..2237a3797e1 100644 --- a/mod/groups/elgg-plugin.php +++ b/mod/groups/elgg-plugin.php @@ -24,6 +24,7 @@ 'commentable' => false, 'searchable' => true, 'likable' => true, + 'restorable' => true, ], ], ], diff --git a/mod/groups/lib/functions.php b/mod/groups/lib/functions.php index 9ce4f8f7386..e8bc2cc44eb 100644 --- a/mod/groups/lib/functions.php +++ b/mod/groups/lib/functions.php @@ -21,7 +21,7 @@ function groups_get_group_leave_menu_item(\ElggGroup $group, \ElggUser $user = n return false; } - if (!$group->isMember($user) || ($group->owner_guid === $user->guid)) { + if (!$group->isMember($user) || ($group->owner_guid === $user->guid) || $group->deleted === 'yes') { // a member can leave a group if he/she doesn't own it return false; } @@ -55,7 +55,7 @@ function groups_get_group_join_menu_item(\ElggGroup $group, \ElggUser $user = nu return false; } - if ($group->isMember($user)) { + if ($group->isMember($user) || $group->deleted === 'yes') { return false; } diff --git a/mod/likes/classes/Elgg/Likes/Menus/Social.php b/mod/likes/classes/Elgg/Likes/Menus/Social.php index 397b78cff17..e366616b9ab 100644 --- a/mod/likes/classes/Elgg/Likes/Menus/Social.php +++ b/mod/likes/classes/Elgg/Likes/Menus/Social.php @@ -27,6 +27,10 @@ public static function register(\Elgg\Event $event) { if (!$entity->hasCapability('likable')) { return; } + + if ($entity->deleted === 'yes') { + return; + } $return = $event->getValue(); diff --git a/mod/messages/classes/Elgg/Messages/User.php b/mod/messages/classes/Elgg/Messages/User.php index e9d7d22c8e5..8fc0b2803b5 100644 --- a/mod/messages/classes/Elgg/Messages/User.php +++ b/mod/messages/classes/Elgg/Messages/User.php @@ -24,18 +24,21 @@ public static function purgeMessages(\Elgg\Event $event) { } // make sure we delete them all - elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($user) { - $batch = new \ElggBatch('elgg_get_entities', [ + elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($user) { + /* @var $batch \ElggBatch */ + $batch = elgg_get_entities([ 'type' => 'object', 'subtype' => 'messages', 'metadata_name_value_pairs' => [ 'fromId' => $user->guid, ], + 'batch' => true, + 'batch_inc_offset' => false, 'limit' => false, ]); - $batch->setIncrementOffset(false); + /* @var $e \ElggMessage */ foreach ($batch as $e) { - $e->delete(); + $e->delete(true, true); } }); } diff --git a/mod/pages/classes/Elgg/Pages/Menus/Entity.php b/mod/pages/classes/Elgg/Pages/Menus/Entity.php index 4bd44368836..4d253b5ee6b 100644 --- a/mod/pages/classes/Elgg/Pages/Menus/Entity.php +++ b/mod/pages/classes/Elgg/Pages/Menus/Entity.php @@ -22,6 +22,10 @@ public static function register(\Elgg\Event $event) { if (!$entity instanceof \ElggPage) { return; } + + if ($entity->deleted === 'yes') { + return; + } if (!$entity->canEdit()) { return; diff --git a/mod/pages/classes/ElggPage.php b/mod/pages/classes/ElggPage.php index b2800bd9dd7..9366cf92a94 100644 --- a/mod/pages/classes/ElggPage.php +++ b/mod/pages/classes/ElggPage.php @@ -128,7 +128,7 @@ public function setParentEntity(\ElggPage $entity = null): bool { /** * {@inheritdoc} */ - public function delete(bool $recursive = true): bool { + public function delete(bool $recursive = true, bool $persistent = null): bool { $parent_guid = $this->getParentGUID(); $guid = $this->guid; @@ -153,7 +153,7 @@ public function delete(bool $recursive = true): bool { }); }; - $result = parent::delete($recursive); + $result = parent::delete($recursive, $persistent); if ($result) { $move_children(); diff --git a/mod/pages/elgg-plugin.php b/mod/pages/elgg-plugin.php index 7efd00f4473..01bc93d5c77 100644 --- a/mod/pages/elgg-plugin.php +++ b/mod/pages/elgg-plugin.php @@ -20,6 +20,7 @@ 'commentable' => true, 'searchable' => true, 'likable' => true, + 'restorable' => true, ], ], ], diff --git a/mod/reportedcontent/classes/Elgg/ReportedContent/Menus/Entity.php b/mod/reportedcontent/classes/Elgg/ReportedContent/Menus/Entity.php index 126b507a881..b29adc54adb 100644 --- a/mod/reportedcontent/classes/Elgg/ReportedContent/Menus/Entity.php +++ b/mod/reportedcontent/classes/Elgg/ReportedContent/Menus/Entity.php @@ -59,6 +59,10 @@ public static function registerEntityReporting(\Elgg\Event $event) { if (!$entity instanceof \ElggEntity || !elgg_is_logged_in()) { return; } + + if ($entity->deleted === 'yes') { + return; + } $report_this = (bool) $event->getParam('report_this', $entity->hasCapability('searchable')); if (!$report_this) { diff --git a/mod/site_notifications/classes/Elgg/SiteNotifications/Cron.php b/mod/site_notifications/classes/Elgg/SiteNotifications/Cron.php index 004a4ab16d7..bfda1856f39 100644 --- a/mod/site_notifications/classes/Elgg/SiteNotifications/Cron.php +++ b/mod/site_notifications/classes/Elgg/SiteNotifications/Cron.php @@ -43,7 +43,7 @@ public static function cleanupSiteNotificationsWithRemovedLinkedEntities(\Elgg\E /* @var $cron_logger \Elgg\Logger\Cron */ $cron_logger = $event->getParam('logger'); - $count = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() { + $count = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() { $count = 0; $max_runtime = 120; // 2 minutes $start_time = microtime(true); @@ -73,7 +73,7 @@ function (QueryBuilder $qb, $main_alias) { /* @var $entity \ElggEntity */ foreach ($batch as $entity) { - if (!$entity->delete()) { + if (!$entity->delete(true, true)) { $batch->reportFailure(); continue; } @@ -113,7 +113,7 @@ public static function cleanupUnreadSiteNotifications(\Elgg\Event $event): void $max_runtime = static::CLEANUP_MAX_DURATION[$event->getType()]; - $count = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($days, $max_runtime) { + $count = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($days, $max_runtime) { $count = 0; $start_time = microtime(true); @@ -182,7 +182,7 @@ public static function cleanupReadSiteNotifications(\Elgg\Event $event): void { $max_runtime = static::CLEANUP_MAX_DURATION[$event->getType()]; - $count = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($days, $max_runtime) { + $count = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES | ELGG_DISABLE_SYSTEM_LOG, function() use ($days, $max_runtime) { $count = 0; $start_time = microtime(true); diff --git a/mod/thewire/classes/Elgg/TheWire/Menus/Entity.php b/mod/thewire/classes/Elgg/TheWire/Menus/Entity.php index ffd9f95d669..51a9b775927 100644 --- a/mod/thewire/classes/Elgg/TheWire/Menus/Entity.php +++ b/mod/thewire/classes/Elgg/TheWire/Menus/Entity.php @@ -25,7 +25,11 @@ public static function register(\Elgg\Event $event) { if (!$entity instanceof \ElggWire) { return; } - + + if ($entity->deleted === 'yes') { + return; + } + $menu = $event->getValue(); $menu->remove('edit'); diff --git a/mod/web_services/classes/ElggApiKey.php b/mod/web_services/classes/ElggApiKey.php index 63fd910c805..db3ef8da660 100644 --- a/mod/web_services/classes/ElggApiKey.php +++ b/mod/web_services/classes/ElggApiKey.php @@ -40,10 +40,10 @@ protected function create() { /** * {@inheritdoc} */ - public function delete(bool $recursive = true): bool { + protected function persistentDelete(bool $recursive = true): bool { $public_key = $this->public_key; - if (!parent::delete($recursive)) { + if (!parent::persistentDelete($recursive)) { return false; } diff --git a/views/default/admin/statistics/numentities.php b/views/default/admin/statistics/numentities.php index 1466576fb01..cb12614287e 100644 --- a/views/default/admin/statistics/numentities.php +++ b/views/default/admin/statistics/numentities.php @@ -1,6 +1,19 @@ [ + function(QueryBuilder $qb, $main_alias) { + return $qb->compare("{$main_alias}.deleted", '=', 'yes', ELGG_VALUE_STRING); + }, + ], + ]); +}); $searchable = []; $other = []; @@ -15,9 +28,15 @@ } if (elgg_entity_has_capability($type, $subtype, 'searchable')) { - $searchable[$name] = $value; + $searchable[$name] = [ + $value, + elgg_extract($subtype, elgg_extract($type, $trashed_stats, [])), + ]; } else { - $other[$name] = $value; + $other[$name] = [ + $value, + elgg_extract($subtype, elgg_extract($type, $trashed_stats, [])), + ]; } } } @@ -25,23 +44,41 @@ arsort($searchable); arsort($other); -$header = '' . elgg_echo('admin:statistics:numentities:type') . ''; -$header .= '' . elgg_echo('admin:statistics:numentities:number') . ''; +$header = elgg_format_element('th', [], elgg_echo('admin:statistics:numentities:type')); +$header .= elgg_format_element('th', [], elgg_echo('total')); -$rows = ''; +$header = elgg_format_element('thead', [], elgg_format_element('tr', [], $header)); +// searchable entity stats +$rows = []; foreach ($searchable as $name => $value) { - $rows .= "{$name}{$value}"; + $cells = []; + $cells[] = elgg_format_element('td', [], $name); + + $number = $value[0] . ($value[1] ? elgg_format_element('span', ['class' => ['elgg-quiet', 'mls']], elgg_echo('status:trashed') . ': ' . $value[1]) : null); + $cells[] = elgg_format_element('td', [], $number); + + $rows[] = elgg_format_element('tr', [], implode('', $cells)); } -$body = "{$header}{$rows}
"; -echo elgg_view_module('info', elgg_echo('admin:statistics:numentities:searchable'), $body); +$rows = elgg_format_element('tbody', [], implode(PHP_EOL, $rows)); +$body = elgg_format_element('table', ['class' => 'elgg-table'], $header . $rows); +echo elgg_view_module('info', elgg_echo('admin:statistics:numentities:searchable'), $body); -$rows = ''; +// remaining entity stats +$rows = []; foreach ($other as $name => $value) { - $rows .= "{$name}{$value}"; + $cells = []; + $cells[] = elgg_format_element('td', [], $name); + + $number = $value[0] . ($value[1] ? elgg_format_element('span', ['class' => ['elgg-quiet', 'mls']], elgg_echo('status:trashed') . ': ' . $value[1]) : null); + $cells[] = elgg_format_element('td', [], $number); + + $rows[] = elgg_format_element('tr', [], implode('', $cells)); } -$body = "{$header}{$rows}
"; +$rows = elgg_format_element('tbody', [], implode(PHP_EOL, $rows)); + +$body = elgg_format_element('table', ['class' => 'elgg-table'], $header . $rows); echo elgg_view_module('info', elgg_echo('admin:statistics:numentities:other'), $body); diff --git a/views/default/admin/upgrades.php b/views/default/admin/upgrades.php index 97474271241..d724b6f92e3 100644 --- a/views/default/admin/upgrades.php +++ b/views/default/admin/upgrades.php @@ -25,7 +25,7 @@ ]); // make sure to use the same options as in \Elgg\UpgradeService::executeUpgrade() -echo elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function () use ($upgrades) { +echo elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function () use ($upgrades) { return elgg_view_entity_list($upgrades, [ 'limit' => false, 'pagination' => false, diff --git a/views/default/admin/upgrades/finished.php b/views/default/admin/upgrades/finished.php index 2cd41262e0d..88629b7c565 100644 --- a/views/default/admin/upgrades/finished.php +++ b/views/default/admin/upgrades/finished.php @@ -17,7 +17,7 @@ $items = array_slice($upgrades, $offset, $limit); // make sure to use the same options as in \Elgg\UpgradeService::executeUpgrade() -echo elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function() use ($items, $offset, $limit, $count) { +echo elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES | ELGG_SHOW_DELETED_ENTITIES, function() use ($items, $offset, $limit, $count) { return elgg_view_entity_list($items, [ 'offset' => $offset, 'limit' => $limit, diff --git a/views/default/core/settings/statistics/numentities.php b/views/default/core/settings/statistics/numentities.php index 5b0c6afc14f..b66fc0aaaf7 100644 --- a/views/default/core/settings/statistics/numentities.php +++ b/views/default/core/settings/statistics/numentities.php @@ -6,11 +6,19 @@ */ $user = elgg_extract('entity', $vars, elgg_get_page_owner_entity()); // page owner for BC reasons -if (!$user instanceof ElggUser) { +if (!$user instanceof \ElggUser) { return; } -$entity_stats = elgg_get_entity_statistics($user->guid); +$options = [ + 'owner_guid' => $user->guid, +]; + +if (!elgg_is_admin_logged_in()) { + $options['type_subtype_pairs'] = elgg_entity_types_with_capability('searchable'); +} + +$entity_stats = elgg_get_entity_statistics($options); if (empty($entity_stats)) { return; } @@ -30,9 +38,7 @@ $cells[] = elgg_format_element('td', [], elgg_format_element('b', [], "{$label}:")); $cells[] = elgg_format_element('td', [], $count); - if (elgg_entity_has_capability($type, $subtype, 'searchable') || elgg_is_admin_logged_in()) { - $rows[] = elgg_format_element('tr', [], implode(PHP_EOL, $cells)); - } + $rows[] = elgg_format_element('tr', [], implode(PHP_EOL, $cells)); } } diff --git a/views/default/forms/admin/site/settings.php b/views/default/forms/admin/site/settings.php index 79a7706da2e..09d60627c43 100644 --- a/views/default/forms/admin/site/settings.php +++ b/views/default/forms/admin/site/settings.php @@ -10,6 +10,7 @@ echo elgg_view('forms/admin/site/settings/users', $vars); echo elgg_view('forms/admin/site/settings/caching', $vars); echo elgg_view('forms/admin/site/settings/content', $vars); +echo elgg_view('forms/admin/site/settings/comments', $vars); echo elgg_view('forms/admin/site/settings/debugging', $vars); echo elgg_view('forms/admin/site/settings/email', $vars); echo elgg_view('forms/admin/site/settings/other', $vars); diff --git a/views/default/forms/admin/site/settings/comments.php b/views/default/forms/admin/site/settings/comments.php new file mode 100644 index 00000000000..febe9cd5111 --- /dev/null +++ b/views/default/forms/admin/site/settings/comments.php @@ -0,0 +1,55 @@ + 'select', + '#label' => elgg_echo('config:content:comments_max_depth'), + '#help' => elgg_echo('config:content:comments_max_depth:help'), + 'name' => 'comments_max_depth', + 'value' => elgg_get_config('comments_max_depth'), + 'options_values' => [ + 0 => elgg_echo('config:content:comments_max_depth:none'), + 2 => 2, + 3 => 3, + 4 => 4, + ], +]); + +$body .= elgg_view_field([ + '#type' => 'checkbox', + '#label' => elgg_echo('config:content:comment_box_collapses'), + '#help' => elgg_echo('config:content:comment_box_collapses:help'), + 'name' => 'comment_box_collapses', + 'checked' => (bool) elgg_get_config('comment_box_collapses'), + 'switch' => true, + 'value' => 1, +]); + +$body .= elgg_view_field([ + '#type' => 'checkbox', + '#label' => elgg_echo('config:content:comments_latest_first'), + '#help' => elgg_echo('config:content:comments_latest_first:help'), + 'name' => 'comments_latest_first', + 'checked' => (bool) elgg_get_config('comments_latest_first'), + 'switch' => true, + 'value' => 1, +]); + +$body .= elgg_view_field([ + '#type' => 'number', + '#label' => elgg_echo('config:content:comments_per_page'), + 'name' => 'comments_per_page', + 'value' => elgg_get_config('comments_per_page'), + 'min' => 1, + 'step' => 1, +]); + +$body .= elgg_view_field([ + '#type' => 'checkbox', + '#label' => elgg_echo('config:content:comments_group_only'), + 'name' => 'comments_group_only', + 'checked' => (bool) elgg_get_config('comments_group_only'), + 'switch' => true, + 'value' => 1, +]); + +echo elgg_view_module('info', elgg_echo('admin:legend:comments'), $body); diff --git a/views/default/forms/admin/site/settings/content.php b/views/default/forms/admin/site/settings/content.php index d966641593c..a2f34f2c390 100644 --- a/views/default/forms/admin/site/settings/content.php +++ b/views/default/forms/admin/site/settings/content.php @@ -56,66 +56,34 @@ $body .= elgg_view_field([ '#type' => 'select', - '#label' => elgg_echo('config:content:comments_max_depth'), - '#help' => elgg_echo('config:content:comments_max_depth:help'), - 'name' => 'comments_max_depth', - 'value' => elgg_get_config('comments_max_depth'), + '#label' => elgg_echo('config:content:mentions_display_format'), + '#help' => elgg_echo('config:content:mentions_display_format:help'), + 'name' => 'mentions_display_format', + 'value' => elgg_get_config('mentions_display_format'), 'options_values' => [ - 0 => elgg_echo('config:content:comments_max_depth:none'), - 2 => 2, - 3 => 3, - 4 => 4, + 'display_name' => elgg_echo('config:content:mentions_display_format:display_name'), + 'username' => elgg_echo('config:content:mentions_display_format:username'), ], ]); $body .= elgg_view_field([ '#type' => 'checkbox', - '#label' => elgg_echo('config:content:comment_box_collapses'), - '#help' => elgg_echo('config:content:comment_box_collapses:help'), - 'name' => 'comment_box_collapses', - 'checked' => (bool) elgg_get_config('comment_box_collapses'), - 'switch' => true, + '#label' => elgg_echo('config:content:trash_enabled:label'), + '#help' => elgg_echo('config:content:trash_enabled:help'), + 'name' => 'trash_enabled', 'value' => 1, -]); - -$body .= elgg_view_field([ - '#type' => 'checkbox', - '#label' => elgg_echo('config:content:comments_latest_first'), - '#help' => elgg_echo('config:content:comments_latest_first:help'), - 'name' => 'comments_latest_first', - 'checked' => (bool) elgg_get_config('comments_latest_first'), + 'checked' => (bool) elgg_get_config('trash_enabled'), 'switch' => true, - 'value' => 1, ]); $body .= elgg_view_field([ '#type' => 'number', - '#label' => elgg_echo('config:content:comments_per_page'), - 'name' => 'comments_per_page', - 'value' => elgg_get_config('comments_per_page'), - 'min' => 1, - 'step' => 1, -]); - -$body .= elgg_view_field([ - '#type' => 'checkbox', - '#label' => elgg_echo('config:content:comments_group_only'), - 'name' => 'comments_group_only', - 'checked' => (bool) elgg_get_config('comments_group_only'), - 'switch' => true, - 'value' => 1, -]); - -$body .= elgg_view_field([ - '#type' => 'select', - '#label' => elgg_echo('config:content:mentions_display_format'), - '#help' => elgg_echo('config:content:mentions_display_format:help'), - 'name' => 'mentions_display_format', - 'value' => elgg_get_config('mentions_display_format'), - 'options_values' => [ - 'display_name' => elgg_echo('config:content:mentions_display_format:display_name'), - 'username' => elgg_echo('config:content:mentions_display_format:username'), - ], + '#label' => elgg_echo('config:content:trash_retention:label'), + '#help' => elgg_echo('config:content:trash_retention:help'), + '#class' => ['elgg-divide-left', 'plm'], + 'name' => 'trash_retention', + 'value' => (int) elgg_get_config('trash_retention'), + 'min' => 0, ]); echo elgg_view_module('info', elgg_echo('admin:legend:content'), $body); diff --git a/views/default/forms/entity/chooserestoredestination.php b/views/default/forms/entity/chooserestoredestination.php new file mode 100644 index 00000000000..c17f8645bd2 --- /dev/null +++ b/views/default/forms/entity/chooserestoredestination.php @@ -0,0 +1,81 @@ +canEdit()) { + throw new EntityPermissionsException(); +} + +echo elgg_view_field([ + '#type' => 'hidden', + 'name' => 'entity_guid', + 'value' => $entity->guid, +]); + +$owner = $entity->getOwnerEntity(); +if ($owner instanceof \ElggUser && $owner->getGroups(['count' => true])) { + echo elgg_view('output/longtext', [ + 'value' => elgg_echo('trash:restore:container:choose'), + ]); + + echo elgg_view_field([ + '#type' => 'radio', + 'name' => 'destination_container_guid', + 'value' => 'group', + 'options_values' => [ + 'group' => elgg_echo('trash:restore:container:group'), + ], + ]); + + echo elgg_view_field([ + '#type' => 'grouppicker', + '#label' => elgg_echo('trash:restore:group'), + '#help' => elgg_echo('trash:restore:group:help'), + 'name' => 'destination_container_guid', + 'options' => [ + 'match_target' => $owner->guid, + 'match_membership' => !elgg_is_admin_logged_in(), + ], + 'limit' => 1, + ]); + + echo elgg_view_field([ + '#type' => 'radio', + 'name' => 'destination_container_guid', + 'options_values' => [ + $owner->guid => elgg_echo('trash:restore:owner', [$owner->getDisplayName()]), + ], + ]); +} else { + echo elgg_view('output/longtext', [ + 'value' => elgg_echo('trash:restore:container:owner'), + ]); + echo elgg_view_field([ + '#type' => 'hidden', + 'name' => 'destination_container_guid', + 'value' => $owner->guid, + ]); +} + +// form footer +$footer = elgg_view_field([ + '#type' => 'submit', + 'text' => elgg_echo('save'), + 'confirm' => elgg_echo('restoreandmoveconfirm'), +]); + +elgg_set_form_footer($footer); diff --git a/views/default/resources/trash/container.php b/views/default/resources/trash/container.php new file mode 100644 index 00000000000..24deb766ba7 --- /dev/null +++ b/views/default/resources/trash/container.php @@ -0,0 +1,20 @@ +getDisplayName()]), [ + 'content' => elgg_view('trash/listing/container', ['entity' => $group]), + 'filter_id' => 'trash', +]); diff --git a/views/default/resources/trash/owner.php b/views/default/resources/trash/owner.php new file mode 100644 index 00000000000..99cf80f7191 --- /dev/null +++ b/views/default/resources/trash/owner.php @@ -0,0 +1,24 @@ +guid !== elgg_get_logged_in_user_guid()) { + $title = elgg_echo('trash:owner:title_owner', [$user->getDisplayName()]); +} + +echo elgg_view_page($title, [ + 'content' => elgg_view('trash/listing/owner', ['entity' => $user]), + 'filter_id' => 'trash', + 'show_owner_block' => false, +]); diff --git a/views/default/trash/elements/imprint.php b/views/default/trash/elements/imprint.php new file mode 100644 index 00000000000..60b0155d055 --- /dev/null +++ b/views/default/trash/elements/imprint.php @@ -0,0 +1,20 @@ + 'elgg-listing-imprint', +], $imprint); diff --git a/views/default/trash/elements/imprint/actor.php b/views/default/trash/elements/imprint/actor.php new file mode 100644 index 00000000000..066ff32e226 --- /dev/null +++ b/views/default/trash/elements/imprint/actor.php @@ -0,0 +1,43 @@ +getEntitiesFromRelationship([ + 'type' => 'user', + 'relationship' => 'deleted_by', + 'inverse_relationship' => true, + 'limit' => 1, + ]); + if (!empty($actors)) { + /* @var $actor \ElggUser */ + $actor = $actors[0]; + + if ($actor->deleted !== 'yes') { + $actor_text = elgg_view_entity_url($actor); + } else { + $actor_text = $actor->getDisplayName(); + } + + $actor_text = elgg_echo('trash:imprint:actor', [$actor_text]); + } + } +} + +if (elgg_is_empty($actor_text)) { + return; +} + +echo elgg_view('trash/elements/imprint/element', [ + 'icon_name' => elgg_extract('actor_icon', $vars, 'user'), + 'content' => $actor_text, + 'class' => 'elgg-listing-actor', +]); diff --git a/views/default/trash/elements/imprint/byline.php b/views/default/trash/elements/imprint/byline.php new file mode 100644 index 00000000000..6fe2228af5c --- /dev/null +++ b/views/default/trash/elements/imprint/byline.php @@ -0,0 +1,57 @@ +getOwnerEntity()); + if ($owner instanceof \ElggEntity) { + if ($show_links && $owner->deleted !== 'yes') { + $owner_text = elgg_view_entity_url($owner); + } else { + $owner_text = $owner->getDisplayName(); + } + + $parts[] = elgg_echo('byline', [$owner_text]); + } + + $container_entity = elgg_extract('byline_container_entity', $vars, $entity->getContainerEntity()); + if ($container_entity instanceof \ElggGroup && $container_entity->guid !== elgg_get_page_owner_guid()) { + if ($show_links && $container_entity->deleted !== 'yes') { + $group_text = elgg_view_entity_url($container_entity); + } else { + $group_text = $container_entity->getDisplayName(); + } + + $parts[] = elgg_echo('byline:ingroup', [$group_text]); + } + + $byline_str = implode(' ', $parts); +} + +if (elgg_is_empty($byline_str)) { + return; +} + +echo elgg_view('trash/elements/imprint/element', [ + 'content' => $byline_str, + 'class' => 'elgg-listing-byline', +]); diff --git a/views/default/trash/elements/imprint/contents.php b/views/default/trash/elements/imprint/contents.php new file mode 100644 index 00000000000..ca233ac7cf8 --- /dev/null +++ b/views/default/trash/elements/imprint/contents.php @@ -0,0 +1,35 @@ + 'calendar', 'content' => 'Starts on Jan 12'] + */ + +$entity = elgg_extract('entity', $vars); +if (!$entity instanceof \ElggEntity) { + return; +} + +echo elgg_view('trash/elements/imprint/byline', $vars); +echo elgg_view('trash/elements/imprint/time', $vars); +echo elgg_view('trash/elements/imprint/actor', $vars); +echo elgg_view('trash/elements/imprint/type', $vars); + +$imprint = elgg_extract('imprint', $vars); +if (!empty($imprint)) { + foreach ($imprint as $item) { + echo elgg_view('trash/elements/imprint/element', $item); + } +} diff --git a/views/default/trash/elements/imprint/element.php b/views/default/trash/elements/imprint/element.php new file mode 100644 index 00000000000..a16653b3dbd --- /dev/null +++ b/views/default/trash/elements/imprint/element.php @@ -0,0 +1,26 @@ + elgg_extract_class($vars), + 'title' => elgg_extract('title', $vars), +], $result); diff --git a/views/default/trash/elements/imprint/time.php b/views/default/trash/elements/imprint/time.php new file mode 100644 index 00000000000..d135b909762 --- /dev/null +++ b/views/default/trash/elements/imprint/time.php @@ -0,0 +1,39 @@ +time_deleted; +} + +if (!$time) { + return; +} + +$content = elgg_view_friendly_time($time); +$time_href = elgg_extract('time_href', $vars); +if (!empty($time_href)) { + $content = elgg_view_url($time_href, $content); +} + +echo elgg_view('trash/elements/imprint/element', [ + 'icon_name' => elgg_extract('time_icon', $vars, 'trash-alt'), + 'content' => $content, + 'class' => 'elgg-listing-time', +]); diff --git a/views/default/trash/elements/imprint/type.php b/views/default/trash/elements/imprint/type.php new file mode 100644 index 00000000000..86777d8fb70 --- /dev/null +++ b/views/default/trash/elements/imprint/type.php @@ -0,0 +1,40 @@ +getSubtype()); + $lan_keys = [ + "item:{$entity->getType()}:{$entity->getSubtype()}", + "collection:{$entity->getType()}:{$entity->getSubtype()}", + ]; + foreach ($lan_keys as $key) { + if (!elgg_language_key_exists($key)) { + continue; + } + + $type_text = elgg_echo($key); + break; + } + + $type_text = elgg_echo('trash:imprint:type', [$type_text]); + } +} + +if (elgg_is_empty($type_text)) { + return; +} + +echo elgg_view('trash/elements/imprint/element', [ + 'icon_name' => elgg_extract('type_icon', $vars, false), + 'content' => $type_text, + 'class' => 'elgg-listing-type', +]); diff --git a/views/default/trash/elements/metadata.php b/views/default/trash/elements/metadata.php new file mode 100644 index 00000000000..236cb9eb5c2 --- /dev/null +++ b/views/default/trash/elements/metadata.php @@ -0,0 +1,33 @@ + $entity, + 'prepare_dropdown' => true, + ]); + } +} + +if (!$metadata) { + return; +} + +echo elgg_format_element('div', [ + 'class' => [ + 'elgg-listing-summary-metadata', + ] +], $metadata); diff --git a/views/default/trash/elements/notice.php b/views/default/trash/elements/notice.php new file mode 100644 index 00000000000..bc2cc00f040 --- /dev/null +++ b/views/default/trash/elements/notice.php @@ -0,0 +1,13 @@ + false, +]); diff --git a/views/default/trash/elements/subtitle.php b/views/default/trash/elements/subtitle.php new file mode 100644 index 00000000000..8444fca4104 --- /dev/null +++ b/views/default/trash/elements/subtitle.php @@ -0,0 +1,22 @@ + [ + 'elgg-listing-summary-subtitle', + 'elgg-subtext', + ] +], $subtitle); diff --git a/views/default/trash/elements/title.php b/views/default/trash/elements/title.php new file mode 100644 index 00000000000..5c1d031d594 --- /dev/null +++ b/views/default/trash/elements/title.php @@ -0,0 +1,27 @@ +getDisplayName(), 100); + if (elgg_is_empty($title)) { + return; + } +} + +if (elgg_is_empty($title)) { + return; +} + +echo elgg_format_element('div', ['class' => 'elgg-listing-summary-title'], $title); diff --git a/views/default/trash/entity.php b/views/default/trash/entity.php new file mode 100644 index 00000000000..6f7fbc4abd1 --- /dev/null +++ b/views/default/trash/entity.php @@ -0,0 +1,26 @@ +getType()}/{$entity->getSubtype()}", + "trash/{$entity->getType()}/default", + 'trash/entity/default', +]; + +foreach ($views as $view) { + if (!elgg_view_exists($view)) { + continue; + } + + echo elgg_view($view, $vars); + break; +} diff --git a/views/default/trash/entity/default.php b/views/default/trash/entity/default.php new file mode 100644 index 00000000000..c1f0a8d5527 --- /dev/null +++ b/views/default/trash/entity/default.php @@ -0,0 +1,30 @@ +guid; + +echo elgg_view_image_block('', $summary, $params); diff --git a/views/default/trash/listing/all.php b/views/default/trash/listing/all.php new file mode 100644 index 00000000000..cb29627ba7d --- /dev/null +++ b/views/default/trash/listing/all.php @@ -0,0 +1,40 @@ + elgg_entity_types_with_capability('restorable'), + 'no_results' => elgg_echo('trash:no_results'), + 'list_class' => 'elgg-listing-trash', + 'item_view' => 'trash/entity', + 'pagination_behaviour' => 'ajax-replace', + 'limit' => max(20, (int) elgg_get_config('default_limit'), (int) get_input('limit')), + 'sort_by' => [ + 'property' => 'time_deleted', + 'direction' => 'desc', + ], +]; + +$options = (array) elgg_extract('options', $vars, []); +$options = array_merge($defaults, $options); + +// ensure only deleted items are shown +if (!isset($options['wheres'])) { + $options['wheres'] = []; +} + +$options['wheres'][] = function(QueryBuilder $qb, $main_alias) { + return $qb->compare("{$main_alias}.deleted", '=', 'yes', ELGG_VALUE_STRING); +}; + +$vars['options'] = $options; +echo elgg_view('trash/elements/notice', $vars); + +echo elgg_call(ELGG_SHOW_DELETED_ENTITIES, function() use ($options) { + return elgg_list_entities($options); +}); diff --git a/views/default/trash/listing/container.php b/views/default/trash/listing/container.php new file mode 100644 index 00000000000..4456eaf5977 --- /dev/null +++ b/views/default/trash/listing/container.php @@ -0,0 +1,22 @@ + $entity->guid, + 'preload_containers' => false, +]; + +$vars['options'] = array_merge($options, $owner_options); + +echo elgg_view('trash/listing/all', $vars); diff --git a/views/default/trash/listing/owner.php b/views/default/trash/listing/owner.php new file mode 100644 index 00000000000..0472973f2dc --- /dev/null +++ b/views/default/trash/listing/owner.php @@ -0,0 +1,22 @@ + $entity->guid, + 'preload_owners' => false, +]; + +$vars['options'] = array_merge($options, $owner_options); + +echo elgg_view('trash/listing/all', $vars);