get_error_message() ) ); return; } /* * The current directory is gone, so its name goes too (dorabotki.md, * пункт 25). Kept, it would make the next install recreate an empty * directory under a dead name and never look at the leftovers - a * second directory next to one already on disk. Only after the whole * deletion went through: on a failure above, the name is what explains * what is still there. * * Its uninstall tick goes with it (приёмка 2026-10-05). The tick is a * yes or no for "the current directory", not a name, and it was about * the one just deleted. Kept, it would land on the leftover the next * install shows and takes over as current, and the uninstall after that * would delete a directory nobody ticked, logs and all. */ if ( in_array( (string) DLOGR_Settings::get_state( 'log_dir' ), $directories, true ) ) { DLOGR_Settings::set_state( 'log_dir', '' ); DLOGR_Settings::set( 'delete_log_dir_on_uninstall', false ); } } /* * The plugin's folder in uploads goes with the last log directory in it, * ticked or not: with none left it holds only what the plugin put there * itself, and a folder named after a deleted plugin explains nothing. * Anything else found in it keeps it in place - see remove_base(). Not a * step of the chain: a folder that stays is empty and named, which is * worth no journal entry and no stop. * * The loader data file is normally gone already - deactivation deletes it, * and core deactivates before it deletes. Removed here anyway: the block is * out of wp-config.php, so nothing will ever read it again. */ $data_file = DLOGR_Log_Dir::base() . '/' . DLOGR_Log_Dir::DATA_FILE; if ( '/' . DLOGR_Log_Dir::DATA_FILE !== $data_file && file_exists( $data_file ) ) { wp_delete_file( $data_file ); } if ( empty( DLOGR_Log_Dir::find_existing() ) ) { DLOGR_Log_Dir::remove_base(); } if ( ! $delete_data ) { // Listed again rather than reusing the list taken at the start: the directories above may be gone by now. DLOGR_Journal::info( 'uninstall_completed', sprintf( 'Plugin deleted and wp-config.php cleaned up.%1$s Its data was kept, because "the data of the plugin" was not ticked: %2$s.', dlogr_uninstall_deleted_dirs( $directories ), dlogr_uninstall_leftovers() ) ); return; } /* * Written before the DROP, because after it there is nowhere to write. It * deliberately does not claim the table and the options are gone: if the * DROP below fails, this row stays behind and has to still be true. */ DLOGR_Journal::info( 'uninstall_completed', sprintf( 'Plugin deleted and wp-config.php cleaned up.%1$s%2$s The journal table and the options go next.', dlogr_uninstall_deleted_dirs( $directories ), dlogr_uninstall_kept_dirs() ) ); if ( ! DLOGR_Journal::drop() ) { DLOGR_Journal::error( 'uninstall_table_failed', sprintf( 'The journal table %s could not be dropped. The options were kept, so that this table can still be traced back to the plugin that made it.', DLOGR_Journal::table() ) ); return; } DLOGR_Settings::delete_options(); } /** * Takes the block out of wp-config.php and gives the constants back. * * The same steps the button on the Status tab performs, in the same order * and through the same two classes - there is no second way to edit that file. * What is missing here is everything that needs somebody watching: no result * screen, no question, no manual fallback. * * @param string|false $config_path Absolute path to wp-config.php, false when there is none. * @return string Empty string on success, otherwise why the file was not changed. */ function dlogr_uninstall_clear_config( $config_path ) { if ( false === $config_path ) { return 'wp-config.php was not found.'; } $config = DLOGR_Config::read( $config_path ); /* * ⚠️ Asked before anything is concluded from blocks and disabled, because * read() never returns a WP_Error: a file it could not read or could not * parse comes back with both of those empty, which is indistinguishable * from a clean config. Taken as success it would let the chain below * delete the log directories, the journal and the options while our block * may still be sitting in the file - the one thing "could not remove the * block, so remove nothing" exists to prevent. * * The button cannot reach this: DLOGR_Config::remove() makes both checks * itself. Here the decision is taken earlier, before remove() is called at * all, so the checks have to be made here too. */ if ( ! $config['readable'] ) { return 'wp-config.php could not be read, so it was left untouched.'; } if ( '' !== $config['parse_error'] ) { return sprintf( 'wp-config.php did not parse, so what is in it is unknown and it was left untouched: %s.', $config['parse_error'] ); } /* * Nothing of ours in the file: either the button already took it out, or it * never went in. Both are a clean config, so this counts as success - * refusing here would punish exactly the person who tidied up first. */ if ( empty( $config['blocks'] ) && empty( $config['disabled'] ) ) { return ''; } if ( ! dlogr_uninstall_is_direct( $config['path'] ) ) { return 'WordPress cannot write files directly on this site, so wp-config.php was left untouched.'; } /* * As on the Status tab (2026-10-04): the config is replaced by rename(), * which would undo a read-only setting rather than stop at it. Left in * place, the block costs nothing - the loader is gone with the plugin and * file_exists() in the block turns it into a no-op. */ if ( ! $config['writable'] ) { return 'wp-config.php is closed for writing, so it was left untouched.'; } $removed = DLOGR_Config::remove( $config['path'] ); if ( is_wp_error( $removed ) ) { return $removed->get_error_message(); } return ''; } /** * Whether WordPress can write to the directory wp-config.php sits in. * * ⚠️ The context argument matters. Without it the core tests WP_CONTENT_DIR, * where the permissions are usually different from those of the site root - * and the site root is where wp-config.php lives. * * @param string $path Absolute path to wp-config.php. * @return bool */ function dlogr_uninstall_is_direct( $path ) { if ( ! function_exists( 'get_filesystem_method' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } return 'direct' === get_filesystem_method( array(), dirname( $path ), false ); } /** * Everything this plugin would leave behind, in one sentence. * * Named precisely rather than described: a directory with an eight character * random suffix is unrecognisable a year later, and the journal entry holding * this text is the only place its name still exists. The Settings tab lists the * same things and lists them well, but it is read before the deletion and dies * with the plugin; this text is read after it, straight out of the database. * * ⚠️ Called on the two paths where the data of the plugin stays - the config * could not be cleaned up, or "the data of the plugin" was not ticked and only * the block and the ticked directories went. Both times it is asked of the * disk at that moment, so "what is there right now" is literally true, and no * branch on the boxes is needed here. * * @return string */ function dlogr_uninstall_leftovers() { $parts = array(); $directories = DLOGR_Log_Dir::find_existing(); if ( ! empty( $directories ) ) { $parts[] = 'log directories ' . dlogr_uninstall_labels( $directories ); } $parts[] = 'the journal table ' . DLOGR_Journal::table(); $parts[] = 'the options dlogr_settings and dlogr_state'; return implode( '; ', $parts ); } /** * The log directories ticked for deletion on the settings tab. * * The current directory by its yes or no, so that a rename never untied it * from its box; the leftovers by name, and only those still on disk and still * leftovers. A name on the list that has since become the current directory * is left alone: its box is the other one. * * @return array Directory names, without the path. */ function dlogr_uninstall_directories() { $names = array(); if ( DLOGR_Settings::get( 'delete_log_dir_on_uninstall' ) ) { $current = DLOGR_Log_Dir::current(); if ( '' !== $current ) { $names[] = $current; } } $ticked = (array) DLOGR_Settings::get( 'delete_leftovers_on_uninstall' ); return array_merge( $names, array_values( array_intersect( $ticked, DLOGR_Log_Dir::leftovers() ) ) ); } /** * The deleted directories, as a sentence for the journal. * * @param array $names Directory names that were deleted. * @return string Starts with a space, empty when there were none. */ function dlogr_uninstall_deleted_dirs( array $names ) { if ( empty( $names ) ) { return ''; } return ' Log directories deleted: ' . dlogr_uninstall_labels( $names ) . '.'; } /** * The directories that stay, as a sentence for the journal. * * Asked of the disk after the deletion. It goes into the entry written just * before the journal is dropped, so it outlives the plugin only when that DROP * fails; otherwise the README.txt inside each kept directory is what explains * it. Said anyway, because that entry has to stay true if it does stay. * * @return string Starts with a space, empty when none stays. */ function dlogr_uninstall_kept_dirs() { $names = DLOGR_Log_Dir::find_existing(); if ( empty( $names ) ) { return ''; } return ' Log directories kept, because they were not ticked: ' . dlogr_uninstall_labels( $names ) . '.'; } /** * Directory names in the readable form the journal uses. * * @param array $names Directory names, without the path. * @return string Comma separated. */ function dlogr_uninstall_labels( array $names ) { return implode( ', ', array_map( array( 'DLOGR_Log_Dir', 'label' ), $names ) ); } dlogr_uninstall();