',
esc_html__( 'Debug Log Router:', 'debug-log-router' ),
esc_html( $text ),
esc_url(
add_query_arg(
array(
'page' => self::PAGE,
'tab' => 'status',
),
admin_url( 'tools.php' )
)
),
esc_html__( 'Set up logging', 'debug-log-router' )
);
}
/**
* States the plugin has to announce outside its own page.
*
* Three of these are promised by the design documents, and they share every
* rule: all admin screens, dismissible, back again when the state returns.
* Written as a list from the start so the other two - a foreign define()
* beating ours, and a log directory that cannot be written - drop in beside
* this one instead of growing a second mechanism.
*
* ⚠️ The cheap exit comes first and carries the whole cost argument. While
* logging is off - the default, and the state of every site that has not
* been set up - this is one option read that WordPress has already done.
* Only once logging is on does anybody pay for reading and parsing
* wp-config.php on an admin page. No cache: it would be a second source of
* truth in the one place where the truth is deliberately kept on disk.
*
* @return array Notices to show, each with keys key, type and text.
*/
private static function pending_notices() {
$found = array();
/*
* Ahead of the switch and of the config checks on purpose. A constant
* taken over matters with logging off too - the off position is exactly
* what stops working - and it has nothing to do with whether
* wp-config.php can be read or parsed.
*
* ⚠️ Only the group whose value differs gets a notice. The group that
* happens to match is shown on the Status tab and nowhere else
* (заказчик, 2026-09-12): nothing is missing from the log yet, so
* chasing somebody across every screen is not warranted - but staying
* silent is not either, because the setting is already not in charge.
*/
$conflict = self::taken_constants( 'DLOGR_BOOT_CONFLICT' );
if ( ! empty( $conflict ) ) {
$found[] = array(
/*
* The names ride in the key. Dismissals are kept as a list of
* keys and forget_stale_dismissals() compares keys, not
* meaning: under one shared key a dismissed notice about
* WP_DEBUG would swallow a new one about another constant.
*/
'key' => 'constants_' . strtolower( implode( '_', $conflict ) ),
'type' => 'error',
'text' => sprintf(
/* translators: %s: comma separated constant names. */
_n(
'%s is declared before this plugin can declare it, and with another value, so the plugin setting behind it does nothing.',
'%s are declared before this plugin can declare them, and with other values, so the plugin settings behind them do nothing.',
count( $conflict ),
'debug-log-router'
),
implode( ', ', $conflict )
),
);
}
if ( ! DLOGR_Settings::get( 'logging_enabled' ) ) {
return $found;
}
$config = DLOGR_Config::read();
/*
* A config that cannot be read or parsed says nothing about whether our
* block is in it, and a notice claiming otherwise would be a guess.
*
* The directory notice below sits behind this gate on purpose, although
* the disk is readable either way. Whether to show it depends on
* whether the block is there - with no block the other notice owns the
* message - and that is exactly what cannot be answered here. Guessing
* would point somebody at the wrong repair.
*/
if ( false === $config['path'] || ! $config['readable'] || '' !== $config['parse_error'] ) {
return $found;
}
if ( empty( $config['blocks'] ) ) {
$found[] = array(
'key' => 'not_built',
'type' => 'error',
'text' => __( 'Logging is switched on, but the block is missing from wp-config.php, so nothing is being written to the log files.', 'debug-log-router' ),
);
/*
* Nothing more from here. Without the block nothing is written
* anyway, and the missing directory below would say the same thing
* a second time - two notices about one state read as two states.
*/
return $found;
}
/*
* The block is there and the loader still never ran: boot.php declares
* DLOGR_BOOT_DATA on every path, so its absence means the block does not
* reach our boot.php - most often a path left from another place after
* the site was moved (dorabotki 26, 2026-10-06). The same when the
* block points the loader at another folder than this site's in
* uploads (2026-10-11): it runs, but reads data the plugin no longer
* writes. Nothing more from here, for the same reason as above: the
* repair needs the loader, and every notice below would describe a
* symptom of this one.
*
* ⚠️ No false alarm right after the button writes the block: handle()
* always redirects, and the next request runs wp-config.php anew.
*/
if ( ! defined( 'DLOGR_BOOT_DATA' ) || ! DLOGR_Boot::base_matches() ) {
$found[] = array(
'key' => 'not_loaded',
'type' => 'error',
'text' => self::not_loaded_text(),
);
return $found;
}
/*
* Logging on, block in place, and still nothing reaches the log
* directory: the server has fixed PHP's log (2026-10-04). Allowed, so a
* warning rather than an error - but on every screen, because the
* entries somebody expects to find on the Logs tab are elsewhere.
*/
$server = DLOGR_Reader::server_log();
if ( $server['pinned'] ) {
$found[] = array(
'key' => 'log_pinned',
'type' => 'warning',
'text' => self::pinned_text( $server ),
);
}
/*
* ⚠️ The disk right now, not DLOGR_BOOT_DIR. The loader flag says the
* directory was gone when the request began, and maybe_repair() runs on
* wp_loaded - before any notice is drawn. Trusting the flag would
* announce a breakage this plugin repaired a moment earlier. is_dir()
* says no only when the repair did not manage it.
*
* ⚠️ Deliberately the mirror image of the constants check, decided the
* same day: there the frozen fact is the true one because the live
* value lies after a repair. Here the live disk is the true one because
* the frozen flag lies. The difference is whether the state repairs
* itself. Разбор - docs/boot-php.md.
*/
if ( ! DLOGR_Log_Dir::exists() ) {
$named = '' !== DLOGR_Log_Dir::name();
/*
* Two keys, because the two cases are fixed by different people
* doing different things. One key would let a dismissal of the
* first hide the arrival of the second.
*/
$found[] = array(
'key' => $named ? 'dir_missing' : 'dir_unset',
'type' => 'error',
/*
* ⚠️ Never "nothing is being written": measured on PHP 7.4, a
* log target it cannot open makes PHP fall back to the error
* output of the environment - stderr on the command line, the
* web server log on a live site - while error_log() still
* reports success. The entries are not lost, they are somewhere
* this plugin cannot show. Hedged wording on the destination:
* the fallback was measured here only on the command line.
*/
'text' => $named
? __( 'The log directory is gone from the uploads folder and this plugin has not managed to recreate it yet, so entries are not reaching it. They are most likely going to the error log of the hosting instead, which usually needs FTP or a hosting panel to read. The plugin journal says why. The plugin keeps trying and brings the directory back by itself once the uploads folder can be written to; switching logging off and on again on the Settings tab does it at once.', 'debug-log-router' )
: __( 'No log directory is set, so entries are not reaching this plugin at all. They are most likely going to the error log of the hosting instead, which usually needs FTP or a hosting panel to read. Switch logging off and on again on the Settings tab: the directory is then found or created.', 'debug-log-router' ),
);
}
return $found;
}
/**
* Prints the notices on every admin screen but our own.
*
* Not on the Status tab on purpose: it says the same thing in its own
* words, right next to the button that fixes it, and two messages about one
* state read as two problems.
*
* ⚠️ The tab, not the page. Silencing the whole page left Settings saying
* nothing at all - and that is where somebody calmly picks a log directory
* without knowing that nothing is being written to it. The rule held when
* the Logs tab arrived: only the tab that says it itself stays quiet.
*
* @return void
*/
public static function print_notices() {
if ( ! current_user_can( self::capability() ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( $screen && '' !== self::$hook && $screen->id === self::$hook && 'status' === self::current_tab() ) {
return;
}
$pending = self::pending_notices();
self::forget_stale_dismissals( $pending );
$dismissed = self::dismissed();
foreach ( $pending as $notice ) {
if ( in_array( $notice['key'], $dismissed, true ) ) {
continue;
}
self::print_notice( $notice );
}
}
/**
* Draws one notice.
*
* ⚠️ The markup is written out by hand rather than through
* wp_admin_notice(), which arrived in WordPress 6.4 while this plugin
* supports 5.8.
*
* The plugin's name leads the text because this appears on somebody else's
* screens - the media library, the posts list - where an unattributed
* warning about wp-config.php is alarming and untraceable.
*
* @param array $notice One entry of pending_notices().
* @return void
*/
private static function print_notice( array $notice ) {
$page = add_query_arg(
array( 'page' => self::PAGE ),
admin_url( 'tools.php' )
);
$dismiss = wp_nonce_url(
add_query_arg( self::DISMISS, $notice['key'] ),
self::DISMISS
);
printf(
'
',
esc_attr( 'error' === $notice['type'] ? 'error' : 'warning' ),
esc_html__( 'Debug Log Router:', 'debug-log-router' ),
esc_html( $notice['text'] ),
esc_url( $page ),
esc_html__( 'Open the plugin page', 'debug-log-router' ),
esc_url( $dismiss ),
esc_html__( 'Dismiss', 'debug-log-router' )
);
}
/**
* Remembers that this person has seen a notice and does not need it again.
*
* @return void
*/
public static function handle_dismiss() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only used to see whether this is a dismissal at all; the nonce is checked immediately below.
if ( empty( $_GET[ self::DISMISS ] ) ) {
return;
}
if ( ! current_user_can( self::capability() ) ) {
return;
}
check_admin_referer( self::DISMISS );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checked on the line above.
$key = sanitize_key( wp_unslash( $_GET[ self::DISMISS ] ) );
$dismissed = self::dismissed();
if ( ! in_array( $key, $dismissed, true ) ) {
$dismissed[] = $key;
update_user_meta( get_current_user_id(), self::DISMISS_META, $dismissed );
}
wp_safe_redirect( remove_query_arg( array( self::DISMISS, '_wpnonce' ) ) );
exit;
}
/**
* Notices this person has dismissed.
*
* @return array List of notice keys.
*/
private static function dismissed() {
$stored = get_user_meta( get_current_user_id(), self::DISMISS_META, true );
return is_array( $stored ) ? $stored : array();
}
/**
* Drops dismissals for states the site is no longer in.
*
* This is what makes "dismissible" mean "until it happens again" rather
* than "never again". Somebody who waved the warning away in March, fixed
* the config, and broke it again in July has to be told a second time -
* their earlier click was about the earlier problem.
*
* @param array $pending Result of pending_notices().
* @return void
*/
private static function forget_stale_dismissals( array $pending ) {
$dismissed = self::dismissed();
if ( empty( $dismissed ) ) {
return;
}
$keys = wp_list_pluck( $pending, 'key' );
$fresh = array_values( array_intersect( $dismissed, $keys ) );
if ( $fresh !== $dismissed ) {
update_user_meta( get_current_user_id(), self::DISMISS_META, $fresh );
}
}
/**
* The one capability check for the whole plugin.
*
* Centralised on purpose: current_user_can( 'manage_options' ) scattered
* through the code would make changing this later a read of the whole
* project. No filter inside, deliberately - delegation is not planned, and
* an extension point is public API that cannot be taken back once somebody
* hooks into it. Adding one later costs nothing at the call sites.
*
* @return string
*/
public static function capability() {
return 'manage_options';
}
/**
* Puts a Settings link into the plugin's row on the Plugins screen.
*
* First in the row, to the left of Deactivate (заказчик, 2026-10-04): no
* rule says where, and that is where most plugins put it, so that is
* where people look. It leads to the Settings tab, not the default Logs.
*
* Only for those who may open the page. Managing plugins and managing
* options are separate rights, and a link that ends in "you are not
* allowed" is worse than none.
*
* The label is the tab's own string, so it needs no translation of its own.
*
* @param array $links Action links WordPress has put together so far.
* @return array
*/
public static function action_links( $links ) {
if ( ! current_user_can( self::capability() ) ) {
return $links;
}
$settings = sprintf(
'%s',
esc_url( self::tab_url( 'settings' ) ),
esc_html__( 'Settings', 'debug-log-router' )
);
return array_merge( array( 'settings' => $settings ), (array) $links );
}
/**
* Adds the page under Tools.
*
* Under Tools rather than a top-level menu, with tabs rather than submenu
* entries: core puts its own diagnostic tool exactly there, and logs are a
* tool for working on a site, not a section of it.
*
* @return void
*/
public static function register_menu() {
$hook = add_management_page(
__( 'Debug Log Router', 'debug-log-router' ),
__( 'Debug Logs', 'debug-log-router' ),
self::capability(),
self::PAGE,
array( __CLASS__, 'render' )
);
if ( $hook ) {
self::$hook = $hook;
/*
* The handler runs on load-, before any output, so a success
* can redirect to itself and the result travels in the URL. Without
* that, refreshing the page would repeat an operation that edits
* wp-config.php.
*/
add_action( 'load-' . $hook, array( __CLASS__, 'handle' ) );
}
}
/**
* Loads the one script this page uses: copying the block to the clipboard.
*
* The library is the core's own clipboard.js, registered there for the copy
* buttons core itself draws. Writing our own would mean writing the
* fallback too:
* navigator.clipboard exists only in a secure context, so on a plain http
* site - this very installation included - it is simply not there, and the
* whole job falls to document.execCommand(). That is exactly what this
* library already does, and it ships with WordPress since 4.9.
*
* Enqueued for the whole Status tab rather than only when the fragment is
* on the page: telling that apart would mean reading wp-config.php a second
* time, this early, and possibly getting a different answer than the render
* does. A cached core script is cheaper than a second read of the file the
* page is reporting on.
*
* The stylesheet goes on every tab of the page, the script only on Status:
* the page check is shared, the tab check belongs to the script alone.
* Разбор - development/docs/admin.md, «Собственные стили».
*
* @param string $hook_suffix Page the admin is about to draw.
* @return void
*/
public static function enqueue( $hook_suffix ) {
if ( $hook_suffix !== self::$hook ) {
return;
}
wp_enqueue_style( 'dlogr-admin', DLOGR_PLUGIN_URL . 'assets/css/admin.css', array(), DLOGR_VERSION );
/*
* The one script of the Settings tab: it reveals the line about unsaved
* changes. A file of its own rather than inline code on a handle with
* no source, which is a case whose handling in core could not be
* checked back to 5.8. In the footer, where the form it looks for is
* already drawn. Разбор - development/docs/oformlenie.md.
*/
if ( 'settings' === self::current_tab() ) {
wp_enqueue_script( 'dlogr-settings', DLOGR_PLUGIN_URL . 'assets/js/settings.js', array(), DLOGR_VERSION, true );
return;
}
if ( 'status' !== self::current_tab() ) {
return;
}
wp_enqueue_script( 'clipboard' );
$strings = array(
'done' => __( 'Copied', 'debug-log-router' ),
'manual' => __( 'Press Ctrl+C to copy', 'debug-log-router' ),
);
wp_add_inline_script(
'clipboard',
'var dlogrCopy = ' . wp_json_encode( $strings ) . ';',
'before'
);
wp_add_inline_script( 'clipboard', self::copy_script() );
}
/**
* The glue between the copy button and clipboard.js.
*
* ⚠️ The button is printed hidden and revealed from here, once the library
* is known to be there. A copy button that does nothing because a script
* failed to load is worse than no button: the text is selectable by hand
* either way, and nobody can tell a dead button from a silent failure.
*
* @return string JavaScript, without the script tag.
*/
private static function copy_script() {
return <<<'JS'
( function () {
var start = function () {
var field = document.getElementById( 'dlogr-block' );
var button = document.getElementById( 'dlogr-copy' );
if ( ! field || ! button || 'undefined' === typeof ClipboardJS ) {
return;
}
button.style.display = '';
var label = button.textContent;
var timer = null;
var say = function ( text ) {
button.textContent = text;
window.clearTimeout( timer );
timer = window.setTimeout( function () {
button.textContent = label;
}, 2000 );
};
// The block is a
since 2026-09-28, not a textarea: its text is
// textContent, and there is no value to read.
var clipboard = new ClipboardJS( button, {
text: function () {
return field.textContent;
}
} );
clipboard.on( 'success', function ( event ) {
event.clearSelection();
say( dlogrCopy.done );
} );
// Old browsers refuse the copy command; selecting the text is then the
// most useful thing left to do for the person. A
has no select(),
// so the selection is made with a range over its contents.
clipboard.on( 'error', function () {
var range = document.createRange();
var selection = window.getSelection();
range.selectNodeContents( field );
selection.removeAllRanges();
selection.addRange( range );
say( dlogrCopy.manual );
} );
};
// This runs from the footer, so the document is usually still parsing - but
// anything that defers scripts would have it finished already, and then the
// event never comes.
if ( 'loading' === document.readyState ) {
document.addEventListener( 'DOMContentLoaded', start );
} else {
start();
}
}() );
JS;
}
/**
* The tabs that exist right now.
*
* Four is the ceiling: past that core hides the extras behind a dropdown on
* its own page. Logs arrived in step 8 and became the default tab then,
* since that is what the plugin is installed for.
*
* ⚠️ Support took the fourth place (заказчик, 2026-10-08), so the ceiling is
* reached: anything new goes into an existing tab as a block. Support is
* last because it is the one tab nobody opens to work with the site.
*
* Settings holds the log directory first - where to write is a setting, and
* creating extra directories is that same setting widened, not a feature of
* its own - then the logging toggle, then the error display mode, then what
* deleting the plugin does to its data. Rotation was taken out of the first
* release altogether (заказчик, 2026-09-11).
*
* ⚠️ The toggle arrived here on 2026-09-11 by moving, not by being added.
* Two places that switch logging on already cost one rename, when the
* removal button standing next to a toggle was read as one, and that stays
* true: the Status tab gave its toggle up in the same change.
*
* @return array Slug => label.
*/
private static function tabs() {
return array(
'logs' => __( 'Logs', 'debug-log-router' ),
'status' => __( 'Status', 'debug-log-router' ),
'settings' => __( 'Settings', 'debug-log-router' ),
'support' => __( 'Support', 'debug-log-router' ),
);
}
/**
* Which tab is open.
*
* @return string
*/
private static function current_tab() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only navigation: the value only picks which tab to draw.
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '';
return isset( self::tabs()[ $tab ] ) ? $tab : 'logs';
}
/**
* Everything the page knows, in one snapshot.
*
* One consistent picture rather than four independent lookups that could
* each see a different version of the file.
*
* @return array
*/
public static function snapshot() {
$config = DLOGR_Config::read();
return array(
'config' => $config,
'enabled' => (bool) DLOGR_Settings::get( 'logging_enabled' ),
'log_dir' => DLOGR_Log_Dir::name(),
'dir_exists' => DLOGR_Log_Dir::exists(),
'current' => DLOGR_Log_Dir::current(),
'leftovers' => DLOGR_Log_Dir::leftovers(),
'boot_ok' => DLOGR_Boot::matches(),
'loaded' => defined( 'DLOGR_BOOT_DATA' ) && DLOGR_Boot::base_matches(),
'journal' => DLOGR_Journal::is_ready(),
'state' => self::state_key( $config, (bool) DLOGR_Settings::get( 'logging_enabled' ) ),
);
}
/**
* Which of the four states the plugin is in.
*
* A 2x2 matrix - block present or not, logging on or off. Four cells, four
* states, no gap and no overlap. What used to be listed as E and F are not
* states at all: E is choosing a directory, F is the outcome of an
* operation. See docs/admin.md.
*
* @param array $config Result of DLOGR_Config::read().
* @param bool $enabled Whether logging is switched on.
* @return string One of A, B, C, D.
*/
private static function state_key( array $config, $enabled ) {
$has_block = ! empty( $config['blocks'] );
if ( ! $has_block ) {
return $enabled ? 'D' : 'A';
}
return $enabled ? 'B' : 'C';
}
/**
* What stands in the way, as a separate layer.
*
* Kept apart from the state on purpose: DLOGR_Config::read() alone tells
* ten conditions apart, and folding them into states would give forty
* branches instead of four plus ten. A blocker never changes which state
* the plugin is in - it changes what the button may do.
*
* @param array $snapshot Result of snapshot().
* @return array List of arrays with keys code, text and blocks.
*/
private static function blockers( array $snapshot ) {
$config = $snapshot['config'];
$found = array();
if ( false === $config['path'] ) {
$found[] = array(
'code' => 'no_config',
'short' => __( 'wp-config.php was not found', 'debug-log-router' ),
'text' => __( 'wp-config.php was not found where WordPress looks for it. The plugin cannot report on a file it cannot locate.', 'debug-log-router' ),
'blocks' => true,
);
return $found;
}
if ( ! $config['readable'] ) {
$found[] = array(
'code' => 'unreadable',
'short' => __( 'wp-config.php cannot be read', 'debug-log-router' ),
'text' => __( 'wp-config.php cannot be read. Check the file permissions.', 'debug-log-router' ),
'blocks' => true,
);
return $found;
}
if ( '' !== $config['parse_error'] ) {
$found[] = array(
'code' => 'parse_error',
'short' => __( 'wp-config.php does not parse', 'debug-log-router' ),
'text' => __( 'wp-config.php did not parse, so its contents are unknown. Nothing will be changed in it.', 'debug-log-router' ),
'blocks' => true,
);
return $found;
}
if ( ! $config['writable'] ) {
$found[] = array(
'code' => 'not_writable',
'short' => __( 'wp-config.php is not writable', 'debug-log-router' ),
'text' => __( 'wp-config.php is not writable. The block below can be pasted by hand instead.', 'debug-log-router' ),
'blocks' => true,
);
}
if ( 0 === $config['anchor']['type'] ) {
$found[] = array(
'code' => 'no_anchor',
'short' => __( 'there is no safe place in it to insert the block', 'debug-log-router' ),
'text' => __( 'No line in wp-config.php loads wp-settings.php, so there is no safe place to insert the block automatically.', 'debug-log-router' ),
'blocks' => true,
);
}
if ( 3 === $config['anchor']['type'] ) {
$found[] = array(
'code' => 'anchor_shared',
'short' => __( 'other code shares the line that loads wp-settings.php', 'debug-log-router' ),
'text' => sprintf(
/* translators: %d: line number in wp-config.php. */
__( 'Line %d of wp-config.php, the one that loads wp-settings.php, has other code in front of it. A block inserted there could not be taken out again cleanly, so it has to be pasted by hand — on a line of its own, right before that statement.', 'debug-log-router' ),
$config['anchor']['line']
),
'blocks' => true,
);
}
foreach ( $config['blocks'] as $block ) {
if ( ! $block['closed'] ) {
$found[] = array(
'code' => 'unclosed_block',
'short' => __( 'a block of this plugin there has no closing marker', 'debug-log-router' ),
'text' => __( 'A block of this plugin in wp-config.php has no closing marker. It has to be repaired by hand before anything else is done to the file.', 'debug-log-router' ),
'blocks' => true,
);
break;
}
}
$shared = array();
foreach ( $config['defines'] as $define ) {
if ( ! $define['alone'] ) {
$shared[] = $define['line_start'];
}
}
if ( ! empty( $shared ) ) {
/*
* ⚠️ Known before the button is pressed, on purpose. Commenting out
* a line that also carries somebody else's statement would take
* that statement with it and still leave a perfectly valid file -
* the one failure the tokenizer cannot catch. Saying so here turns
* a surprise refusal into a condition the person can see and fix.
*/
$found[] = array(
'code' => 'shared_line',
'short' => __( 'a constant to take over shares its line with other code', 'debug-log-router' ),
'text' => sprintf(
/* translators: %s: comma separated line numbers. */
__( 'On these lines of wp-config.php a constant this plugin manages shares the line with other code: %s. Commenting the line out would take that code with it, so the block has to be inserted by hand.', 'debug-log-router' ),
implode( ', ', $shared )
),
'blocks' => true,
);
}
/*
* Not while the block does not load the plugin (dorabotki 27): the
* repair needs the loader, so the directory would not come back by
* itself, and switching logging off and on does not mend the block.
* The red card says what does. The row in the table still says the
* directory is missing - that is a fact, not a promise.
*/
$not_loaded = 'B' === $snapshot['state'] && ! $snapshot['loaded'];
if ( '' !== $snapshot['log_dir'] && ! $snapshot['dir_exists'] && ! $not_loaded ) {
$found[] = array(
'code' => 'dir_missing',
'short' => __( 'the log directory is missing from disk', 'debug-log-router' ),
'text' => __( 'The log directory is gone from the disk. While logging is on, the plugin brings it back by itself under the same name once the uploads folder can be written to; switching logging off and on again on the Settings tab does it at once.', 'debug-log-router' ),
'blocks' => false,
);
}
return $found;
}
/**
* Whether the button may act at all.
*
* ⚠️ Which blockers count depends on the state, and this is not a detail.
* From state A the button edits wp-config.php, so every problem with that
* file stops it. From state C the block is already in place and the config
* is not touched at all, so an unwritable config is irrelevant there.
*
* @param array $snapshot Result of snapshot().
* @param array $blockers Result of blockers().
* @return bool
*/
private static function button_allowed( array $snapshot, array $blockers ) {
return empty( self::blocking_reasons( $snapshot, $blockers ) );
}
/**
* The obstacles that actually stop the button in this state.
*
* Split out of button_allowed() so that "may the button act" and "why it
* may not" cannot drift apart: a disabled button whose stated reasons come
* from a second copy of the rule is worse than no explanation at all.
*
* @param array $snapshot Result of snapshot().
* @param array $blockers Result of blockers().
* @return array The blocking entries, in the order they were found.
*/
private static function blocking_reasons( array $snapshot, array $blockers ) {
$touches_config = in_array( $snapshot['state'], array( 'A', 'D' ), true );
$found = array();
foreach ( $blockers as $blocker ) {
if ( ! $blocker['blocks'] ) {
continue;
}
if ( ! $touches_config ) {
continue;
}
$found[] = $blocker;
}
return $found;
}
/**
* Says why the button is switched off, right where the button is.
*
* ⚠️ The reasons are listed further up the page as well, and that was not
* enough: a greyed out button with the explanation somewhere above it reads
* as a broken plugin, not as a condition to fix (замечание заказчика
* 2026-09-05). A short line next to the button is what connects the two.
*
* @param array $reasons Result of blocking_reasons().
* @return void
*/
private static function render_disabled_reason( array $reasons ) {
if ( empty( $reasons ) ) {
return;
}
$short = array();
foreach ( $reasons as $reason ) {
$short[] = $reason['short'];
}
printf(
'
%s %s
',
esc_html__( 'The button is off:', 'debug-log-router' ),
esc_html( implode( '; ', $short ) . '.' )
);
}
/**
* Runs before any output, handles a submitted form and redirects.
*
* @return void
*/
public static function handle() {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Only used to see whether a form was submitted at all; the nonce is checked immediately below.
if ( empty( $_POST['dlogr_action'] ) ) {
return;
}
if ( ! current_user_can( self::capability() ) ) {
wp_die( esc_html__( 'You are not allowed to manage these settings.', 'debug-log-router' ), '', array( 'response' => 403 ) );
}
check_admin_referer( self::NONCE );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked on the line above.
$action = sanitize_key( wp_unslash( $_POST['dlogr_action'] ) );
$result = 'unknown';
$tab = 'status';
$confirm = '';
$renamed = '';
if ( 'enable' === $action ) {
/*
* Pressed on Status only since 2026-09-28: the cheap switch on
* Settings is saved with the rest of that tab by save_settings, so
* the form no longer has to say which tab it came from.
*/
$result = self::do_enable();
} elseif ( 'remove_block' === $action ) {
$result = self::do_remove();
} elseif ( 'update_block' === $action ) {
$result = self::do_update_block();
} elseif ( 'rename_dir' === $action ) {
$tab = 'settings';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked above; rename_random() accepts only names that are ours.
$name = isset( $_POST['dlogr_dir'] ) ? sanitize_text_field( wp_unslash( $_POST['dlogr_dir'] ) ) : '';
$outcome = self::do_rename_dir( $name );
$result = $outcome['result'];
/*
* The new name travels to the next request because the message has
* to say it. Whoever had the old path written down needs the new one
* in front of them, not a hint that it is somewhere in the table.
*/
if ( '' !== $outcome['name'] ) {
$renamed = $outcome['name'];
}
} elseif ( 'delete_dir' === $action ) {
$tab = 'settings';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked above; delete() accepts only names that are ours.
$name = isset( $_POST['dlogr_dir'] ) ? sanitize_text_field( wp_unslash( $_POST['dlogr_dir'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked above; presence is the whole value.
$result = self::do_delete_dir( $name, isset( $_POST['dlogr_confirmed'] ) );
/*
* Nothing was deleted: the directory holds something, so the page
* has to ask first. The name travels in the URL because the answer
* is one click away in the next request - storing it would mean
* state to clean up after somebody who simply walked off.
*/
if ( 'dir_confirm' === $result ) {
$confirm = $name;
}
} elseif ( 'save_settings' === $action ) {
$tab = 'settings';
$result = self::do_save_settings();
}
$args = array(
'page' => self::PAGE,
'tab' => $tab,
);
/*
* A question and a result are mutually exclusive: asking is what
* happens instead of doing, so there is no outcome to report yet.
*/
if ( '' !== $confirm ) {
$args['dlogr_confirm'] = $confirm;
} else {
$args['dlogr_result'] = $result;
}
if ( '' !== $renamed ) {
$args['dlogr_name'] = $renamed;
}
wp_safe_redirect( add_query_arg( $args, admin_url( 'tools.php' ) ) );
exit;
}
/**
* Gives a directory a fresh random name, active one included.
*
* Two steps for the active directory, and the order is the whole of it:
* rename on disk, then the name in the state. A failure on the first step
* changes nothing at all, which is why it goes first.
*
* boot-data.json is not touched (dorabotki 28, 2026-10-13). It does not
* name the directory since 2026-10-11 - the loader finds it by the marker,
* which travels with it - so rewriting the file changed nothing but its
* inode and modification time.
*
* ⚠️ Between the rename and the state write a repair in a parallel request
* would read the old name from the state and find no directory under it,
* and it would create that old directory again, empty. Somebody would then be looking at a
* directory they never made. The repair is held off for the whole
* operation instead - see hold_repair(), which explains why the order of
* taking it matters. The state is still written first thing after the
* rename, before the journal: the shorter that stretch is, the less the
* lock has to cover.
*
* @param string $name Directory name from the form.
* @return array Keys result (code for the URL) and name (the new one, or empty).
*/
private static function do_rename_dir( $name ) {
$was_active = ( DLOGR_Log_Dir::name() === $name );
/*
* Before the disk is touched, never after: a request learns the
* directory is gone in its own boot.php, so the only dangerous one is
* the one that starts after the rename - and it has to find the lock
* already set. Held for both paths and released on both.
*/
DLOGR_Boot::hold_repair();
$new = DLOGR_Log_Dir::rename_random( $name );
if ( is_wp_error( $new ) ) {
DLOGR_Boot::release_repair();
DLOGR_Journal::error( 'dir_rename_failed', $new->get_error_message() );
return array(
'result' => 'dir_rename_failed',
'name' => '',
);
}
// First thing after the rename, for the reason given above.
if ( $was_active ) {
DLOGR_Settings::set_state( 'log_dir', $new );
}
DLOGR_Journal::info(
'dir_renamed',
sprintf( 'Log directory %1$s was renamed to %2$s.', $name, $new )
);
self::move_leftover_tick( $name, $new );
// Last, once the state says what is on disk.
DLOGR_Boot::release_repair();
return array(
'result' => 'dir_renamed',
'name' => $new,
);
}
/**
* Deletes one log directory, asking first when it is not empty.
*
* ⚠️ Every check runs again on the confirmed pass. Between the question
* and the answer sits a whole request: the directory can have become the
* active one, filled up or gone. Nothing established the first time is
* taken on trust the second.
*
* ⚠️ The active directory is refused here as well as having no button.
* The button is what a person sees; this is what a forged POST meets. And
* the refusal is not squeamishness: deleting the active directory would
* not stick, because the loader reports it missing and ensure() puts it
* back under the same name on the next request - see
* development/docs/katalog-logov.md.
*
* @param string $name Directory name from the form.
* @param bool $confirmed Whether this is the second, confirming pass.
* @return string Result code for the URL, or dir_confirm to ask instead.
*/
private static function do_delete_dir( $name, $confirmed ) {
if ( '' === DLOGR_Log_Dir::path( $name ) ) {
DLOGR_Journal::error(
'dir_delete_failed',
sprintf( '%s is not a log directory of this plugin, so it was left alone.', $name )
);
return 'dir_delete_refused';
}
if ( DLOGR_Log_Dir::name() === $name ) {
DLOGR_Journal::error(
'dir_delete_failed',
sprintf( 'The log directory %s is the active one and was left alone: it would be recreated straight away.', $name )
);
return 'dir_delete_active';
}
$had_content = DLOGR_Log_Dir::has_content( $name );
if ( $had_content && ! $confirmed ) {
return 'dir_confirm';
}
$deleted = DLOGR_Log_Dir::delete( $name );
if ( is_wp_error( $deleted ) ) {
DLOGR_Journal::error( 'dir_delete_failed', $deleted->get_error_message() );
return 'dir_delete_failed';
}
DLOGR_Journal::info(
'dir_deleted',
sprintf(
$had_content
? 'Log directory %s was deleted with the log files in it.'
: 'Log directory %s was deleted; it held no logs.',
$name
)
);
self::move_leftover_tick( $name, '' );
return 'dir_deleted';
}
/**
* Keeps the uninstall tick of a leftover in step with its name.
*
* Renamed, the tick follows the directory: the person ticked that
* directory, not that spelling of it. Losing the tick would be the safe way
* to be wrong, but a silent one. Deleted, the name leaves the list, so that
* it does not linger there describing nothing.
*
* @param string $old Name the list may hold.
* @param string $renamed New name, or empty to drop the entry.
* @return void
*/
private static function move_leftover_tick( $old, $renamed ) {
$ticked = (array) DLOGR_Settings::get( 'delete_leftovers_on_uninstall' );
$at = array_search( $old, $ticked, true );
if ( false === $at ) {
return;
}
if ( '' === $renamed ) {
unset( $ticked[ $at ] );
} else {
$ticked[ $at ] = $renamed;
}
DLOGR_Settings::set( 'delete_leftovers_on_uninstall', $ticked );
}
/**
* Makes sure there is a log directory to write to, before logging goes on.
*
* Shared by the two ways of switching on: the Status button and the switch
* on the Settings tab. Takes over a leftover or creates a directory, and
* says which in the journal.
*
* @return bool False when no directory could be set up; the reason is in
* the journal.
*/
private static function prepare_log_dir() {
if ( DLOGR_Log_Dir::is_usable() ) {
/*
* Not a bare return: ensure() is what gives the current directory and
* the leftovers back their protective files, and pressing the button
* is the moment writing to disk was agreed to (2026-09-28). On a
* directory that is there and writable it creates and takes over
* nothing (dorabotki.md, пункт 24).
*/
DLOGR_Log_Dir::ensure();
// Whoever brought it back, a run of failed repairs is over.
DLOGR_Boot::repair_settled();
return true;
}
/*
* Taken before, so that the journal can tell a leftover taken over from
* a directory made new. ensure() does either without saying which, and
* the two read very differently a year later.
*/
$found = '' === DLOGR_Log_Dir::name() ? DLOGR_Log_Dir::find_existing() : array();
$ensured = DLOGR_Log_Dir::ensure();
if ( is_wp_error( $ensured ) ) {
DLOGR_Journal::error( 'dir_create_failed', $ensured->get_error_message() );
return false;
}
if ( in_array( DLOGR_Log_Dir::name(), $found, true ) ) {
DLOGR_Journal::info( 'dir_adopted', sprintf( 'Log directory %s from an earlier install was taken over.', DLOGR_Log_Dir::name() ) );
// No longer a leftover: its uninstall box is now the yes or no of the current directory.
self::move_leftover_tick( DLOGR_Log_Dir::name(), '' );
} else {
DLOGR_Journal::info( 'dir_created', DLOGR_Log_Dir::created_message( $found ) );
}
DLOGR_Boot::repair_settled();
return true;
}
/**
* Switches logging on, and sets it up first when that is still needed.
*
* In a fixed order: whether wp-config.php can be written at all (since
* 2026-10-05, see below), the log directory, the setting, the loader data
* file, and only then the block.
*
* ⚠️ The steps before the block do not depend on the config and are **not
* rolled back** when the block itself fails - a write the tokenizer
* refused, for one. As soon as the block is in place -
* by hand if need be - boot.php finds everything ready and logging starts.
* Undoing them would throw away work that is already correct. A config
* that cannot be written at all no longer gets that far.
*
* From state C the block is already there and the config is not touched at
* all: this is then a setting plus a regenerated data file.
*
* @return string Result code for the URL.
*/
private static function do_enable() {
$config = DLOGR_Config::read();
$needs_block = empty( $config['blocks'] );
/*
* ⚠️ Whether wp-config.php can be written at all is asked first, before
* anything else is set up (заказчик, 2026-10-05, dorabotki.md, пункт 16).
* Until then the three steps below ran first and a refusal came fourth:
* the press left a log directory, a data file and logging switched on
* for a config it could not edit - state D, with the "on, but not built"
* notice on every admin screen for somebody who may simply not go on.
* Now a refusal changes nothing. Nothing is lost by it either: once the
* block is pasted by hand, the loader reports the missing data file and
* the repair sets up the directory and the file on the next request,
* leaving one switch to turn on.
*/
if ( $needs_block ) {
$refused = self::config_refusal( $config );
if ( '' !== $refused ) {
return $refused;
}
}
if ( ! self::prepare_log_dir() ) {
return 'dir_failed';
}
DLOGR_Settings::set( 'logging_enabled', true );
$written = DLOGR_Boot::write();
if ( is_wp_error( $written ) ) {
DLOGR_Settings::set( 'logging_enabled', false );
DLOGR_Journal::error( 'boot_data_write_failed', $written->get_error_message() );
return 'boot_failed';
}
DLOGR_Journal::info( 'logging_enabled', 'Logging switched on.' );
if ( ! $needs_block ) {
return 'enabled';
}
return self::add_block( $config );
}
/**
* Why wp-config.php cannot take the block, asked before anything is done.
*
* The first step of the enable button since 2026-10-05; until then these
* checks opened the fourth. Each refusal goes into the journal, as it did
* there.
*
* Shared with the button that updates the block since 2026-10-15; the
* journal code says which of the two was refused.
*
* @param array $config Result of DLOGR_Config::read().
* @param string $code Journal code of the refusal.
* @return string Result code for the URL, empty when the file can be written.
*/
private static function config_refusal( array $config, $code = 'config_block_add_failed' ) {
if ( false === $config['path'] ) {
DLOGR_Journal::error( $code, 'wp-config.php was not found.' );
return 'config_missing';
}
if ( ! self::config_is_direct( $config['path'] ) ) {
/*
* Not a failure and not an accident: the manual path is the second
* normal outcome, and the fragment for it is already on the page.
* Nothing has been set up by now, and nothing needs to be - see
* do_enable().
*/
DLOGR_Journal::error( $code, 'WordPress cannot write files directly, so wp-config.php was left alone.' );
return 'config_manual';
}
/*
* ⚠️ Checked here, not left to the write (2026-10-04). get_filesystem_method()
* looks at the directory only, and "direct method, writable directory,
* read-only wp-config.php" is a standard hardening setup. This comment
* used to say the write itself would refuse such a file. It would not:
* the config is replaced by rename(), which needs write access to the
* directory and none to the file, so the hardening would simply be
* undone.
*/
if ( ! $config['writable'] ) {
DLOGR_Journal::error( $code, 'wp-config.php is closed for writing, so it was left alone.' );
return 'dlogr_config_read_only';
}
return '';
}
/**
* The fourth step: puts the block into wp-config.php.
*
* Everything risky about this plugin is in these few lines: write, then
* record what was written. Whether the file can be written at all was
* established before the first step, by config_refusal(). The write
* itself never leaves a half-edited file: DLOGR_Config::write() checks the
* finished text before it replaces anything.
*
* @param array $config Result of DLOGR_Config::read().
* @return string Result code for the URL.
*/
private static function add_block( array $config ) {
$written = DLOGR_Config::write( $config['path'], DLOGR_PLUGIN_DIR . 'boot.php', DLOGR_Log_Dir::base() );
if ( is_wp_error( $written ) ) {
DLOGR_Journal::error( 'config_block_add_failed', $written->get_error_message() );
/*
* The error's own code travels on, so the page can say why rather
* than "it did not work". They are all codes of ours, lower case
* with underscores, which is exactly what sanitize_key() keeps.
*/
return $written->get_error_code();
}
DLOGR_Settings::set_state( 'config_written', time() );
DLOGR_Journal::info( 'config_block_added', 'Block added to wp-config.php.' );
return 'config_written';
}
/**
* Writes the block again with the current paths, when it no longer loads the plugin.
*
* The button in the red card on the Status tab (заказчик, 2026-10-15).
* Until then the way out was two presses - remove the setting, set it up
* again - with the owner's WP_DEBUG live in between. The plugin does not
* do this by itself, and on purpose: the fix is an edit of wp-config.php,
* and which side is right is not always the plugin's to know - a folder
* filter switched off for an hour, a staging copy. Разбор -
* development/docs/wp-config.md, «Обновление блока».
*
* The order is the enable button's, for the same reason: everything that
* does not touch the config first, the config last. The log directory and
* the data file are set up in the current uploads folder, so the next
* request finds everything in place and there is nothing left to repair.
* They are not rolled back when the config refuses: they are correct, and
* a block replaced by hand finds them ready.
*
* Entries written before stay where the loader put them. The journal
* names that folder when the block named one, because the page cannot:
* after the redirect the old block is gone.
*
* @return string Result code for the URL.
*/
private static function do_update_block() {
$config = DLOGR_Config::read();
$refused = self::config_refusal( $config, 'config_block_update_failed' );
if ( '' !== $refused ) {
return 'config_manual' === $refused ? 'update_manual' : $refused;
}
if ( ! self::prepare_log_dir() ) {
return 'update_dir_failed';
}
$written = DLOGR_Boot::write();
if ( is_wp_error( $written ) ) {
DLOGR_Journal::error( 'boot_data_write_failed', $written->get_error_message() );
return 'boot_failed';
}
$replaced = DLOGR_Config::replace_block( $config['path'], DLOGR_PLUGIN_DIR . 'boot.php', DLOGR_Log_Dir::base() );
if ( is_wp_error( $replaced ) ) {
DLOGR_Journal::error( 'config_block_update_failed', $replaced->get_error_message() );
return $replaced->get_error_code();
}
DLOGR_Settings::set_state( 'config_written', time() );
$message = 'Block in wp-config.php updated with the current paths.';
if ( defined( 'DLOGR_BASE_DIR' ) && ! DLOGR_Boot::base_matches() ) {
$old = untrailingslashit( wp_normalize_path( (string) DLOGR_BASE_DIR ) );
$message .= sprintf( ' Entries written before stay in %s.', basename( dirname( $old ) ) . '/' . basename( $old ) );
}
DLOGR_Journal::info( 'config_block_updated', $message );
return 'block_updated';
}
/**
* Takes our block back out of wp-config.php.
*
* ⚠️ Not the same operation as switching logging off, and the difference is
* not which files are written. The toggle leaves the block in place, which
* means the site's debug settings stay in this plugin's hands: a
* define( 'WP_DEBUG', true ) of the owner's stays commented out and
* boot.php declares it false. This gives those lines back.
*
* Logging is switched off **after** the config has been changed, not
* before: a failure then means nothing happened at all, rather than "logging
* off, block still there".
*
* @return string Result code for the URL.
*/
private static function do_remove() {
$config = DLOGR_Config::read();
if ( false === $config['path'] ) {
DLOGR_Journal::error( 'config_block_remove_failed', 'wp-config.php was not found.' );
return 'config_missing';
}
if ( empty( $config['blocks'] ) && empty( $config['disabled'] ) ) {
return 'nothing_to_remove';
}
if ( ! self::config_is_direct( $config['path'] ) ) {
DLOGR_Journal::error( 'config_block_remove_failed', 'WordPress cannot write files directly, so wp-config.php was left alone.' );
return 'remove_manual';
}
// Same reasons as in add_block(): rename() would replace a read-only file all the same.
if ( ! $config['writable'] ) {
DLOGR_Journal::error( 'config_block_remove_failed', 'wp-config.php is closed for writing, so it was left alone.' );
return 'dlogr_config_read_only';
}
$removed = DLOGR_Config::remove( $config['path'] );
if ( is_wp_error( $removed ) ) {
DLOGR_Journal::error( 'config_block_remove_failed', $removed->get_error_message() );
return $removed->get_error_code();
}
DLOGR_Settings::set( 'logging_enabled', false );
/*
* There is no block any more, so the record of it goes. Cleared through
* DLOGR_Settings, which uninstall.php calls as well: the rule is one,
* so the code for it is one.
*/
DLOGR_Settings::clear_config_state();
$written = DLOGR_Boot::write();
if ( is_wp_error( $written ) ) {
DLOGR_Journal::error( 'boot_data_write_failed', $written->get_error_message() );
}
/*
* Read again rather than guessed at: whether a line had to stay
* commented out is decided inside the removal, and the page has to say
* so straight away instead of leaving it to be found in the file.
*/
$after = DLOGR_Config::read( $config['path'] );
if ( ! empty( $after['kept'] ) ) {
DLOGR_Journal::info(
'config_block_removed',
sprintf(
'Block removed from wp-config.php. %d line(s) stayed commented out: the file declares those constants elsewhere.',
count( $after['kept'] )
)
);
return 'block_removed_kept';
}
DLOGR_Journal::info( 'config_block_removed', 'Block removed from wp-config.php and the commented out lines restored.' );
return 'block_removed';
}
/**
* Whether WordPress can write to the directory the config lives in.
*
* ⚠️ The context matters. Without it the core tests WP_CONTENT_DIR, where
* the permissions are usually different from the site root - and the site
* root is where wp-config.php is.
*
* This is a gate, not a promise: the method says what transport WordPress
* would use, and the file itself may still be read-only. Whether the write
* works is decided by the write.
*
* @param string $path Absolute path to wp-config.php.
* @return bool
*/
private static function config_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 );
}
/**
* Saves everything on the Settings tab: the one button of it.
*
* Replaces four handlers - language, error display, uninstall boxes and the
* logging toggle - with one, because the tab has one form and one button
* since 2026-09-28 (заказчик). Разбор - development/docs/admin.md, «Одна
* форма, одна кнопка».
*
* 1. Everything is checked first. One value that is not on its list, and
* nothing is changed at all.
* 2. Language and uninstall boxes are stored as they came.
* 3. Logging and the display mode change only if they differ from what the
* page showed - carried in the *_shown fields. A page left open while
* logging was switched elsewhere would otherwise flip it back just
* because somebody changed the language.
* 4. A missing *_shown field means the control was never drawn (states A
* and D), and that is "leave it", not "off": an unticked box sends
* nothing either, and the two would read the same without the marker.
* 5. boot-data.json is rewritten once for both, and only when one of them
* changed.
*
* ⚠️ When the loader file cannot be written, a new display mode and a
* logging switched on go back, as they always did; logging switched off
* stays off, as do_disable() used to leave it. The database then says what
* the file will say the next time it is written.
*
* @return string Result code for the URL.
*/
private static function do_save_settings() {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Checked in handle() before this is reached.
/*
* Not sanitize_key() on the language: it lowercases, and en_US would
* arrive as en_us. The value is held against the list below, so
* nothing else gets through.
*/
$language = isset( $_POST['dlogr_language'] ) ? sanitize_text_field( wp_unslash( $_POST['dlogr_language'] ) ) : null;
$display = isset( $_POST['dlogr_display'] ) ? sanitize_key( wp_unslash( $_POST['dlogr_display'] ) ) : '';
$display_shown = isset( $_POST['dlogr_display_shown'] ) ? sanitize_key( wp_unslash( $_POST['dlogr_display_shown'] ) ) : null;
$logging_shown = isset( $_POST['dlogr_logging_shown'] ) ? sanitize_key( wp_unslash( $_POST['dlogr_logging_shown'] ) ) : null;
$logging = isset( $_POST['dlogr_logging'] );
$log_dir = isset( $_POST['dlogr_delete_log_dir'] );
$data = isset( $_POST['dlogr_delete_data'] );
$leftovers = isset( $_POST['dlogr_delete_leftovers'] ) && is_array( $_POST['dlogr_delete_leftovers'] )
? array_map( 'sanitize_text_field', wp_unslash( $_POST['dlogr_delete_leftovers'] ) )
: array();
// phpcs:enable WordPress.Security.NonceVerification.Missing
$modes = array( 'off', 'on', 'server' );
if ( null !== $language && 'default' !== $language && ! array_key_exists( $language, DLOGR_Plugin::languages() ) ) {
return 'language_bad';
}
if ( null !== $display_shown && ! in_array( $display, $modes, true ) ) {
return 'display_bad';
}
if ( null !== $language ) {
DLOGR_Settings::set( 'interface_language', $language );
}
/*
* Only leftovers that are on disk right now, and never the current
* directory: its box is the yes or no above, and a name of it on this
* list would outlive a rename that the yes or no does not notice.
*/
$leftovers = array_values( array_intersect( $leftovers, DLOGR_Log_Dir::leftovers() ) );
DLOGR_Settings::set( 'delete_log_dir_on_uninstall', $log_dir );
DLOGR_Settings::set( 'delete_leftovers_on_uninstall', $leftovers );
DLOGR_Settings::set( 'delete_data_on_uninstall', $data );
$old_display = (string) DLOGR_Settings::get( 'debug_display' );
$old_logging = (bool) DLOGR_Settings::get( 'logging_enabled' );
$new_display = ( null !== $display_shown && $display !== $display_shown ) ? $display : $old_display;
$new_logging = ( null !== $logging_shown && ( $logging ? '1' : '0' ) !== $logging_shown ) ? $logging : $old_logging;
if ( $new_display === $old_display && $new_logging === $old_logging ) {
return 'settings_saved';
}
/*
* Both controls are drawn only while the block is in wp-config.php. If
* it has gone since the page was drawn, neither setting has anything to
* act through: switching on would make state D, a loader file for a
* loader nobody includes. The rest is saved; these two wait for the
* page to be drawn again with the truth.
*/
$config = DLOGR_Config::read();
if ( empty( $config['blocks'] ) ) {
return 'settings_saved';
}
$result = 'settings_saved';
if ( $new_logging && ! $old_logging && ! self::prepare_log_dir() ) {
// No directory: logging stays off, the display mode still goes ahead.
$new_logging = false;
$result = 'dir_failed';
if ( $new_display === $old_display ) {
return $result;
}
}
DLOGR_Settings::set( 'debug_display', $new_display );
DLOGR_Settings::set( 'logging_enabled', $new_logging );
$written = DLOGR_Boot::write();
if ( is_wp_error( $written ) ) {
DLOGR_Settings::set( 'debug_display', $old_display );
DLOGR_Settings::set( 'logging_enabled', $old_logging && $new_logging );
DLOGR_Journal::error( 'boot_data_write_failed', $written->get_error_message() );
return 'boot_failed';
}
if ( $new_display !== $old_display ) {
DLOGR_Journal::info(
'display_mode_changed',
sprintf( 'Error display mode changed from %1$s to %2$s.', $old_display, $new_display )
);
}
if ( $new_logging && ! $old_logging ) {
DLOGR_Journal::info( 'logging_enabled', 'Logging switched on.' );
} elseif ( ! $new_logging && $old_logging ) {
DLOGR_Journal::info( 'logging_disabled', 'Logging switched off.' );
}
return $result;
}
/**
* Human-readable text for a result code coming back in the URL.
*
* @param string $code Result code.
* @param string $name Log directory name, for the one message that needs it.
* @return array Keys type and text, or an empty array for an unknown code.
*/
private static function result_notice( $code, $name = '' ) {
/*
* The one message that names something: renaming is done so that a path
* somebody wrote down stops being the right one, and the reply owes them
* the path that is right now. Everything else in the map is a fixed text.
*/
if ( 'dir_renamed' === $code ) {
return array(
'type' => 'success',
'text' => '' === $name
? __( 'The log directory has a new name. It is in the list below.', 'debug-log-router' )
: sprintf(
/* translators: %s: new path of the log directory, starting at the uploads folder. */
__( 'The log directory is now %s. Nothing inside it moved, and the old path leads nowhere.', 'debug-log-router' ),
DLOGR_Log_Dir::label( $name )
),
);
}
if ( 'download_dir_too_big' === $code ) {
return array(
'type' => 'error',
'text' => '' === $name
? __( 'That directory is larger than this page packs in one go. Download the files one at a time from the Logs tab.', 'debug-log-router' )
: sprintf(
/* translators: 1: size of the directory, 2: the limit. */
__( 'That directory holds %1$s, more than the %2$s this page packs in one go. Download the files one at a time from the Logs tab.', 'debug-log-router' ),
$name,
size_format( DLOGR_Download::MAX_BYTES )
),
);
}
if ( 'download_dir_space' === $code ) {
return array(
'type' => 'error',
'text' => '' === $name
? __( 'There is not enough free space to build the archive, so nothing was written.', 'debug-log-router' )
: sprintf(
/* translators: %s: size of the directory. */
__( 'There is not enough free space to build an archive of %s, so nothing was written.', 'debug-log-router' ),
$name
),
);
}
$map = array(
'download_missing' => array( 'error', __( 'That log file is not on disk any more, so there was nothing to download.', 'debug-log-router' ) ),
'download_unknown' => array( 'error', __( 'There is no log file by that name, so nothing was downloaded.', 'debug-log-router' ) ),
'download_unreadable' => array( 'error', __( 'That log file could not be opened, so nothing was downloaded. Check the file permissions.', 'debug-log-router' ) ),
'download_dir_missing' => array( 'error', __( 'That log directory is not on disk any more, so there was nothing to pack.', 'debug-log-router' ) ),
'download_dir_empty' => array( 'error', __( 'That log directory holds nothing but the files the plugin puts there itself, so there was nothing to pack.', 'debug-log-router' ) ),
'download_dir_no_zip' => array( 'error', __( 'Packing a directory needs the PHP zip extension, and it is not installed here. The files can still be downloaded one at a time from the Logs tab.', 'debug-log-router' ) ),
'download_dir_failed' => array( 'error', __( 'The archive could not be built, so nothing was downloaded. A file inside the directory that cannot be read is the usual reason.', 'debug-log-router' ) ),
'download_journal_missing' => array( 'error', __( 'The journal table is not in the database, so there was nothing to export.', 'debug-log-router' ) ),
'download_journal_failed' => array( 'error', __( 'The journal could not be written out, so nothing was downloaded. No room in the temporary directory is the usual reason.', 'debug-log-router' ) ),
'settings_saved' => array( 'success', __( 'Settings saved.', 'debug-log-router' ) ),
'display_bad' => array( 'error', __( 'That is not one of the three display modes, so nothing was changed.', 'debug-log-router' ) ),
'language_bad' => array( 'error', __( 'That is not one of the language options, so nothing was changed.', 'debug-log-router' ) ),
'enabled' => array( 'success', __( 'Logging is on.', 'debug-log-router' ) ),
'config_written' => array( 'success', __( 'Logging is on and wp-config.php now loads the plugin.', 'debug-log-router' ) ),
'config_manual' => array( 'warning', __( 'Nothing was changed: WordPress cannot write files directly where wp-config.php is, so the file was left alone and nothing else was set up either. Paste the block below into wp-config.php by hand, then switch logging on.', 'debug-log-router' ) ),
'config_failed' => array( 'error', __( 'wp-config.php was left exactly as it was: the edit did not pass its own checks. Everything else is set up, so pasting the block below by hand is all that is missing.', 'debug-log-router' ) ),
'config_missing' => array( 'error', __( 'wp-config.php was not found where WordPress looks for it, so nothing was written to it.', 'debug-log-router' ) ),
'block_removed' => array( 'success', __( 'The block is out of wp-config.php and the lines it had commented out are active again. Logging is off; the log directory and the settings were kept.', 'debug-log-router' ) ),
'block_removed_kept' => array( 'warning', __( 'The block is out of wp-config.php, but not every line could be given back: the file declares those constants elsewhere too, and restoring them would declare the same constant twice. See below for which lines and why.', 'debug-log-router' ) ),
'remove_manual' => array( 'warning', __( 'WordPress cannot write files directly here, so wp-config.php was left alone. Delete the block by hand and drop the DLOGR-DISABLED prefixes; the lines are listed above.', 'debug-log-router' ) ),
'remove_failed' => array( 'error', __( 'wp-config.php was left exactly as it was: taking the block out did not pass its own checks. It can be removed by hand instead.', 'debug-log-router' ) ),
'nothing_to_remove' => array( 'warning', __( 'There is nothing of this plugin in wp-config.php to remove.', 'debug-log-router' ) ),
'block_updated' => array( 'success', __( 'The block in wp-config.php now names the current paths, and entries go to the log directory in the current uploads folder. Entries written before stay where they were; the plugin journal names that folder.', 'debug-log-router' ) ),
'update_manual' => array( 'warning', __( 'WordPress cannot write files directly here, so wp-config.php was left alone. Replace the block by hand with the one shown below.', 'debug-log-router' ) ),
'update_dir_failed' => array( 'error', __( 'wp-config.php was left alone: no log directory could be set up in the current uploads folder. Check that it is writable by the web server; the plugin journal has the details.', 'debug-log-router' ) ),
/*
* ⚠️ Refusals that have a reason a person can act on get that
* reason spelled out. "It did not pass the checks" is true of all
* of them and useful for none: the one thing somebody needs to know
* is what to change. The line numbers are not repeated here - the
* page below re-reads the file and lists them.
*/
'dlogr_config_shared_line' => array( 'error', __( 'wp-config.php was left alone: one of the constants this plugin manages shares its line with other code. Commenting that line out would have taken the other statement with it. Move the other statement onto a line of its own, or paste the block in by hand — the lines are listed below.', 'debug-log-router' ) ),
'dlogr_config_open_statement' => array( 'error', __( 'wp-config.php was left alone: one of the declarations has no closing semicolon, so where it ends is unknown.', 'debug-log-router' ) ),
'dlogr_config_no_anchor' => array( 'error', __( 'wp-config.php was left alone: no line in it loads wp-settings.php, so there is no safe place to put the block. Paste it in by hand.', 'debug-log-router' ) ),
'dlogr_config_anchor_shared' => array( 'error', __( 'wp-config.php was left alone: the line that loads wp-settings.php has other code in front of it, and a block put there could not be taken out again cleanly. Paste the block in by hand, on a line of its own.', 'debug-log-router' ) ),
'dlogr_config_unclosed_block' => array( 'error', __( 'wp-config.php was left alone: a block of this plugin has no closing marker, so where it ends is unknown. Repair or delete it by hand first.', 'debug-log-router' ) ),
'dlogr_config_parse_error' => array( 'error', __( 'wp-config.php was left alone: it did not parse, so what is in it is unknown. A config that does not parse would take the site down, so check it first.', 'debug-log-router' ) ),
'dlogr_config_unreadable' => array( 'error', __( 'wp-config.php could not be read, so it was not changed. Check the file permissions.', 'debug-log-router' ) ),
'dlogr_config_nothing_to_remove' => array( 'warning', __( 'There is nothing of this plugin in wp-config.php to remove.', 'debug-log-router' ) ),
'dlogr_config_write_failed' => array( 'error', __( 'The new wp-config.php could not be written next to the old one, which was left untouched. Check the permissions on the directory it lives in.', 'debug-log-router' ) ),
'dlogr_config_rename_failed' => array( 'error', __( 'The new wp-config.php could not take the place of the old one, which was left untouched.', 'debug-log-router' ) ),
'dlogr_config_read_only' => array( 'error', __( 'wp-config.php was left alone: the file itself is closed for writing, and this plugin does not open it. The change can be made by hand — this page shows what to paste or remove — or after allowing writes to the file.', 'debug-log-router' ) ),
'dir_failed' => array( 'error', __( 'No log directory could be set up, so logging stays off. Check that the uploads folder is writable by the web server; the plugin journal has the details.', 'debug-log-router' ) ),
'dir_deleted' => array( 'success', __( 'The log directory is gone, with everything that was in it.', 'debug-log-router' ) ),
'dir_rename_failed' => array( 'error', __( 'The log directory was not renamed and nothing was changed. A file inside it held open by another process is the usual reason; the journal has the details.', 'debug-log-router' ) ),
'dir_delete_failed' => array( 'error', __( 'The log directory could not be removed; something inside it stayed. The journal names the file. A log another process still has open is the usual reason, and trying again later usually works.', 'debug-log-router' ) ),
'dir_delete_active' => array( 'error', __( 'The current log directory is not deleted: it would come straight back, because the plugin recreates the directory it is told to write to.', 'debug-log-router' ) ),
'dir_delete_refused' => array( 'error', __( 'That is not a log directory of this plugin, so it was left alone.', 'debug-log-router' ) ),
'boot_failed' => array( 'error', __( 'The loader data file could not be written. Check that the uploads folder is writable by the web server.', 'debug-log-router' ) ),
);
if ( isset( $map[ $code ] ) ) {
return array(
'type' => $map[ $code ][0],
'text' => $map[ $code ][1],
);
}
/*
* Anything else our own writing half can return: the checks it runs on
* the finished text before letting it near the disk. Those failures are
* technical - a marker count, a lost require - and there is nothing for
* the reader to do about them except know that the file was not touched.
* The exact message is in the journal.
*/
if ( 0 === strpos( $code, 'dlogr_config_' ) ) {
return array(
'type' => 'error',
'text' => __( 'wp-config.php was left exactly as it was: the edited version did not pass the checks this plugin runs before writing. The plugin journal has the exact reason.', 'debug-log-router' ),
);
}
return array();
}
/**
* Draws the page.
*
* @return void
*/
public static function render() {
if ( ! current_user_can( self::capability() ) ) {
return;
}
$snapshot = self::snapshot();
$blockers = self::blockers( $snapshot );
$tab = self::current_tab();
// dlogr-page scopes the palette and the restyled tabs to this page.
echo '
';
/*
* The outcome of the last press goes first, above the title
* (заказчик, 2026-09-28): it is the answer to what was just done, and
* the first thing looked for after the page comes back.
*/
self::render_result();
echo '
';
self::render_tabs( $tab );
self::render_journal_warning( $snapshot );
if ( 'logs' === $tab ) {
self::render_logs( $snapshot );
} elseif ( 'support' === $tab ) {
self::render_support();
} elseif ( 'settings' === $tab ) {
/*
* Directories first: without one nothing is written at all, while
* everything below only refines how an already working plugin
* behaves. Their buttons act at once, each in a form of its own, so
* they stand outside the settings form - forms cannot nest.
*
* ⚠️ Language is no longer first (заказчик, 2026-09-28, reversing
* 2026-09-15): with it in the row below, everything that is saved
* stands together and one plain form holds it. Разбор -
* development/docs/oformlenie.md, «Вкладка «Настройки»».
*/
self::blocks(
array(
function () use ( $snapshot ) {
self::render_directories( $snapshot );
},
function () use ( $snapshot ) {
self::render_leftovers( $snapshot );
},
)
);
/*
* One form, one button, for every setting on the tab (заказчик,
* 2026-09-28). Its only submit button is Save, so Enter in any field
* saves rather than pressing some other button further up the page.
* Разбор - development/docs/admin.md, «Одна форма, одна кнопка».
*/
echo '';
} else {
self::blocks(
array(
/*
* The warning about lines left commented out belongs with
* the state block: it speaks about the same state of the
* config the table above it describes.
*/
function () use ( $snapshot ) {
self::render_state( $snapshot );
self::render_kept( $snapshot );
},
/*
* Ahead of the blockers on purpose: those answer why the
* button will not press, while here everything presses and
* everything looks right - which is exactly why this has to
* be said earlier.
*/
function () use ( $snapshot ) {
self::render_constants( $snapshot );
},
function () use ( $blockers ) {
self::render_blockers( $blockers );
},
/*
* The button belongs with the fragment, and not by taste:
* its caption points at the constants and the block printed
* above it. A line between them would cut the text off from
* what it points at.
*/
function () use ( $snapshot, $blockers ) {
self::render_fragment( $snapshot );
self::render_form( $snapshot, $blockers );
},
function () use ( $snapshot ) {
self::render_removal( $snapshot );
},
)
);
}
self::print_sprite();
echo '
';
}
/**
* Draws the blocks of a tab, each in a card of its own.
*
* ⚠️ The card lives here and nowhere else: a block does not know it is in
* one, so a block added later gets its card without knowing anything about
* it. Cards replaced the rule between blocks (заказчик, 2026-09-28); what
* made the rule belong to the tab rather than to a block holds for the card
* as well.
*
* ⚠️ The output is captured rather than the block being asked whether it
* intends to draw. Half the blocks of the Status tab are conditional, and a
* predicate in the caller would be a second copy of a condition that
* already lives inside the block - two copies that drift apart on the first
* edit of either. Buffering asks the one question that cannot go stale: did
* anything come out. A block that drew nothing gets no card, the same way it
* used to get no line: an empty card would hang there just as the line did.
*
* Grouping is what the caller expresses by putting several renderers in one
* callback: not every renderer is a block of its own. A list of callbacks in
* place of one is a row of cards side by side; its empty cards are skipped,
* and a row with nothing in it is not drawn at all.
*
* Разбор - development/docs/oformlenie.md, «Карточки вместо линий».
*
* @param array $blocks Renderers in the order drawn: a callable per block,
* or a list of callables for a row.
* @return void
*/
private static function blocks( array $blocks ) {
foreach ( $blocks as $block ) {
/*
* is_callable() first: an array can be a callable itself, and a
* callable is never a row. A list of closures is not callable.
*/
if ( ! is_callable( $block ) && is_array( $block ) ) {
$cards = array();
foreach ( $block as $card ) {
$out = self::capture( $card );
if ( '' !== trim( $out ) ) {
$cards[] = $out;
}
}
if ( empty( $cards ) ) {
continue;
}
/*
* Classed by how many the row was written for, not how many
* drew: where it goes to a column is a matter of the width it
* was designed at.
*/
printf( '
', (int) count( $block ) );
foreach ( $cards as $out ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by the block that produced it; this only moves bytes that were about to be printed anyway.
echo '
' . $out . '
';
}
echo '
';
continue;
}
$out = self::capture( $block );
if ( '' === trim( $out ) ) {
continue;
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by the block that produced it; this only moves bytes that were about to be printed anyway.
echo '
' . $out . '
';
}
}
/**
* Runs one renderer and hands back what it printed.
*
* @param callable $block Renderer.
* @return string
*/
private static function capture( callable $block ) {
ob_start();
/*
* finally, because the buffer is state that has to be unwound. Let a
* block throw past an ob_get_clean() and the buffer stays open to the
* end of the request, where PHP flushes it after the error it was
* supposed to precede. Direct printing had nothing to leak; capturing
* the output is what creates the obligation.
*/
try {
$block();
} finally {
$out = ob_get_clean();
}
return (string) $out;
}
/**
* Draws a notice inside the page.
*
* The page's own box rather than core's .notice (заказчик, 2026-09-28):
* tinted as a whole by how serious it is. Notices on every other admin
* screen stay core's - those screens are not ours. Разбор -
* development/docs/oformlenie.md, «Плашки».
*
* @param string $type One of info, success, warning, error.
* @param string $body Inner HTML, escaped by the caller; paragraphs included.
* @return void
*/
private static function note( $type, $body ) {
self::note_open( $type );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by the caller, which is the one that knows what is inside.
echo $body;
self::note_close();
}
/**
* Opens a notice box, for the one caller whose body holds forms.
*
* @param string $type One of info, success, warning, error.
* @return void
*/
private static function note_open( $type ) {
$icons = array(
'info' => 'info',
'success' => 'circle-check',
'warning' => 'triangle-alert',
'error' => 'circle-alert',
);
$type = isset( $icons[ $type ] ) ? $type : 'info';
printf( '
';
}
/**
* Closes what note_open() opened.
*
* @return void
*/
private static function note_close() {
echo '
';
}
/**
* Prints one icon from the sprite.
*
* Decorative only: there is always text next to it, so it is hidden from
* screen readers and kept out of the tab order. Size and colour come from
* the stylesheet - dlogr-icon, and currentColor from whatever it sits in.
*
* @param string $name Icon name, as in assets/icons/sprite.svg without the prefix.
* @return void
*/
private static function icon( $name ) {
printf(
'',
esc_attr( $name )
);
}
/**
* Prints the icon sprite into the page, once.
*
* ⚠️ Into the page, not referenced as a file.