%1$s %2$s

%4$s

', 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( '

%2$s %3$s

%5$s  ·  %7$s

', 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 '

' . esc_html__( 'Debug Log Router', 'debug-log-router' ) . '

'; 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 '
'; wp_nonce_field( self::NONCE ); echo ''; self::blocks( array( array( function () { self::render_language(); }, function () use ( $snapshot ) { self::render_toggle( $snapshot ); }, function () use ( $snapshot ) { self::render_display( $snapshot ); }, ), function () use ( $snapshot ) { self::render_settings( $snapshot ); }, ) ); self::render_save_bar(); 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( '
', esc_attr( $type ) ); self::icon( $icons[ $type ] ); echo '
'; } /** * 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. * breaks wherever plugin assets are served from a CDN on another domain: * browsers do not let reach across origins. Printed at the end of the * page, where it cannot get between anything and its :first-child rule; * finds a symbol wherever in the document it is. * * Put through wp_kses() although the file is ours: printing a file * unescaped is what neither phpcs nor a WordPress.org review lets through. * * ⚠️ kses lowercases attribute names, and viewBox comes out as viewbox. * SVG attributes are case-sensitive, and a symbol without its viewBox draws * a 24-unit icon cropped into a 20-pixel box. Browsers following the HTML * standard give the case back while parsing, but that is one more thing to * trust; the case is put back here instead, on the one attribute that has * it, after kses has had its say about everything else. * * Icons are Lucide, lucide-static 1.48.0, ISC with a few under MIT - the * licence is in assets/icons/LICENSE. Разбор - development/docs/oformlenie.md, * «Иконки». * * @return void */ private static function print_sprite() { $file = DLOGR_PLUGIN_DIR . 'assets/icons/sprite.svg'; if ( ! is_readable( $file ) ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A small local file of this plugin, not a remote URL. $svg = file_get_contents( $file ); if ( false === $svg ) { return; } $clean = wp_kses( $svg, array( 'svg' => array( 'xmlns' => true, 'class' => true, 'width' => true, 'height' => true, 'aria-hidden' => true, 'focusable' => true, ), 'symbol' => array( 'id' => true, 'viewbox' => true, ), 'path' => array( 'd' => true, ), 'circle' => array( 'cx' => true, 'cy' => true, 'r' => true, ), 'line' => array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, ), ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Passed through wp_kses() just above; only the case of one attribute name changes here. echo str_replace( ' viewbox="', ' viewBox="', $clean ); } /** * Draws the tab strip. * * @param string $current Slug of the open tab. * @return void */ private static function render_tabs( $current ) { echo ''; } /** * The Support tab: what the plugin is, where to report a bug, how to say * thanks, and the small print. * * Static: nothing here reads the state of the site, so it needs no snapshot * and has no form. The bug and thanks cards share a row, both being short * (заказчик, 2026-10-08). Разбор - development/docs/admin.md, «Вкладка * «Поддержка»». * * @return void */ private static function render_support() { self::blocks( array( function () { self::render_about(); }, array( function () { self::render_bug_report(); }, function () { self::render_thanks(); }, ), ) ); self::render_support_footer(); } /** * First card of the Support tab: logo, what the plugin does, single sites * only, free for good, and what happens to the data in the logs. * * The logo carries an empty alt: the plugin name stands next to it as text, * and a screen reader would only read it twice. Versioned like admin.css, * so a new logo in a new release is not served from the browser cache. * * @return void */ private static function render_about() { echo '
'; printf( '', esc_url( add_query_arg( 'ver', DLOGR_VERSION, DLOGR_PLUGIN_URL . 'assets/images/logo.png' ) ) ); echo '
'; echo '

' . esc_html__( 'Debug Log Router', 'debug-log-router' ) . '

'; printf( '

%s

', esc_html__( 'Debug Log Router sorts WordPress debug log entries into separate files by where the request came from: the site front end, the admin area, ajax, the REST API, cron, WP-CLI or XML-RPC. The files stay in the standard PHP format, so you can read them here, download them or open them over FTP as before.', 'debug-log-router' ) ); printf( '

%s

', esc_html__( 'The plugin is built for single sites. Multisite networks are not supported.', 'debug-log-router' ) ); printf( '

%s

', esc_html__( 'I build Debug Log Router entirely in my free time and keep it free for everyone: no premium version, no paywalled features.', 'debug-log-router' ) ); printf( '

%s

', esc_html__( "Log entries can contain data about the site's visitors and users: page addresses, names, fragments of database queries. The plugin keeps them on your server only and sends nothing anywhere.", 'debug-log-router' ) ); echo '

'; self::external_link( 'https://wordpress.org/plugins/' . self::PAGE . '/#faq', __( 'Frequently asked questions', 'debug-log-router' ) ); echo '

'; echo '
'; } /** * Second card: where to report a bug. * * The address goes out through antispambot(), core's own encoding into * HTML entities: it stops only the simplest harvesters, but costs nothing. * esc_attr() and esc_html() leave those entities alone - they do not * encode twice - so the escaped link still decodes to the address. * * The links to WordPress.org lead nowhere until the plugin is published; * the addresses themselves will not change then (2026-10-08). * * @return void */ private static function render_bug_report() { echo '

'; self::icon( 'bug' ); echo esc_html__( 'Found a bug?', 'debug-log-router' ) . '

'; if ( '' !== self::SUPPORT_EMAIL ) { $email = antispambot( self::SUPPORT_EMAIL ); printf( '

%s

', sprintf( /* translators: %s: email address, as a link. */ esc_html__( 'If the plugin does not work as expected, please write to %s. Describe what you did and what happened, and include your WordPress and PHP versions. I will try to fix the bug as soon as I can.', 'debug-log-router' ), '' . esc_html( $email ) . '' ) ); } echo '

'; self::external_link( 'https://wordpress.org/support/plugin/' . self::PAGE . '/', __( 'Support forum on WordPress.org', 'debug-log-router' ) ); echo '

'; } /** * Third card: rating and coffee. * * ⚠️ The coffee sentence is a string of its own and goes together with the * button: until DONATE_URL is given, the paragraph would otherwise offer a * coffee there is no way to buy. * * @return void */ private static function render_thanks() { echo '

'; self::icon( 'heart' ); echo esc_html__( 'Enjoying the plugin?', 'debug-log-router' ) . '

'; $text = __( 'Thank you for using Debug Log Router! If it has helped you, please rate it on WordPress.org: reviews help others find the plugin and motivate me to keep developing it.', 'debug-log-router' ); if ( '' !== self::DONATE_URL ) { $text .= ' ' . __( 'And if you would like to buy me a coffee, I would be very grateful.', 'debug-log-router' ); } printf( '

%s

', esc_html( $text ) ); echo '

'; self::external_link( 'https://wordpress.org/support/plugin/' . self::PAGE . '/reviews/#new-post', __( 'Rate on WordPress.org', 'debug-log-router' ), 'star' ); if ( '' !== self::DONATE_URL ) { self::external_link( self::DONATE_URL, __( 'Buy me a coffee', 'debug-log-router' ), 'coffee' ); } echo '

'; } /** * The small print under the cards: version, requirements, licences. * * Requirements are read from the plugin header rather than repeated here, * so the header stays their one source. Each item is a string of its own * and the dots between them are markup: a translator never has to keep a * separator in place. * * @return void */ private static function render_support_footer() { $header = get_file_data( DLOGR_PLUGIN_FILE, array( 'wp' => 'Requires at least', 'php' => 'Requires PHP', ) ); /* translators: %s: plugin version number. */ $items = array( sprintf( __( 'Version %s', 'debug-log-router' ), DLOGR_VERSION ) ); if ( '' !== $header['wp'] ) { /* translators: %s: lowest WordPress version the plugin supports. */ $items[] = sprintf( __( 'WordPress %s or later', 'debug-log-router' ), $header['wp'] ); } if ( '' !== $header['php'] ) { /* translators: %s: lowest PHP version the plugin supports. */ $items[] = sprintf( __( 'PHP %s or later', 'debug-log-router' ), $header['php'] ); } $items[] = __( 'License: GPLv2 or later', 'debug-log-router' ); echo ''; } /** * Prints a link that leaves for another site, in a new browser tab. * * A new tab by the customer's choice (2026-10-08), so screen readers are * told so in words. noreferrer as well as noopener: the page address would * otherwise travel to the other site in the Referer header. * * @param string $url Where it leads. * @param string $text Link text, not yet escaped. * @param string $icon Sprite icon name; given, the link is drawn as a button. * @return void */ private static function external_link( $url, $text, $icon = '' ) { printf( '', esc_url( $url ), '' !== $icon ? ' class="button dlogr-button-icon"' : '' ); if ( '' !== $icon ) { self::icon( $icon ); } printf( '%s %s', esc_html( $text ), esc_html__( '(opens in a new tab)', 'debug-log-router' ) ); } /** * The Logs tab, and the reason the plugin gets installed at all. * * Reading is done by DLOGR_Reader; everything here is presentation. The * directory shown is the active one - reading an inactive one is a line of * interface away, and it waits in development/idei.md. * * Three cards since 2026-09-28 (заказчик): which file, the line about the * file that is open, and its records. The tab now goes through blocks() * like the other two, and a card that has nothing to say - no file open, a * file that is not there - is simply not drawn. Разбор - * development/docs/oformlenie.md, «Вкладка «Логи»». * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_logs( array $snapshot ) { $dir = $snapshot['log_dir']; $context = self::requested_context(); $outside = DLOGR_Reader::LEGACY === $context || DLOGR_Reader::SERVER === $context; $journal = self::JOURNAL === $context; $ready = '' !== $dir && $snapshot['dir_exists']; $loaded = 'B' !== $snapshot['state'] || $snapshot['loaded']; $blocks = array( function () use ( $dir, $context, $outside, $journal, $ready, $loaded ) { self::render_files( $dir, $context, $outside, $journal, $ready, $loaded ); }, ); if ( $journal ) { $blocks = array_merge( $blocks, self::journal_blocks( $snapshot ) ); } elseif ( $ready || $outside ) { $blocks = array_merge( $blocks, self::log_blocks( $dir, $context, $outside ) ); } self::blocks( $blocks ); } /** * The first card of the Logs tab: what there is to open. * * @param string $dir Active log directory name. * @param string $context The file being read. * @param bool $outside Whether that is a file outside the log directory: the old debug.log or the server's log. * @param bool $journal Whether that is the journal. * @param bool $ready Whether the directory is set and on disk. * @param bool $loaded False when logging is on and the block does not load the plugin. * @return void */ private static function render_files( $dir, $context, $outside, $journal, $ready, $loaded ) { echo '

'; self::icon( 'file-text' ); echo esc_html__( 'Log files', 'debug-log-router' ) . '

'; if ( ! $ready ) { if ( '' === $dir ) { printf( '

%s

', esc_html__( 'There is no log directory yet, so there is nothing to read. It is set up when logging is first switched on.', 'debug-log-router' ) ); } elseif ( ! $loaded ) { /* * The bare fact and no promise (dorabotki 27): with a block that * does not load the plugin, the repair never runs and the switch * does not help. The notice above this tab says what does. */ printf( '

%s

', esc_html( sprintf( /* translators: %s: path of a file or directory. */ __( '%s is not on disk any more.', 'debug-log-router' ), DLOGR_Log_Dir::label( $dir ) ) ) ); } else { printf( '

%s

', esc_html( sprintf( /* translators: %s: path of the log directory. */ __( 'The log directory %s is not on disk. While logging is on, the plugin brings it 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' ), DLOGR_Log_Dir::label( $dir ) ) ) ); } /* * The journal is reachable whether or not there is a log directory: * it lives in the database and often holds the very reason there is * no directory. When the row of files is not drawn, it needs a way in * of its own. */ self::render_journal_link( $journal ); } else { self::render_contexts( $dir, $context ); } /* * ⚠️ Drawn in every state, including the two above: no directory * chosen and directory gone from disk are exactly the states where the * old debug.log is the only thing left to read, so the way into it has * to be on screen there of all places. */ self::render_server_log_note( DLOGR_Reader::SERVER === $context ); self::render_old_log_note( DLOGR_Reader::LEGACY === $context ); /* * Last in the card, right above the file it captions - which is why it * is drawn only for the seven files of the directory. Over the journal * or a file outside it - the old debug.log, the server's log - it would * name a directory the table below does not come from (заказчик, * 2026-09-14). Разбор - * development/docs/chtenie-logov.md. * * "log directory", not "active directory" since 2026-09-28 (заказчик): * a site has one directory now, and "active" set it against others * that no longer exist. */ if ( $ready && ! $outside && ! $journal ) { printf( '

%s %s

', esc_html__( 'log directory:', 'debug-log-router' ), esc_html( DLOGR_Log_Dir::label( $dir ) ) ); } } /** * The cards of one log file: the line about it, then its records. * * A file that is not there or cannot be read gets one card saying so, and * no table. * * @param string $dir Active log directory name. * @param string $context The file being read. * @param bool $outside Whether that is a file outside the log directory: the old debug.log or the server's log. * @return callable[] Renderers for blocks(). */ private static function log_blocks( $dir, $context, $outside ) { $limit = self::requested_limit(); $top = self::requested_top(); if ( DLOGR_Reader::LEGACY === $context ) { $page = DLOGR_Reader::read_legacy( $top, $limit ); } elseif ( DLOGR_Reader::SERVER === $context ) { $page = DLOGR_Reader::read_server( $top, $limit ); } else { $page = DLOGR_Reader::read( $dir, $context, $top, $limit ); } if ( ! $page['exists'] ) { if ( $outside ) { $missing = sprintf( /* translators: %s: path of a file or directory. */ __( '%s is not on disk any more.', 'debug-log-router' ), self::log_file_name( $context ) ); } else { $missing = sprintf( /* translators: %s: log file name. */ __( '%s does not exist yet. A context file appears the first time something is logged in that context, so not having one is normal rather than missing.', 'debug-log-router' ), self::log_file_name( $context ) ); } return array( function () use ( $missing ) { printf( '

%s

', esc_html( $missing ) ); }, ); } if ( ! $page['readable'] ) { return array( function () use ( $context ) { printf( '

%s

', esc_html( sprintf( /* translators: %s: log file name. */ __( '%s cannot be read. Check the file permissions.', 'debug-log-router' ), self::log_file_name( $context ) ) ) ); }, ); } /* * The frozen top of the listing. Everything appended after the page was * first opened lies above it and stays out of the way, so pressing for * more never makes the rows already on screen shift or repeat. * * A file that got shorter loses that boundary along with the records it * counted, so the listing starts again from the end as it is now. */ $boundary = ( null === $top || $page['reset'] ) ? $page['size'] : $top; return array( function () use ( $context, $page ) { self::render_log_header( $context, $page ); }, function () use ( $context, $page, $boundary, $limit ) { if ( $page['reset'] ) { self::note( 'warning', '

' . esc_html__( 'The file got shorter since this page was opened, so the list starts from its end again.', 'debug-log-router' ) . '

' ); } self::render_log_table( $page['records'] ); self::render_log_footer( $context, $page, $boundary, $limit ); }, ); } /** * The seven contexts, always all seven. * * A missing file stays on the list with a note instead of disappearing: * cli.log never turns up on a site without WP-CLI, and that is a normal * state rather than a loss. Only files that exist are links. * * While the old debug.log is the one on screen none of the seven matches, * so none is bold and the whole row reads as the way back. That falls out * of the comparison below rather than being arranged for. * * @param string $dir Active log directory name. * @param string $context The context being read. * @return void */ private static function render_contexts( $dir, $context ) { /* * Ordered by how often they get opened, not the way boot.php checks * them. The set itself comes from the reader, so a context added there * still shows up here even if this order forgets to mention it. */ $order = array( 'front', 'admin', 'ajax', 'rest', 'cron', 'cli', 'xmlrpc' ); $order = array_values( array_unique( array_merge( $order, DLOGR_Reader::CONTEXTS ) ) ); echo '
'; foreach ( $order as $name ) { if ( ! DLOGR_Reader::is_context( $name ) ) { continue; } $path = DLOGR_Reader::path( $dir, $name ); if ( '' === $path || ! is_file( $path ) ) { self::render_file_pill( sprintf( /* translators: %s: context name, such as cron. */ __( '%s (no file)', 'debug-log-router' ), $name ), '' ); continue; } self::render_file_pill( $name, $name === $context ? '' : self::logs_url( array( 'file' => $name ) ), $name === $context ); } /* * ⚠️ The rule is not decoration. Everything to its left is a file in one * directory, telling the story of the site; to its right is a table in * the database, telling the story of the plugin. Two kinds of thing in * one row, and the rule is the only thing that says so. * * The grey and the room around it live in admin.css as dlogr-rule, shared * with the rule between the buttons in a directory row. */ echo ''; $open = self::JOURNAL === $context; self::render_file_pill( __( 'journal', 'debug-log-router' ), $open ? '' : self::logs_url( array( 'file' => self::JOURNAL ) ), $open ); echo '
'; } /** * The way into the journal when the row of contexts was not drawn. * * The same pill in the same row, so the line a file is picked from looks * the same in every state. * * @param bool $open Whether the journal is the one on screen. * @return void */ private static function render_journal_link( $open ) { echo '
'; self::render_file_pill( __( 'journal', 'debug-log-router' ), $open ? '' : self::logs_url( array( 'file' => self::JOURNAL ) ), $open ); echo '
'; } /** * One pill in the row of files. * * Three kinds, told apart by the arguments: the file on screen (no link, * aria-current, blue outline), a file that can be opened (a link), and a * file that does not exist (neither - it is not missing, just not written * yet, and a link to it would lead to a message saying so). * * @param string $label Translated text of the pill. * @param string $url Where it leads; empty for no link. * @param bool $current Whether it is the file on screen. * @return void */ private static function render_file_pill( $label, $url, $current = false ) { if ( $current ) { printf( '%s', esc_html( $label ) ); return; } if ( '' === $url ) { printf( '%s', esc_html( $label ) ); return; } printf( '%s', esc_url( $url ), esc_html( $label ) ); } /** * The old debug.log, and the way into it. * * Shown only while that file is on disk. The path is a link: the reader * takes the file it reads as an argument, so opening this one costs a line * of interface rather than a mechanism of its own. * * ⚠️ The sentence no longer claims the file stopped growing. That held only * while our block sits in wp-config.php and boot.php runs, and this note is * now printed in states where logging can be off - where the claim would * simply be false. What replaced it is conditional and true in every state. * * ⚠️ The file being reachable from outside is put as a possibility, not a * fact: whether it is served depends on the server. Core says "potentially * available" in the very check we annotate elsewhere, and claiming more * than core does would be claiming more than we know. * * @param bool $open Whether that file is the one on screen. * @return void */ private static function render_old_log_note( $open ) { if ( ! DLOGR_Reader::has_legacy() ) { return; } $name = basename( WP_CONTENT_DIR ) . '/debug.log'; if ( $open ) { printf( '

%s %s

', esc_html__( 'also readable:', 'debug-log-router' ), esc_html( $name ) ); } else { printf( '

%s %s

', esc_html__( 'also readable:', 'debug-log-router' ), esc_url( self::logs_url( array( 'file' => DLOGR_Reader::LEGACY ) ) ), esc_html( $name ) ); } printf( '

%s

', esc_html( sprintf( /* translators: %s: path of the old log file. */ __( '%s - the log file WordPress writes by default. While the plugin\'s logging is on, that file is not used. Keep in mind that it has no protection of its own and may be readable straight from a browser.', 'debug-log-router' ), $name ) ) ); } /** * The server's fixed PHP log, and the way into it. * * The same line as the old debug.log, for the same reason: the reader takes * the file as an argument, so another file outside our directory costs a * line of interface (заказчик, 2026-10-04). Shown only while the server * keeps error_log fixed and the plugin can read the file; why it cannot, * when it cannot, is said on the Status tab and in the notice. * * ⚠️ The warning about other sites is not decoration. A fixed log may be the * one file of a whole PHP pool, shared by several sites, and the plugin has * no reliable way to tell. Showing it with that said was the customer's * choice over not showing it at all. * * @param bool $open Whether that file is the one on screen. * @return void */ private static function render_server_log_note( $open ) { if ( ! DLOGR_Reader::has_server() ) { return; } $name = DLOGR_Reader::server_path(); if ( $open ) { printf( '

%s %s

', esc_html__( 'also readable:', 'debug-log-router' ), esc_html( $name ) ); } else { printf( '

%s %s

', esc_html__( 'also readable:', 'debug-log-router' ), esc_url( self::logs_url( array( 'file' => DLOGR_Reader::SERVER ) ) ), esc_html( $name ) ); } printf( '

%s

', esc_html( sprintf( /* translators: %s: path of the PHP log fixed by the server. */ __( '%s - the log file the server has fixed for PHP. While logging is on, entries land here, because the plugin cannot redirect them. The file belongs to the server and may hold entries of other sites that share the same PHP settings.', 'debug-log-router' ), $name ) ) ); } /** * The cards of the journal: what the plugin did, and who asked it to. * * The same two cards as a log file - the line about it, then its entries - * or one card saying why there is nothing to show. * * @param array $snapshot Result of snapshot(). * @return callable[] Renderers for blocks(). */ private static function journal_blocks( array $snapshot ) { if ( ! $snapshot['journal'] ) { return array( function () { printf( '

%s

', esc_html__( 'The journal table is not in the database, so there is nothing to show. The plugin keeps working without it, but it cannot record its own actions; the usual reason is a database user that is not allowed to create tables.', 'debug-log-router' ) ); }, ); } $limit = self::requested_limit(); $top = self::requested_top(); $rows = DLOGR_Journal::page( null === $top ? 0 : $top, $limit ); /* * The frozen top, the same idea the log files are paged with and for * the same reason: an id instead of a byte offset, so that entries * written between two clicks stay above the boundary and the rows on * screen neither shift nor repeat. */ $boundary = $top; if ( null === $boundary ) { $boundary = empty( $rows ) ? 0 : (int) $rows[0]->id; } return array( function () use ( $rows ) { self::render_journal_header( $rows ); }, function () use ( $rows, $boundary, $limit ) { self::render_journal_table( $rows ); self::render_journal_footer( $rows, $boundary, $limit ); }, ); } /** * How much the journal holds, when it last spoke, and the way out of it. * * @param array $rows Rows on screen, newest first. * @return void */ private static function render_journal_header( array $rows ) { $total = DLOGR_Journal::count(); $top = empty( $rows ) ? null : $rows[0]; $newest = ''; if ( $top ) { /* * The last event, not the first: a collapsed row keeps the time of * its first occurrence in logged_at and of its latest repeat in * last_repeat_at (dorabotki.md, пункт 18). Only the newest row * collapses - see DLOGR_Journal::add() - so this is the latest event * of all. */ $newest = self::journal_time( (int) $top->repeat_count > 1 && ! empty( $top->last_repeat_at ) ? $top->last_repeat_at : $top->logged_at ); } if ( '' === $newest ) { $text = sprintf( /* translators: %s: number of journal entries. */ _n( '%s entry in the plugin journal.', '%s entries in the plugin journal.', $total, 'debug-log-router' ), number_format_i18n( $total ) ); } else { $text = sprintf( /* translators: 1: number of journal entries, 2: timestamp of the newest one. */ __( '%1$s in the plugin journal, last entry %2$s', 'debug-log-router' ), sprintf( /* translators: %s: number of journal entries. */ _n( '%s entry', '%s entries', $total, 'debug-log-router' ), number_format_i18n( $total ) ), $newest ); } self::render_file_line( $text, DLOGR_Download::journal_url() ); } /** * The line about the file on screen, in a card of its own. * * One shape for a log file, the journal and the old debug.log: icon, what * it is and how much, and Download on the far side. The button is the same * green outlined one everywhere (заказчик, 2026-09-28) - a link, because * downloading changes nothing and the address carries nothing secret. * * @param string $text What to say about the file, translated. * @param string $url Download address. * @return void */ private static function render_file_line( $text, $url ) { echo '
'; self::icon( 'file-text' ); printf( '%s', esc_html( $text ) ); printf( '', esc_url( $url ) ); self::icon( 'download' ); echo esc_html__( 'Download', 'debug-log-router' ) . ''; echo '
'; } /** * Six columns: what happened, when, and at whose hand. * * ⚠️ The actor is stored as a name rather than a user id, so it outlives * the account (заказчик, 2026-09-11): an admin account created, used and * later deleted still leaves the journal able to say who pressed what. A * user id in its place would leave a number nobody answers to. * * ⚠️ Repeats get no column of their own. Collapsing is rare, so the column * would stand empty on nearly every row; the counter and the time of the * last repeat go under the timestamp instead, and a row without repeats * shows nothing extra. * * @param array $rows Rows as read, newest first. * @return void */ private static function render_journal_table( array $rows ) { $cells = array(); $number = 0; foreach ( $rows as $row ) { ++$number; $when = esc_html( self::journal_time( $row->logged_at ) ); $repeats = (int) $row->repeat_count; if ( $repeats > 1 ) { $when .= '
' . esc_html( sprintf( /* translators: 1: how many times the entry repeated, 2: timestamp of the last repeat. */ __( 'x%1$s, last %2$s', 'debug-log-router' ), number_format_i18n( $repeats ), self::journal_time( $row->last_repeat_at ) ) ) . ''; } $cells[] = array( esc_html( number_format_i18n( $number ) ), $when, self::chip( $row->level, 'error' === $row->level ? 'red' : '' ), '' . esc_html( $row->code ) . '', esc_html( '' === $row->actor ? '—' : $row->actor ), esc_html( self::encode_log_text( $row->message ) ), ); } self::render_table( array( array( 'dlogr-col-number', __( '#', 'debug-log-router' ) ), array( 'dlogr-col-time', __( 'Time', 'debug-log-router' ) ), array( 'dlogr-col-level', __( 'Level', 'debug-log-router' ) ), array( 'dlogr-col-code', __( 'Code', 'debug-log-router' ) ), array( 'dlogr-col-actor', __( 'Actor', 'debug-log-router' ) ), array( '', __( 'Message', 'debug-log-router' ) ), ), $cells, __( 'The journal is empty.', 'debug-log-router' ) ); } /** * How much is on screen, and the link that adds more below it. * * ⚠️ The two ceilings of five hundred in this plugin are different things, * and the wording has to keep them apart. On a log file it is the limit of * one page and the rest of the records are still in the file. Here it is * the limit of the store: older entries were dropped as new ones arrived, * and there is nowhere left to look for them. * * @param array $rows Rows on screen. * @param int $boundary Highest id the listing is counted from. * @param int $limit How many rows this page asked for. * @return void */ private static function render_journal_footer( array $rows, $boundary, $limit ) { $count = count( $rows ); if ( 0 === $count ) { return; } $text = sprintf( /* translators: %s: number of entries shown. */ _n( 'Showing %s entry, newest first.', 'Showing %s entries, newest first.', $count, 'debug-log-router' ), number_format_i18n( $count ) ); printf( ''; } /** * A journal timestamp in the shape the log files use. * * Stored as a MySQL datetime in UTC - current_time( 'mysql', true ) - so * the zone is named rather than guessed, and the two tables on this tab * read the same way. * * @param string|null $stamp Datetime as stored. * @return string */ private static function journal_time( $stamp ) { if ( empty( $stamp ) ) { return '—'; } $time = strtotime( $stamp . ' UTC' ); return false === $time ? (string) $stamp : gmdate( 'd-M-Y H:i:s', $time ) . ' UTC'; } /** * What the file on screen is called. * * @param string $context One of DLOGR_Reader::CONTEXTS, DLOGR_Reader::LEGACY or DLOGR_Reader::SERVER. * @return string */ private static function log_file_name( $context ) { if ( DLOGR_Reader::LEGACY === $context ) { return basename( WP_CONTENT_DIR ) . '/debug.log'; } // The full path: it is the server's file, and nothing about it is relative to the site. if ( DLOGR_Reader::SERVER === $context ) { return DLOGR_Reader::server_path(); } return $context . '.log'; } /** * File name, size and the time of the newest record. * * The time comes from the newest record read rather than from the file * mtime: that record sits right below this line, so the two cannot * disagree. * * @param string $context The context being read. * @param array $page Result of DLOGR_Reader::read(). * @return void */ private static function render_log_header( $context, array $page ) { $records = $page['records']; $newest = empty( $records ) ? null : $records[ count( $records ) - 1 ]; $stamp = ( null !== $newest && '' !== $newest['time'] ) ? $newest['time'] : ''; if ( '' === $stamp ) { $text = sprintf( /* translators: 1: log file name, 2: file size. */ __( '%1$s, %2$s', 'debug-log-router' ), self::log_file_name( $context ), size_format( $page['size'] ) ); } else { $text = sprintf( /* translators: 1: log file name, 2: file size, 3: timestamp of the newest entry. */ __( '%1$s, %2$s, last entry %3$s', 'debug-log-router' ), self::log_file_name( $context ), size_format( $page['size'] ), $stamp ); } /* * The button sits in this line rather than above the table: it belongs * to the file named right here, and there is exactly one of them on * screen. Shown for an empty file too - nought bytes is a state, not a * fault, and a button that disappears reads as a breakage. */ self::render_file_line( $text, DLOGR_Download::url( $context ) ); } /** * Prepares a log record or journal message for esc_html(), so the screen shows the file. * * Two faults of esc_html() on its own, both fixed here (dorabotki.md, * пункты 10 и 15, 2026-10-04): * * - broken UTF-8. esc_html() answers an empty string for it, so the record * vanished - the very record somebody came to read. The old guard, * wp_check_invalid_utf8( $text, true ), depends on the core version: from * 6.9 it replaces the bad bytes, on 5.8 it calls iconv() without //IGNORE, * gets false, raises a notice on every display and the cell stays empty. * ENT_SUBSTITUTE replaces them with U+FFFD on every version of PHP and * WordPress, without mbstring or iconv; * - entities already in the text. esc_html() does not encode them a second * time, so "&" in the file read as "&" on screen, "’" as a * curly quote. Encoding & < > here, the ready-made entities included, * hands esc_html() text it leaves alone, and the browser shows exactly * the characters of the file. * * ⚠️ The "#" after "&" is encoded too, as "#" (dorabotki.md, * пункт 15, приёмка 2026-10-05). On WordPress 5.8 esc_html() runs * wp_kses_normalize_entities(), which there restores "&amp;" before it * looks at numeric references - so "&#8217;" came out as "’" and * the browser drew a curly quote again. From some later version the order * is the other way round and the core says why in a comment. With "#" * there is no "&#" left for either order to act on. Checked on the * kses.php of 5.8 and 7.1, under PHP 7.4 and 8.5. * * ⚠️ Not escaping, and not to be used as such: quotes are left for * esc_html(), which stays on the outside of every call. That is what phpcs, * Plugin Check and the WordPress.org review recognise as escaping; a function * of our own in its place would be flagged by Plugin Check, which does not * read our ruleset. * * @param string $text Text as read from the file or the journal. * @return string Valid UTF-8, with & < > as entities. */ private static function encode_log_text( $text ) { return str_replace( '&#', '&#', htmlspecialchars( (string) $text, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8', true ) ); } /** * A record's text, each of its lines in an element of its own. * * For the hanging indent (заказчик, 2026-10-04, dorabotki.md, пункт 6): a * line of the file starts at the left edge, whatever the browser wraps * onto the next screen line is pushed in by three characters. That tells a * line of the log from a long line folded by the browser - in a stack trace * the #N frames stay at the edge and their tails move in. text-indent works * on a block, so every line has to be one. * * ⚠️ inline-block at full width, with the real line breaks left between the * elements, rather than display: block with the breaks taken out. Copying * is the reason: a selection then holds exactly the characters of the file. * Blocks make the browser add a break of its own between them, and * consecutive ones collapse into one - an empty line inside a record, which * print_r() output has, would be gone from what is copied. * * A carriage return before the break is dropped: a file written on Windows * would otherwise carry it into every element. * * @param string $text Text of the record as read. * @return string HTML, escaped line by line. */ private static function record_lines( $text ) { $lines = array(); foreach ( explode( "\n", (string) $text ) as $line ) { $lines[] = '' . esc_html( self::encode_log_text( rtrim( $line, "\r" ) ) ) . ''; } return implode( "\n", $lines ); } /** * The four columns. * * Newest first, oldest last: the page is opened with the question "what * broke just now", so the answer belongs in the first row. The reader hands * records over in file order, which is why they are turned around here. * * The number counts down from the newest record in this listing, and it * describes the listing rather than the record: the file keeps growing, so * what is number one now is number nine on the next visit. A running number * from the start of the file would be stable and would cost a walk through * the whole file on every page load - the one thing this reader is built to * avoid. * * The text goes out through record_lines(): a line of the file per element * for the hanging indent, each through encode_log_text() and esc_html() - * broken UTF-8 and ready-made entities are dealt with there. * * The record cell is capped in height and scrolls inside itself. A nine * line stack trace would otherwise stretch the table, and cutting it with a * show-more control would need JavaScript. * * @param array $records Records as read, oldest first. * @return void */ private static function render_log_table( array $records ) { $rows = array(); $number = 0; foreach ( array_reverse( $records ) as $record ) { ++$number; $rows[] = array( esc_html( number_format_i18n( $number ) ), esc_html( '' === $record['time'] ? '—' : $record['time'] ), self::chip( '' === $record['type'] ? '—' : $record['type'], self::type_colour( $record['type'] ) ), '
' . self::record_lines( $record['text'] ) . '
', ); } self::render_table( array( array( 'dlogr-col-number', __( '#', 'debug-log-router' ) ), array( 'dlogr-col-time', __( 'Time', 'debug-log-router' ) ), array( 'dlogr-col-type', __( 'Type', 'debug-log-router' ) ), array( '', __( 'Entry', 'debug-log-router' ) ), ), $rows, __( 'No entries found.', 'debug-log-router' ) ); } /** * Which colour the type of a log record is labelled in. * * Matched on what PHP writes after "PHP ", lowercased. Red is everything * that ends the request - a fatal error in any of its spellings and a parse * error, which as grey would sit next to Notice (заказчик, 2026-09-28). * Deprecated is purple rather than the blue of the sketch, because blue on * this tab is what can be clicked. Everything else, the dash of a direct * error_log() call included, stays grey. * * @param string $type Type as the reader split it off; empty for none. * @return string Colour name for chip(); empty for grey. */ private static function type_colour( $type ) { $type = strtolower( trim( preg_replace( '/^PHP\s+/', '', (string) $type ) ) ); if ( false !== strpos( $type, 'fatal' ) || 'parse error' === $type ) { return 'red'; } if ( 'warning' === $type ) { return 'orange'; } if ( 'deprecated' === $type ) { return 'purple'; } return ''; } /** * A label: one short value, tinted. * * @param string $text Text of the label, not yet escaped. * @param string $colour red, orange, purple, blue, or empty for grey. * @return string Escaped HTML. */ private static function chip( $text, $colour = '' ) { $class = 'dlogr-chip'; if ( in_array( $colour, array( 'red', 'orange', 'purple', 'blue' ), true ) ) { $class .= ' dlogr-chip-' . $colour; } return sprintf( '%s', esc_attr( $class ), esc_html( $text ) ); } /** * The table both views draw with, and it knows about neither. * * ⚠️ Shared markup, not shared logic. docs/zhurnal.md put it this way when * the journal was still unwritten: the backwards parser has nothing to * reuse against a database, but the table on screen has. What is shared is * therefore the frame - the classes, the head, the empty line - while each * view builds its own cells. * * ⚠️ Rejected: one function with an "is this the journal?" test inside. * That is two paths of execution in one body, and they part company at the * first edit to either of them. * * @param array $headings Pairs of column class (empty for none) and label. * @param array $rows Rows of cells, each cell already escaped by its view. * @param string $nothing What to say when there are no rows at all. * @return void */ private static function render_table( array $headings, array $rows, $nothing ) { echo ''; foreach ( $headings as $heading ) { if ( '' === $heading[0] ) { printf( '', esc_html( $heading[1] ) ); } else { printf( '', esc_attr( $heading[0] ), esc_html( $heading[1] ) ); } } echo ''; if ( empty( $rows ) ) { printf( '', (int) count( $headings ), esc_html( $nothing ) ); } foreach ( $rows as $row ) { echo ''; foreach ( $row as $cell ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Cells arrive escaped from the view that built them; this frame is shared precisely because it knows nothing about what is in them. echo ''; } echo ''; } echo '
%s%s
%s
' . $cell . '
'; } /** * How much is on screen, and the link that adds the next helping below it. * * ⚠️ The link asks for more records from the same frozen top rather than * for the next fifty after a cursor, and that is what makes the helpings * pile up instead of replacing one another - with no JavaScript and no * state kept between requests. It costs rereading the bytes already shown, * bounded by the ceiling and by the byte fuse. * * ⚠️ There is one case where asking for more cannot help: the fuse stopped * the walk before the requested count was filled, so a larger request would * read the same bytes and end in the same place. The page says so instead * of offering a link that does nothing. * * @param string $context The context being read. * @param array $page Result of DLOGR_Reader::read(). * @param int $boundary Byte offset the listing is counted from. * @param int $limit How many records this page asked for. * @return void */ private static function render_log_footer( $context, array $page, $boundary, $limit ) { $count = count( $page['records'] ); if ( 0 === $count ) { return; } $text = sprintf( /* translators: %s: number of entries shown. */ _n( 'Showing %s entry, newest first.', 'Showing %s entries, newest first.', $count, 'debug-log-router' ), number_format_i18n( $count ) ); printf( ''; } /** * An address on this tab. * * @param array $args Extra query arguments. * @return string */ private static function logs_url( array $args ) { return add_query_arg( array_merge( array( 'page' => self::PAGE, 'tab' => 'logs', ), $args ), admin_url( 'tools.php' ) ); } /** * Which file the address asks for. * * ⚠️ The old debug.log answers to its own name, and only while it is on * disk. A stale link to a file since deleted therefore lands on the front * log rather than on a page explaining a missing context file in words that * would not fit it. * * @return string One of DLOGR_Reader::CONTEXTS, DLOGR_Reader::LEGACY or DLOGR_Reader::SERVER. */ private static function requested_context() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only navigation: the value only picks which file to read, and it has to be one of our seven or the one name kept beside them. $file = isset( $_GET['file'] ) ? sanitize_key( wp_unslash( $_GET['file'] ) ) : ''; if ( self::JOURNAL === $file ) { return self::JOURNAL; } if ( DLOGR_Reader::LEGACY === $file && DLOGR_Reader::has_legacy() ) { return DLOGR_Reader::LEGACY; } // The same rule: only while the server keeps its log fixed and readable. if ( DLOGR_Reader::SERVER === $file && DLOGR_Reader::has_server() ) { return DLOGR_Reader::SERVER; } return DLOGR_Reader::is_context( $file ) ? $file : 'front'; } /** * The frozen top of the listing, if the address carries one. * * A forged value costs nothing: the reader weighs it against the size of * the file and starts from the end when it does not fit. * * @return int|null */ private static function requested_top() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only navigation, and the value is weighed against the file size before it is used. $top = isset( $_GET['top'] ) ? absint( wp_unslash( $_GET['top'] ) ) : 0; return $top > 0 ? $top : null; } /** * How many records this page was asked for. * * Held between one helping and the ceiling, so a forged number can neither * ask for less than a helping nor for more than the page is willing to * draw. * * @return int */ private static function requested_limit() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only navigation: the value only says how many records to draw, and it is held inside its own bounds. $limit = isset( $_GET['limit'] ) ? absint( wp_unslash( $_GET['limit'] ) ) : 0; if ( $limit < DLOGR_Reader::PAGE_SIZE ) { return DLOGR_Reader::PAGE_SIZE; } return min( $limit, DLOGR_Reader::CEILING ); } /** * Draws the log directory block, first on the Settings tab. * * Where to write is a setting, so it lives with the settings. One directory * per site (заказчик, 2026-09-28): the current one alone in its table, and * below it, under a heading of their own, whatever earlier installs left * behind. There is no creating a second directory and no switching to * another one; a leftover is taken over only when there is no directory * at all, by the enable button - see DLOGR_Log_Dir::ensure(). * * Before logging is first switched on, the first row is the leftover that * switching on will take over. It is the answer DLOGR_Log_Dir::current() * gives, the same one ensure() acts on, so the page does not guess. * * ⚠️ Deleting is refused for the current directory, because it would not * stick. Renaming is offered for every one, moves nothing, and hands out a * generated name rather than taking one. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_directories( array $snapshot ) { $current = $snapshot['current']; $leftovers = $snapshot['leftovers']; echo '

'; self::icon( 'folder' ); echo esc_html__( 'Log directory', 'debug-log-router' ) . '

'; echo '

' . esc_html__( 'The directory lives in the uploads folder, inside a folder of this plugin, and is named with eight random characters. That unguessable name is the main protection; the .htaccess file inside it is the second layer, and it has no effect on nginx.', 'debug-log-router' ) . '

'; $can_zip = DLOGR_Download::can_zip(); if ( '' === $current ) { echo '

' . esc_html__( 'There is no log directory yet. It is created when logging is first switched on.', 'debug-log-router' ) . '

'; } else { $note = ''; if ( '' === $snapshot['log_dir'] ) { $note = __( 'Left by an earlier install. Logging will go here once it is switched on.', 'debug-log-router' ); } elseif ( ! $snapshot['dir_exists'] ) { $note = __( 'Missing from disk. While logging is on, it comes back under the same name by itself once the uploads folder can be written to; switching logging off and on again on this tab does it at once.', 'debug-log-router' ); } self::render_directory_table( array( $current ), false, $note, $can_zip ); } /* * Said once rather than in every tooltip: a missing extension is a fact * about the site, not about a row. In this card because this card is * always drawn, while the one with leftovers is not. */ if ( ! $can_zip && ( '' !== $current || ! empty( $leftovers ) ) ) { printf( '

%s

', esc_html__( 'Downloading a whole directory needs the PHP zip extension, which is not installed here. The files themselves can still be downloaded one at a time from the Logs tab.', 'debug-log-router' ) ); } } /** * Draws what earlier installs left behind, in a card of its own. * * A card of its own since 2026-09-28 (заказчик), where it used to be a * subheading under the current directory. Drawn only when there is * something left. The question before deleting a leftover that still holds * logs is asked here, because only leftovers have a Delete button. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_leftovers( array $snapshot ) { $leftovers = $snapshot['leftovers']; if ( empty( $leftovers ) ) { return; } echo '

'; self::icon( 'history' ); echo esc_html__( 'Left by earlier installs', 'debug-log-router' ) . '

'; echo '

' . esc_html__( 'Nothing is written to these any more. Download what you need and delete them, or tick them below to go together with the plugin.', 'debug-log-router' ) . '

'; self::render_delete_confirm( $snapshot['log_dir'] ); self::render_directory_table( $leftovers, true, '', DLOGR_Download::can_zip() ); } /** * One table of log directories with their buttons. * * @param array $names Directory names, without the path. * @param bool $deletable Whether rows get a Delete button: leftovers do, * the current directory never. * @param string $note Translated line under the name, or empty. * @param bool $can_zip Whether directories can be packed for download. * @return void */ private static function render_directory_table( array $names, $deletable, $note, $can_zip ) { echo ''; echo ''; echo ''; echo ''; foreach ( $names as $name ) { $on_disk = is_dir( DLOGR_Log_Dir::path( $name ) ); $content = $on_disk ? DLOGR_Log_Dir::content_state( $name ) : 'empty'; $row_note = $note; /* * A leftover says what is wrong with it in words, not only in the * tooltip of a disabled button (dorabotki.md, пункт 22). Unreadable: * nothing can be done with it from here, Delete included. Not * writable, before logging is first switched on: why switching on * passes it over (пункт 21) - afterwards nothing is written to * leftovers anyway, and the line would only alarm. */ if ( $deletable && 'unreadable' === $content ) { $row_note = __( 'PHP cannot read this directory, so what it holds is unknown. It can be neither downloaded nor deleted until its permissions are fixed.', 'debug-log-router' ); } elseif ( $deletable && $on_disk && '' === DLOGR_Log_Dir::name() && ! DLOGR_Log_Dir::looks_writable( $name ) ) { $row_note = __( 'PHP does not seem to be allowed to write to this directory, so switching logging on passes it over.', 'debug-log-router' ); } /* * Monospace on a light fill, not in a box that looks like a text * field: the name cannot be typed over, Rename hands out a new one * (заказчик, 2026-09-28). */ echo ''; continue; } echo '
'; echo '
'; wp_nonce_field( self::NONCE ); echo ''; printf( '', esc_attr( $name ) ); // A button element rather than submit_button(): an input cannot hold an icon. echo ''; echo ''; /* * Left of the rule, with the harmless half: downloading changes * nothing and needs no undoing. * * Green of the plugin's palette (admin.css), drawn as a border * and text rather than a fill, because the Delete button beside * it is contoured too. One filled button among contoured ones * would read as a different kind of thing. * * ⚠️ A form rather than a link, unlike the button on the Logs * tab. That one needed no directory name; this one packs the * directory it is told to, and a name in the address ends up in * browser history and in the Referer header. */ if ( $can_zip && 'content' === $content ) { printf( '
', esc_url( admin_url( 'admin-post.php' ) ) ); wp_nonce_field( DLOGR_Download::ACTION_DIR ); printf( '', esc_attr( DLOGR_Download::ACTION_DIR ) ); printf( '', esc_attr( $name ) ); echo ''; echo ''; } else { if ( ! $can_zip ) { $why = __( 'Packing a directory needs the PHP zip extension, and it is not installed here.', 'debug-log-router' ); } elseif ( 'unreadable' === $content ) { $why = __( 'This directory cannot be read, so there is nothing to pack.', 'debug-log-router' ); } else { $why = __( 'This directory holds nothing but the files the plugin puts there itself.', 'debug-log-router' ); } printf( ''; } if ( $deletable ) { /* * A rule with room on either side, drawn only when there is * something to separate: everything to its left can be undone * by clicking again, and the button to its right cannot. * * The rule itself is dlogr-rule in admin.css, the same one the * Logs tab draws between the files and the journal; inside a * row of buttons it stretches to the height of the row. */ echo ''; /* * Only for leftovers: the current directory gets no delete * button at all, because deleting it would not stick - the * loader reports it missing and ensure() recreates it under the * same name on the next request. * * The red of the plugin's palette (admin.css); until 2026-09-28 * it was core's delete-link red. Core ships no bordered red * button, so the border is ours: dlogr-button-delete in * admin.css, shared with the button that confirms a deletion. * * ⚠️ Disabled on a directory PHP cannot read (dorabotki.md, * пункт 22): delete_tree() cannot list it and gives up on the * spot, so the button could only ask for confirmation and then * fail. Drawn the way a disabled Download is, without the red. */ if ( 'unreadable' === $content ) { printf( ''; } else { echo '
'; wp_nonce_field( self::NONCE ); echo ''; printf( '', esc_attr( $name ) ); echo ''; echo ''; } } echo '
'; } echo '
' . esc_html__( 'Directory', 'debug-log-router' ) . '' . esc_html__( 'Actions', 'debug-log-router' ) . '
'; printf( '%s', esc_html( DLOGR_Log_Dir::label( $name ) ) ); if ( '' !== $row_note ) { printf( '

%s

', esc_html( $row_note ) ); } echo '
'; if ( ! $on_disk ) { echo '
'; } /** * Shows the outcome of the last operation, if the URL carries one. * * @return void */ private static function render_result() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: the value only picks a message, and the operation itself was nonce-checked before the redirect. $code = isset( $_GET['dlogr_result'] ) ? sanitize_key( wp_unslash( $_GET['dlogr_result'] ) ) : ''; if ( '' === $code ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only, same as the code above it: it only fills a name into the message. $name = isset( $_GET['dlogr_name'] ) ? sanitize_text_field( wp_unslash( $_GET['dlogr_name'] ) ) : ''; $notice = self::result_notice( $code, $name ); if ( empty( $notice ) ) { return; } /* * The page's own box since 2026-09-28 (заказчик), printed above the * page title. Not a .notice, so core's script leaves it where it is: * that script lifts every .notice without .inline to just under the * title, which would put the outcome below the notices of other * plugins. */ self::note( $notice['type'], '

' . esc_html( $notice['text'] ) . '

' ); } /** * Warns that the journal table is missing. * * Shown every time rather than once: this is a lasting condition, and there * is nothing the user can dismiss it with until the permission changes. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_journal_warning( array $snapshot ) { if ( $snapshot['journal'] ) { return; } self::note( 'warning', sprintf( '

%s %s

', esc_html__( 'Debug Log Router could not create its journal table.', 'debug-log-router' ), esc_html__( 'The plugin keeps working, but it cannot record its own actions and errors - if logging stops later, there will be nothing explaining why. This usually means the database user is not allowed to create tables; your host can change that.', 'debug-log-router' ) ) ); } /** * Draws the state block. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_state( array $snapshot ) { $config = $snapshot['config']; $server = DLOGR_Reader::server_log(); $headings = array( 'A' => __( 'Not set up yet', 'debug-log-router' ), 'B' => __( 'Logging is on', 'debug-log-router' ), 'C' => __( 'Set up, logging is off', 'debug-log-router' ), 'D' => __( 'Switched on, but not built into wp-config.php', 'debug-log-router' ), ); /* * The icon says the state before the words do (заказчик, 2026-09-28): * a green tick for logging on, grey for the two states where nothing is * wrong but nothing is logged either, red for D - switched on and * writing nothing, which is a fault rather than a choice. */ $icons = array( 'A' => array( 'circle-minus', 'grey' ), 'B' => array( 'circle-check', 'green' ), 'C' => array( 'circle-minus', 'grey' ), 'D' => array( 'circle-x', 'red' ), ); /* * Logging on, but into the server's log rather than ours (заказчик, * 2026-10-04): orange, not green and not red. It works - the entries * are written and WordPress debugging is on - only not where this * plugin promises, and not split by request type. * * Block in place, loader never ran (dorabotki 26): red, like D, because * nothing reaches the log directory. Still B rather than a fifth state - * the matrix is about the config and the switch, and both say B. Ahead * of the pinned log: with no loader that one is beside the point. */ $not_loaded = 'B' === $snapshot['state'] && ! $snapshot['loaded']; if ( $not_loaded ) { $headings['B'] = __( 'Switched on, but wp-config.php does not load the plugin', 'debug-log-router' ); $icons['B'] = array( 'circle-x', 'red' ); } elseif ( 'B' === $snapshot['state'] && $server['pinned'] ) { $headings['B'] = __( 'Logging is on, into the server\'s log', 'debug-log-router' ); $icons['B'] = array( 'triangle-alert', 'orange' ); } $icon = $icons[ $snapshot['state'] ]; printf( '

', esc_attr( $icon[1] ) ); self::icon( $icon[0] ); echo esc_html( $headings[ $snapshot['state'] ] ) . '

'; if ( 'D' === $snapshot['state'] ) { self::note( 'error', '

' . esc_html__( 'The setting says logging is on, but the block is not in wp-config.php, so nothing is being written. Add the block to make it work.', 'debug-log-router' ) . '

' ); } /* * Said before switching on as well, in every state that leads there: * the button in A and D, the switch on the Settings tab from C. Not a * blocker - logging is allowed - but nobody should find out from an * empty Logs tab afterwards. */ if ( $not_loaded ) { self::note( 'error', '

' . esc_html( self::not_loaded_text() ) . '

' ); self::render_update_block( $snapshot ); } elseif ( $server['pinned'] ) { self::note( 'warning', '

' . esc_html( 'B' === $snapshot['state'] ? self::pinned_text( $server ) : self::pinned_before_text( $server ) ) . '

' ); } echo ''; self::row( __( 'wp-config.php', 'debug-log-router' ), false === $config['path'] ? __( 'not found', 'debug-log-router' ) : $config['path'] ); self::row( __( 'Block in wp-config.php', 'debug-log-router' ), empty( $config['blocks'] ) ? __( 'not present', 'debug-log-router' ) : __( 'present', 'debug-log-router' ) ); self::row( __( 'Logging', 'debug-log-router' ), $snapshot['enabled'] ? __( 'on', 'debug-log-router' ) : __( 'off', 'debug-log-router' ) ); /* * Shown with the directory it sits in, so the name reads as a path * somebody can find over FTP rather than as an eight character riddle. * Taken from DLOGR_Log_Dir::label() rather than written out: WordPress * lets the uploads folder be moved and renamed, and a hard coded path * would then point at nothing. */ $dir_text = __( 'created when logging is switched on', 'debug-log-router' ); if ( '' === $snapshot['log_dir'] && '' !== $snapshot['current'] ) { $dir_text = DLOGR_Log_Dir::label( $snapshot['current'] ) . ' — ' . __( 'left by an earlier install, taken over when logging is switched on', 'debug-log-router' ); } elseif ( '' !== $snapshot['log_dir'] ) { $dir_text = DLOGR_Log_Dir::label( $snapshot['log_dir'] ); if ( ! $snapshot['dir_exists'] ) { $dir_text .= ' — ' . __( 'missing from disk', 'debug-log-router' ); } } self::row( __( 'Log directory', 'debug-log-router' ), $dir_text ); self::row( __( 'Loader data file', 'debug-log-router' ), $snapshot['boot_ok'] ? __( 'matches the settings', 'debug-log-router' ) : __( 'missing or out of date', 'debug-log-router' ) ); if ( ! empty( $config['defines'] ) ) { $lines = array(); $groups = array( 'before' => array(), 'after' => array(), ); foreach ( $config['defines'] as $define ) { $text = sprintf( /* translators: 1: constant name, 2: line number. */ __( '%1$s on line %2$d', 'debug-log-router' ), $define['name'], $define['line_start'] ); $lines[] = $text; $groups[ $define['position'] ][] = $text; } /* * ⚠️ The caption depends on whether our block is in the file yet. * Until it is, this is a promise and the wording is literally true: * the button will comment these out. Once the block is in, the same * wording describes an intention nobody holds, while what it is * actually looking at is a breakage - or noise in our own log. * * Told apart by position, which read() has worked out already. */ if ( empty( $config['blocks'] ) ) { self::row( __( 'Constants to be commented out', 'debug-log-router' ), implode( ', ', $lines ) ); } else { if ( ! empty( $groups['before'] ) ) { self::row( __( 'Declared above our block', 'debug-log-router' ), implode( ', ', $groups['before'] ) ); } if ( ! empty( $groups['after'] ) ) { self::row( __( 'Declared below our block', 'debug-log-router' ), sprintf( /* translators: %s: comma separated constant names with line numbers. */ __( '%s - ours wins, but PHP records "Constant already defined" in the log on every request', 'debug-log-router' ), implode( ', ', $groups['after'] ) ) ); } } } echo '
'; } /** * Where entries go when the server has fixed PHP's log. * * The path when PHP knows it. When it does not - an empty value or syslog - * a description in its place, so every sentence built on this one has the * same shape either way. * * @param array $server Result of DLOGR_Reader::server_log(). * @return string */ private static function pinned_where( array $server ) { if ( '' !== $server['path'] ) { return $server['path']; } return __( 'the error log of the web server or the system log, whose location PHP does not report', 'debug-log-router' ); } /** * Why nothing is logged, when the block is in wp-config.php but never loads the plugin. * * One method because the Status tab and the notice on every other screen * say the same sentence, so the two cannot drift apart. * * @return string */ private static function not_loaded_text() { return __( 'Logging is switched on and the block is in wp-config.php, but it does not load this plugin, so nothing is being written to its log files. Most likely a path in the block no longer leads where it should, for example after the site was moved or the uploads folder changed. The button on the Status tab writes the block again with the current paths.', 'debug-log-router' ); } /** * What to know before switching logging on, when the server has fixed PHP's log. * * @param array $server Result of DLOGR_Reader::server_log(). * @return string */ private static function pinned_before_text( array $server ) { return sprintf( /* translators: %s: path of the PHP log fixed by the server, or a description of where it is. */ __( 'The server has fixed where PHP writes its log: %s. Logging can still be switched on, and the plugin still manages WordPress debugging, but entries will not reach the log directory and will not be split by request type.', 'debug-log-router' ), self::pinned_where( $server ) ); } /** * Where entries are going while logging is on, when the server has fixed PHP's log. * * Three cases, because what somebody can do next differs: read the file on * the Logs tab, read it elsewhere for a reason given, or look for it in the * hosting. The Status tab and the notice on every other screen say the * same sentence, so the two cannot drift apart. * * @param array $server Result of DLOGR_Reader::server_log(). * @return string */ private static function pinned_text( array $server ) { if ( $server['readable'] ) { return sprintf( /* translators: %s: path of the PHP log fixed by the server. */ __( 'The server does not let PHP send its log anywhere else, so entries go to %s instead of the log directory, all in one file. The plugin can still read that file: it is on the Logs tab.', 'debug-log-router' ), $server['path'] ); } if ( '' === $server['path'] ) { return sprintf( /* translators: %s: description of where the PHP log is. */ __( 'The server does not let PHP send its log anywhere else, so entries go to %s. The plugin cannot read it.', 'debug-log-router' ), self::pinned_where( $server ) ); } $reasons = array( 'basedir' => __( 'it lies outside the directories PHP may open on this site', 'debug-log-router' ), 'denied' => __( 'the web server has no permission to read it', 'debug-log-router' ), 'missing' => __( 'it is not on disk; PHP creates it with the first entry if it is allowed to write there', 'debug-log-router' ), ); return sprintf( /* translators: 1: path of the PHP log fixed by the server, 2: why the plugin cannot read it. */ __( 'The server does not let PHP send its log anywhere else, so entries go to %1$s instead of the log directory. The plugin cannot read that file: %2$s.', 'debug-log-router' ), $server['path'], isset( $reasons[ $server['reason'] ] ) ? $reasons[ $server['reason'] ] : $reasons['denied'] ); } /** * Reports lines the plugin commented out and could not give back. * * The one outcome of a removal that leaves something of ours behind, so it * is said out loud rather than left to be discovered in the file. The file * explains itself too - the reason is written above each line - but the * exact line numbers belong here, where they are read fresh on every visit * instead of being frozen into a comment that goes stale on the next edit. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_kept( array $snapshot ) { $kept = $snapshot['config']['kept']; if ( empty( $kept ) ) { return; } $lines = array(); foreach ( $kept as $line ) { $lines[] = (int) $line['line']; } self::note( 'warning', '

' . esc_html( sprintf( /* translators: 1: number of lines, 2: comma separated line numbers. */ _n( '%1$d line in wp-config.php is still commented out by this plugin (line %2$s): giving it back would declare the same constant twice, which the file declares elsewhere as well. The reason is written above the line; keep whichever declaration is right, delete the other, then drop the prefix.', '%1$d lines in wp-config.php are still commented out by this plugin (lines %2$s): giving them back would declare the same constants twice, which the file declares elsewhere as well. The reason is written above each line; keep whichever declaration is right, delete the other, then drop the prefix.', count( $lines ), 'debug-log-router' ), count( $lines ), implode( ', ', $lines ) ) ) . '

' ); } /** * One row of the state table. * * @param string $label Row label. * @param string $value Row value. * @return void */ private static function row( $label, $value ) { printf( '%s%s', esc_html( $label ), esc_html( $value ) ); } /** * Names the constants somebody declared before the loader could. * * @param string $which Loader constant holding the list. * @return array Constant names, empty when there are none. */ private static function taken_constants( $which ) { if ( ! defined( $which ) ) { return array(); } return array_values( array_filter( explode( ',', (string) constant( $which ) ) ) ); } /** * Writes a constant value the way a person reads it. * * @param mixed $value Value in force. * @return string */ private static function constant_text( $value ) { if ( null === $value ) { return __( 'null - leave the server setting alone', 'debug-log-router' ); } if ( is_string( $value ) ) { return $value; } return $value ? 'true' : 'false'; } /** * What this plugin would have set, had its line run. * * ⚠️ Described rather than computed for WP_DEBUG_LOG. The exact value is * a path ending in a file named after the request context, and working it * out here would be a second copy of the rule that lives in the loader. The * directory is the part that answers the question anyway. * * @param string $name Constant name. * @param array $snapshot Result of snapshot(). * @return string */ private static function intended_text( $name, array $snapshot ) { $enabled = (bool) DLOGR_Settings::get( 'logging_enabled' ); if ( 'WP_DEBUG' === $name ) { return $enabled ? 'true' : 'false'; } if ( 'WP_DEBUG_DISPLAY' === $name ) { $display = (string) DLOGR_Settings::get( 'debug_display' ); if ( 'server' === $display ) { return self::constant_text( null ); } return 'on' === $display ? 'true' : 'false'; } if ( ! $enabled || '' === $snapshot['log_dir'] ) { return 'false'; } return sprintf( /* translators: %s: log directory path. */ __( 'a file in %s', 'debug-log-router' ), DLOGR_Log_Dir::label( $snapshot['log_dir'] ) ); } /** * Where the winning declaration came from, as far as anything can tell. * * PHP records no origin for a constant: neither get_defined_constants() nor * reflection knows one. The config answers when the line is in it. When it * is not, saying so is the answer, because a conflict with nothing in the * file otherwise reads as this plugin malfunctioning. * * @param string $name Constant name. * @param array $snapshot Result of snapshot(). * @return string */ private static function constant_origin( $name, array $snapshot ) { $config = $snapshot['config']; if ( false === $config['path'] || ! $config['readable'] || '' !== $config['parse_error'] ) { return __( 'unknown: wp-config.php could not be read', 'debug-log-router' ); } foreach ( $config['defines'] as $define ) { if ( $define['name'] === $name && 'before' === $define['position'] ) { return sprintf( /* translators: %d: line number. */ __( 'wp-config.php, line %d', 'debug-log-router' ), (int) $define['line_start'] ); } } return __( 'not in wp-config.php - look for auto_prepend_file', 'debug-log-router' ); } /** * What the person loses by this constant being out of the hands of the plugin. * * @param string $name Constant name. * @param mixed $current Value in force. * @return string */ private static function constant_effect( $name, $current ) { if ( 'WP_DEBUG_DISPLAY' === $name ) { return __( 'The display mode chosen in the plugin does not apply.', 'debug-log-router' ); } if ( 'WP_DEBUG_LOG' === $name ) { if ( is_string( $current ) ) { return __( 'Core writes the log to that file instead of the log directory of this plugin.', 'debug-log-router' ); } return __( 'Core writes no log file of its own. Only what this plugin caught with its own early ini_set reaches the log.', 'debug-log-router' ); } if ( $current ) { return __( 'Switching logging off in the plugin has no effect: the constant stays on.', 'debug-log-router' ); } return __( 'Writing continues, but core lowers the error mask and E_NOTICE, E_DEPRECATED and E_STRICT never reach the log. The log looks alive with part of it missing.', 'debug-log-router' ); } /** * Builds the rows for one group of the constants block. * * @param array $names Constant names. * @param array $snapshot Result of snapshot(). * @param bool $latent Whether the value in force happens to match. * @return array Rows of escaped cells. */ private static function constant_rows( array $names, array $snapshot, $latent ) { $rows = array(); foreach ( $names as $name ) { $current = defined( $name ) ? constant( $name ) : null; $effect = $latent ? __( 'The plugin setting does not control this constant. Changing the setting changes nothing.', 'debug-log-router' ) : self::constant_effect( $name, $current ); $rows[] = array( '' . esc_html( $name ) . '
' . esc_html( self::constant_origin( $name, $snapshot ) ) . '', esc_html( self::constant_text( $current ) ), esc_html( self::intended_text( $name, $snapshot ) ), esc_html( $effect ), ); } return $rows; } /** * Draws the constants block: two groups, one subject. * * ⚠️ Two groups rather than two blocks (заказчик, 2026-09-12). What * happened is the same in both, our define() never ran, and explaining that * twice would be noise. What differs is urgency and consequence, which is a * difference between groups. Same shape as the removal block from * 2026-09-11, where two groups exist because the lines have different fates. * * ⚠️ The second group must not be softened into a problem that might * arrive later. The line this plugin writes did not run today and the * setting is already not in charge; only the result is invisible. * Разбор - docs/admin.md. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_constants( array $snapshot ) { $conflict = self::taken_constants( 'DLOGR_BOOT_CONFLICT' ); $latent = self::taken_constants( 'DLOGR_BOOT_LATENT_CONFLICT' ); if ( empty( $conflict ) && empty( $latent ) ) { return; } $headings = array( array( 'dlogr-col-constant', __( 'Constant', 'debug-log-router' ) ), array( 'dlogr-col-in-force', __( 'In force', 'debug-log-router' ) ), array( 'dlogr-col-would-set', __( 'This plugin would set', 'debug-log-router' ) ), array( '', __( 'What that means', 'debug-log-router' ) ), ); echo '

'; self::icon( 'triangle-alert' ); echo esc_html__( 'Constants declared before this plugin', 'debug-log-router' ) . '

'; self::note( 'warning', '

' . esc_html__( 'A constant is declared once and the first declaration wins. Something declared these before the loader reached them, so the line this plugin writes never ran. Only editing wp-config.php by hand changes that: the plugin does not touch declarations it did not write.', 'debug-log-router' ) . '

' ); if ( ! empty( $conflict ) ) { echo '

' . esc_html__( 'In force with a different value', 'debug-log-router' ) . '

'; self::render_table( $headings, self::constant_rows( $conflict, $snapshot, false ), '' ); } if ( ! empty( $latent ) ) { echo '

' . esc_html__( 'In force with the same value, for now', 'debug-log-router' ) . '

'; echo '

' . esc_html__( 'Nothing is missing from the log because of these, and that is a coincidence rather than the setting working. The plugin is not in charge of them from now on, so the day the setting changes, what is in force will not follow.', 'debug-log-router' ) . '

'; self::render_table( $headings, self::constant_rows( $latent, $snapshot, true ), '' ); } } /** * Lists what stands in the way. * * @param array $blockers Result of blockers(). * @return void */ private static function render_blockers( array $blockers ) { if ( empty( $blockers ) ) { return; } echo '

'; self::icon( 'circle-alert' ); echo esc_html__( 'Needs attention', 'debug-log-router' ) . '

'; /* * A notice each rather than a list (2026-09-28): what stops the button * is an error, what only deserves a look is a warning, and the colour * says which before the words do. */ foreach ( $blockers as $blocker ) { self::note( $blocker['blocks'] ? 'error' : 'warning', sprintf( '

%s%s

', $blocker['blocks'] ? '' . esc_html__( 'Blocking:', 'debug-log-router' ) . ' ' : '', esc_html( $blocker['text'] ) ) ); } } /** * Draws the button. * * ⚠️ Two labels and two prices, and they must differ. Somebody used to * flipping a switch must not one day discover that the flip rewrote * wp-config.php. * * @param array $snapshot Result of snapshot(). * @param array $blockers Result of blockers(). * @return void */ private static function render_form( array $snapshot, array $blockers ) { $state = $snapshot['state']; /* * ⚠️ The toggle moved to the Settings tab (заказчик, 2026-09-11), and * nothing takes its place here. A pointing sentence was tried and * dropped the same day: this tab already says whether logging is on * twice, in the heading and in the table row, and a third telling would * only be noise. * * It is a move rather than an addition: two places to switch one thing * is exactly what once cost the removal button its name, when it stood * beside the toggle and was mistaken for one. */ if ( 'B' === $state || 'C' === $state ) { return; } $reasons = self::blocking_reasons( $snapshot, $blockers ); $allowed = empty( $reasons ); echo '
'; wp_nonce_field( self::NONCE ); echo ''; /* * States A and D: this is the expensive press, the one that edits * wp-config.php. Everything it will do is written out above it and the * block itself is shown in full, so nothing about it is a surprise. * * ⚠️ This is why it stays here while the toggle leaves: the description * below points at the constants and the block printed above it. On the * Settings tab there is neither, and the same sentence would be untrue. */ self::render_disabled_reason( $reasons ); submit_button( __( 'Set up and enable logging', 'debug-log-router' ), 'primary', 'submit', true, $allowed ? array() : array( 'disabled' => 'disabled' ) ); echo '

' . esc_html__( 'This comments out the constants listed above, adds the block shown above, creates the log directory and switches logging on. If wp-config.php cannot be written, nothing is changed at all, and the block can be pasted in by hand instead.', 'debug-log-router' ) . '

'; echo '
'; } /** * Asks before deleting a directory that still holds something. * * Drawn only when the previous request came back with a question in the * URL, and only when that question still makes sense: the name has to be * ours, on disk and not the active one. A stale link - the directory * deleted in another tab meanwhile, or the answer given already - draws * nothing rather than a dialog about something that is gone. * * ⚠️ No JavaScript, and for the same reason the uninstall warning has * none (заказчик, 2026-09-05): confirm() fails in the dangerous * direction. A broken script means no dialog and a form that submits * anyway, so a directory full of logs would go without a word. A page * that is already rendered has nothing left to break. * * @param string $active Name of the active directory. * @return void */ private static function render_delete_confirm( $active ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: it only decides whether to draw the question. The deletion behind it is a POST with a nonce. $name = isset( $_GET['dlogr_confirm'] ) ? sanitize_text_field( wp_unslash( $_GET['dlogr_confirm'] ) ) : ''; if ( '' === $name || $name === $active ) { return; } $path = DLOGR_Log_Dir::path( $name ); if ( '' === $path || ! is_dir( $path ) ) { return; } self::note_open( 'warning' ); echo '

'; printf( /* translators: %s: path of the log directory, starting at the uploads folder. */ esc_html__( '%s holds log files. Deleting the directory deletes them too, and there is no way back.', 'debug-log-router' ), '' . esc_html( DLOGR_Log_Dir::label( $name ) ) . '' ); echo '

'; echo '

'; wp_nonce_field( self::NONCE ); echo ''; printf( '', esc_attr( $name ) ); echo ''; printf( '', esc_html__( 'Delete', 'debug-log-router' ) ); echo '
'; // A plain link: cancelling is going back to the tab, and nothing else. printf( '%2$s', esc_url( add_query_arg( array( 'page' => self::PAGE, 'tab' => 'settings', ), admin_url( 'tools.php' ) ) ), esc_html__( 'Cancel', 'debug-log-router' ) ); echo '

'; self::note_close(); } /** * The second block of the Settings tab: is anything being logged right now. * * ⚠️ Moved here from the Status tab (заказчик, 2026-09-11), and moved * rather than copied. Two places to switch one thing is what once cost the * removal button its name: standing beside the toggle, it was read as one. * Only the cheap press travels - the expensive one, which edits * wp-config.php, stays on Status because its own description points at the * constants and the block printed above it. * * ⚠️ A switch inside the settings form since 2026-09-28 (заказчик), not a * button that acts at once: it is saved with everything else by the one * button of the tab. Its label calls for the action, by the saved state - * "Enable logging" while off, "Disable logging" while on - which is the * customer's choice over one fixed label. * * The hidden field carries the state the page showed. The handler acts only * if the switch differs from it, so a page left open while logging was * switched elsewhere does not flip it back; and a missing field means the * switch was never drawn, which is what an unticked box looks like too. * Разбор - development/docs/admin.md, «Одна форма, одна кнопка». * * In state C nothing can stand in the way: the obstacles that disable the * Status button all concern writing wp-config.php, which this does not do. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_toggle( array $snapshot ) { $state = $snapshot['state']; echo '

'; self::icon( 'file-text' ); echo esc_html__( 'Logging', 'debug-log-router' ) . '

'; /* * Nothing to toggle yet: in states A and D the press that would start * logging edits wp-config.php, and it belongs where the constants it * comments out and the block it adds are printed around it. */ if ( 'A' === $state || 'D' === $state ) { printf( '

%s

', esc_html__( 'Logging is not set up yet. Setting it up edits wp-config.php once, so it is done on the Status tab, where every change it makes is listed first.', 'debug-log-router' ) ); printf( '

%s

', esc_url( self::tab_url( 'status' ) ), esc_html__( 'Go to the Status tab', 'debug-log-router' ) ); return; } $on = 'B' === $state; printf( '', $on ? '1' : '0' ); echo '

'; if ( $on ) { echo '

' . esc_html__( 'Switching off does not touch wp-config.php: the block stays where it is and does nothing on its own.', 'debug-log-router' ) . '

'; } else { echo '

' . esc_html__( 'The block is already in wp-config.php, so this only turns the setting on and regenerates the loader data file. The config is not touched.', 'debug-log-router' ) . '

'; } } /** * An address on one of this page's tabs. * * @param string $tab Tab slug. * @return string */ private static function tab_url( $tab ) { return add_query_arg( array( 'page' => self::PAGE, 'tab' => $tab, ), admin_url( 'tools.php' ) ); } /** * Draws the error display mode block of the Settings tab. * * Third, between the toggle and deletion: what is written, whether it is * being written, what the visitor sees while it is, and what survives the * plugin. Its own block rather than part of the toggle, because the mode * applies whether logging is on or off - sharing a block would suggest it * switches off with the toggle, which is the one thing people get wrong * about it. Разбор - docs/admin.md. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_display( array $snapshot ) { $state = $snapshot['state']; echo '

'; self::icon( 'users' ); echo esc_html__( 'Showing errors to visitors', 'debug-log-router' ) . '

'; /* * No block in wp-config.php means the loader is never included, so this * constant is decided by core and the setting below would be a promise * nobody keeps. Same shape as the toggle block in these two states. */ if ( 'A' === $state || 'D' === $state ) { printf( '

%s

', esc_html__( 'This setting does nothing yet. It works through the block in wp-config.php, and the block is not there, so WordPress decides on its own whether errors reach the screen.', 'debug-log-router' ) ); printf( '

%s

', esc_url( self::tab_url( 'status' ) ), esc_html__( 'Go to the Status tab', 'debug-log-router' ) ); return; } $current = (string) DLOGR_Settings::get( 'debug_display' ); /* * The constant and its value are glued on outside __(), because they are * code rather than prose: inside the translated string a translator has * every reason to turn "false" into the word for false, and the label * would then name something that does not exist. * * ⚠️ The third option carries no constant on purpose (заказчик, * 2026-09-12). Its value is null, and "WP_DEBUG_DISPLAY = null" tells * nobody anything - what matters is that the plugin steps aside, which * is what the label says. */ $modes = array( 'off' => array( __( 'Hide errors from visitors', 'debug-log-router' ) . ' (WP_DEBUG_DISPLAY = false)', __( 'The default, and the safe one. Errors go to the log and nowhere else.', 'debug-log-router' ), ), 'on' => array( __( 'Show errors in the browser', 'debug-log-router' ) . ' (WP_DEBUG_DISPLAY = true)', __( 'Anyone opening the site sees them, visitors included. Not something to leave on a live site.', 'debug-log-router' ), ), 'server' => array( __( 'Leave it to the server', 'debug-log-router' ), __( 'The plugin does not interfere and the hosting decides.', 'debug-log-router' ), ), ); /* * Part of the one settings form since 2026-09-28. The hidden field is * the mode the page showed: the handler acts only on a change from it, * the same guard the logging switch has. */ printf( '', esc_attr( $current ) ); foreach ( $modes as $value => $mode ) { printf( '


%4$s

', esc_attr( $value ), checked( $current, $value, false ), esc_html( $mode[0] ), esc_html( $mode[1] ) ); } /* * php_admin_flag in a PHP-FPM pool fixes display_errors as firmly as * php_admin_value fixes error_log: ini_set() fails quietly, ours and * core's alike, and the choice above changes nothing (dorabotki.md, * пункт 5). Not a reason to refuse anything - only this block is moot, * so only this block says so. */ if ( DLOGR_Boot::is_pinned( 'display_errors' ) ) { /* * In words rather than as PHP stores it: a flag switched off comes * back as "0" or as nothing at all (dorabotki.md, пункт 20). stderr * and stdout are shown as they are. Not translated: these are the * values php.ini takes, not phrases. */ $shown = strtolower( trim( (string) ini_get( 'display_errors' ) ) ); if ( in_array( $shown, array( '', '0', 'off', 'false', 'no', 'none' ), true ) ) { $shown = 'off'; } elseif ( in_array( $shown, array( '1', 'on', 'true', 'yes' ), true ) ) { $shown = 'on'; } self::note( 'warning', '

' . esc_html( sprintf( /* translators: %s: value of display_errors: off, on, stderr or stdout. */ __( 'The server has fixed display_errors to %s, so this choice changes nothing: PHP ignores any attempt to override it.', 'debug-log-router' ), $shown ) ) . '

' ); } /* * ⚠️ On the saved value, not on every visit. Unlike the warning under * the delete-everything box, which describes an irreversible action and * has to be read before ticking, this describes the state the site is * in right now and is undone by one press. * * ⚠️ The toggle does not come into it, and the note of 2026-08-31 that * tied this to logging being off was too narrow. Core leaves * display_errors alone whenever WP_DEBUG_DISPLAY is null, in both * branches of wp_debug_mode() - see load.php:616-620 - so the hosting * decides either way. With logging on it decides over a wider set: * error_reporting is raised to E_ALL a few lines above. */ if ( 'server' === $current ) { self::note( 'warning', '

' . esc_html__( 'With this choice the hosting decides, whether logging is on or off. If it has display_errors switched on, PHP warnings appear in front of visitors and this plugin does not stop them. Choose to hide errors from visitors to be sure they never show.', 'debug-log-router' ) . '

' ); } /* * Showing errors has a warning too, and a red one (заказчик, * 2026-09-28): it is the one mode that puts errors in front of * visitors for certain, where "server" only might. Same rule as above - * on the saved value, since it describes the site as it is now. * * "Whether logging is on or off" is checked, not assumed: boot.php sets * display_errors itself for this mode, outside core's if ( WP_DEBUG ). * With logging off core narrows error_reporting, so fewer kinds show, * but warnings and fatal errors still do. */ if ( 'on' === $current ) { self::note( 'error', '

' . esc_html__( 'Errors are shown in the browser to everyone who opens the site, visitors included, whether logging is on or off. An error message can give away paths on the server, parts of database queries and other internals. Use this only on a site that is not live, and switch back to hiding errors when you are done.', 'debug-log-router' ) . '

' ); } } /** * The first block of the Settings tab: the language of this plugin's texts. * * Printed in every state, unless the profile language is English already * and there is nothing to choose (заказчик, 2026-10-11). The filter lives * in the plugin rather than in the loader, so wp-config.php has no say in * it - unlike the error display mode further down. * * Two options since 2026-10-11: the profile language, loaded by core from * its language pack, and English. See DLOGR_Plugin::languages(). * * ⚠️ Language names go in untranslated, the way WordPress names languages * in its own selector: the list is for somebody who cannot read the * interface as it stands. So does the locale code after the first option - * code rather than prose, and it says what "from your profile" means right * now (заказчик, 2026-09-15). A stored value from an earlier version that * is no longer on the list shows no match, and the browser selects the * first option - which is what is in effect. * * Разбор - docs/admin.md and docs/perevod.md. * * @return void */ private static function render_language() { /* * Nothing to choose with an English profile: both options would mean * the same. Drawing nothing leaves the card out (see blocks()), the * form then sends no language, and the save leaves the setting alone. */ if ( 'en_US' === get_user_locale() ) { return; } $current = (string) DLOGR_Settings::get( 'interface_language' ); $options = array( 'default' => __( 'WordPress language from your profile', 'debug-log-router' ) . ' (' . get_user_locale() . ')', ); foreach ( DLOGR_Plugin::languages() as $locale => $name ) { $options[ $locale ] = $name; } echo '

'; self::icon( 'globe' ); echo esc_html__( 'Interface language', 'debug-log-router' ) . '

'; printf( '

'; printf( '

%s

', esc_html__( 'Changes only the texts of this plugin. Menus, buttons and messages of WordPress itself stay in the language of your profile.', 'debug-log-router' ) ); } /** * The last block of the Settings tab: what deleting the plugin does. * * Independent boxes, all set by hand (заказчик, 2026-09-28): the current * log directory, each leftover, the plugin's own data. Above them stand * the directories and the row of language, logging and error display; the * boxes are saved with those by the one button under this card. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_settings( array $snapshot ) { echo '

'; self::icon( 'trash-2' ); echo esc_html__( 'Deleting the plugin', 'debug-log-router' ) . '

'; echo '

' . esc_html__( 'WordPress asks nothing when a plugin is deleted, and by then this plugin can no longer ask either. So the answer is given here, in advance.', 'debug-log-router' ) . '

'; self::render_uninstall_warning( $snapshot ); } /** * The one button of the Settings tab, and the line that says it is needed. * * Sticks to the bottom of the window while the form is on screen: the * deletion card above it is long and would otherwise take the button out * of sight of whoever just flipped a switch at the top. * * ⚠️ The line about unsaved changes is printed hidden and revealed by the * script once anything in the form changes. Printed here rather than built * by the script, so it is translated like every other string. With the * script broken the line simply never appears and the page is what it was * without it - which is what makes a script acceptable on this tab at all * (docs/oformlenie.md, «Вкладка «Настройки»»). * * @return void */ private static function render_save_bar() { echo '
'; submit_button( __( 'Save settings', 'debug-log-router' ), 'primary', 'submit', false ); printf( '', esc_html__( 'There are unsaved changes.', 'debug-log-router' ) ); echo '
'; } /** * The uninstall boxes, and what stays whatever is ticked. * * ⚠️ Every box is independent and set by hand only (заказчик, 2026-09-28). * No box switches another and no "tick everything" shortcut: each loss is * chosen on its own. No script shows or hides any of this: all the text is * shown always, before anything is ticked. (The one script of the tab only * says that something is unsaved - see render_save_bar().) * * Everything is named rather than described. A directory with an eight * character random suffix is unrecognisable a year later, and the person * reading this is the one who will have to find it. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_uninstall_warning( array $snapshot ) { // A warning box since 2026-09-28 (заказчик): what follows is lost for good. self::note( 'warning', '

' . esc_html__( 'Ticked, deleting the plugin also deletes:', 'debug-log-router' ) . '

' ); if ( '' === $snapshot['current'] && empty( $snapshot['leftovers'] ) ) { echo '

' . esc_html__( 'log directories — none found right now', 'debug-log-router' ) . '

'; } if ( '' !== $snapshot['current'] ) { self::render_uninstall_box( 'dlogr_delete_log_dir', '1', (bool) DLOGR_Settings::get( 'delete_log_dir_on_uninstall' ), sprintf( /* translators: %s: path of a log directory, starting at the uploads folder. */ __( 'the log directory %s and everything in it', 'debug-log-router' ), DLOGR_Log_Dir::label( $snapshot['current'] ) ) ); } $ticked = (array) DLOGR_Settings::get( 'delete_leftovers_on_uninstall' ); foreach ( $snapshot['leftovers'] as $name ) { /* * ⚠️ A leftover PHP cannot read gets a box that cannot be ticked * (dorabotki.md, пункт 22). Deleting it fails for certain, and a * failure on the directories stops the whole uninstall chain - the * data of the plugin would stay too, ticked or not. A disabled box is * not sent with the form, so a tick from before is dropped on the * next save, which is the point. */ if ( 'unreadable' === DLOGR_Log_Dir::content_state( $name ) ) { self::render_uninstall_box( 'dlogr_delete_leftovers[]', $name, false, sprintf( /* translators: %s: path of a log directory, starting at the uploads folder. */ __( 'the log directory %s — it cannot be read, so it cannot be deleted', 'debug-log-router' ), DLOGR_Log_Dir::label( $name ) ), false ); continue; } self::render_uninstall_box( 'dlogr_delete_leftovers[]', $name, in_array( $name, $ticked, true ), sprintf( /* translators: %s: path of a log directory, starting at the uploads folder. */ __( 'the log directory %s and everything in it', 'debug-log-router' ), DLOGR_Log_Dir::label( $name ) ) ); } self::render_uninstall_box( 'dlogr_delete_data', '1', (bool) DLOGR_Settings::get( 'delete_data_on_uninstall' ), __( 'the data of the plugin:', 'debug-log-router' ) ); echo '
    '; printf( '
  • %s
  • ', esc_html( sprintf( /* translators: %s: database table name. */ __( 'the journal table %s', 'debug-log-router' ), DLOGR_Journal::table() ) ) ); printf( '
  • %s
  • ', esc_html( sprintf( /* translators: 1: first option name, 2: second option name. */ __( 'the settings, kept in the options %1$s and %2$s', 'debug-log-router' ), DLOGR_Settings::SETTINGS, DLOGR_Settings::STATE ) ) ); echo '
'; echo '

' . esc_html__( 'Never deleted: the old wp-content/debug.log, which is left exactly as WordPress wrote it.', 'debug-log-router' ) . '

'; /* * Only while the block is actually in the file. Without it this * describes a danger that does not exist in this state, and a warning * about nothing teaches people to skip warnings. */ if ( empty( $snapshot['config']['blocks'] ) && empty( $snapshot['config']['disabled'] ) ) { return; } echo '

' . esc_html__( 'Nothing is deleted at all unless wp-config.php can be cleaned up first.', 'debug-log-router' ) . ' ' . esc_html__( 'Uninstall works blind and cannot report a failure, so it leaves everything in place rather than half-finished; the reason then goes into the journal. To do it with an answer on screen, use "Remove the setting from wp-config.php" on the Status tab before deleting the plugin.', 'debug-log-router' ) . '

'; /* * Said outright when it is already certain (dorabotki.md, пункт 16): with * the file closed for writing uninstall.php refuses at the first step, * and every box ticked above would come to nothing. */ if ( ! $snapshot['config']['writable'] ) { self::note( 'warning', '

' . esc_html__( 'Right now WordPress cannot change wp-config.php, so deleting the plugin would delete nothing at all.', 'debug-log-router' ) . '

' ); } } /** * One uninstall checkbox with its label. * * @param string $field Name of the form field. * @param string $value Value sent when ticked. * @param bool $checked Whether it is ticked now. * @param string $label Translated label. * @param bool $enabled Whether the box can be ticked at all. * @return void */ private static function render_uninstall_box( $field, $value, $checked, $label, $enabled = true ) { printf( '

', esc_attr( $field ), esc_attr( $value ), checked( $enabled && $checked, true, false ), disabled( $enabled, false, false ), esc_html( $label ) ); } /** * Shows the lines the removal will touch, before it touches them. * * Line numbers alone say where to look without showing anything, and the * person about to have a live wp-config.php edited deserves to see it * first. It also makes the page symmetrical: state A already shows the * block that will be added. * * ⚠️ Two groups rather than one, because the two fates differ. The block * goes away; the commented-out lines come back to life. That difference is * exactly what the paragraph above the button spends its words explaining, * and one lump of text would hide it again. * * ⚠️ Not one neighbouring line for context, ever. wp-config.php holds the * database password and the salts, so only the ranges the reader named are * printed. A "three lines of context" convenience is precisely how a * password reaches the screen and from there a screenshot in a support * thread. See development/docs/wp-config.md. * * @param array $config Result of DLOGR_Config::read(). * @return void */ private static function render_removal_preview( array $config ) { foreach ( $config['blocks'] as $block ) { if ( empty( $block['text'] ) ) { continue; } /* * With its own heading and icon, the same shape as the cards of the * lines given back below it (заказчик, 2026-09-28). Without it the * block reads as one more thing to be given back. */ self::render_subtitle( 'trash-2', 'red', __( 'Will be removed:', 'debug-log-router' ) ); self::render_config_lines( $block['text'], (int) $block['line_start'] ); } $lines = array(); foreach ( $config['disabled'] as $line ) { if ( ! empty( $line['text'] ) ) { $lines[] = $line; } } if ( empty( $lines ) ) { return; } /* * A small card per line, two to a row, one below the other on a narrow * screen (заказчик, 2026-09-28): each line has its own before and * after, and side by side they are read as the set they are. */ echo '
'; foreach ( $lines as $line ) { echo '
'; self::render_subtitle( 'rotate-ccw', 'blue', __( 'Will be given back to the site:', 'debug-log-router' ) ); printf( '

%s

', esc_html__( 'as it stands now', 'debug-log-router' ) ); self::render_config_lines( $line['text'], (int) $line['line'] ); printf( '

%s

', esc_html__( 'as it will stand afterwards', 'debug-log-router' ) ); self::render_config_lines( isset( $line['restored'] ) ? $line['restored'] : '', (int) $line['line'] ); echo '
'; } echo '
'; } /** * A heading inside a card, with its icon. * * @param string $icon Icon name. * @param string $colour Icon colour: blue, red, green, orange, grey. * @param string $text Translated text. * @return void */ private static function render_subtitle( $icon, $colour, $text ) { printf( '

', esc_attr( $colour ) ); self::icon( $icon ); echo '' . esc_html( $text ) . '

'; } /** * A fragment of the config, numbered the way the file numbers it. * * The numbers are not decoration: when WordPress cannot write the file they * are the whole instruction for doing the edit by hand. * * ⚠️ The numbers stand in a column of their own and are not text * (2026-09-28): each line is a block of its own carrying its number in * data-line, and the stylesheet draws it. Generated content cannot be * selected, so a line dragged over with the mouse no longer comes out with * its number glued to the front. No colouring of the code either - one * colour for all of it (заказчик, 2026-09-28). * * @param string $text Lines as they are in the file. * @param int $first Line number the fragment starts at. * @return void */ private static function render_config_lines( $text, $first ) { if ( '' === (string) $text ) { return; } $lines = explode( "\n", str_replace( "\r\n", "\n", rtrim( (string) $text, "\r\n" ) ) ); $number = (int) $first; echo '
';

		foreach ( $lines as $line ) {
			printf( '%s', (int) $number, esc_html( $line ) );
			++$number;
		}

		echo '
'; } /** * Draws the button that writes the block again, inside the red card. * * Only while the block does not load the plugin (заказчик, 2026-10-15) - * see do_update_block(). Right under the sentence that names the problem, * so the way out is where the problem is said. * * Not primary: the removal button further down this tab is, and a tab has * one. Off while the file itself is closed for writing, like that one, and * then the new block is shown to be put in by hand - the same
 and
	 * copy button as the fragment, which is never drawn while a block is in
	 * the file. Shown the same way after a refusal for want of direct writing.
	 *
	 * @param array $snapshot Result of snapshot().
	 * @return void
	 */
	private static function render_update_block( array $snapshot ) {
		$config = $snapshot['config'];

		if ( false === $config['path'] ) {
			return;
		}

		$writable = (bool) $config['writable'];

		/*
		 * The block for a manual edit is also shown right after the button was
		 * refused for want of direct writing, which the update_manual message
		 * points at. Not asked on every visit instead: get_filesystem_method()
		 * writes a probe file next to wp-config.php to find out.
		 */
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only, as in render_result(): it only decides whether a block to copy is shown.
		$refused = isset( $_GET['dlogr_result'] ) && 'update_manual' === sanitize_key( wp_unslash( $_GET['dlogr_result'] ) );
		$manual  = ! $writable || $refused;

		echo '
'; wp_nonce_field( self::NONCE ); echo ''; if ( ! $writable ) { self::render_disabled_reason( array( array( 'short' => __( 'wp-config.php is not writable', 'debug-log-router' ) ) ) ); } submit_button( __( 'Update the block in wp-config.php', 'debug-log-router' ), 'secondary', 'submit', true, $writable ? array() : array( 'disabled' => 'disabled' ) ); if ( ! $manual ) { echo '

' . esc_html__( 'Only the block of this plugin is written again, with the paths this site uses now; nothing else in wp-config.php changes. Entries written so far stay where they are.', 'debug-log-router' ) . '

'; } else { echo '

' . esc_html__( 'WordPress cannot change wp-config.php here, so replace the block by hand: the lines of the current block go, and this text takes their place.', 'debug-log-router' ) . '

'; printf( '
%s
', esc_html( DLOGR_Config::block( $config['path'], DLOGR_PLUGIN_DIR . 'boot.php', DLOGR_Log_Dir::base(), $config['eol'] ) ) ); // Hidden until the script is known to work - see render_fragment(). printf( '

', esc_html__( 'Copy to clipboard', 'debug-log-router' ) ); } echo '
'; } /** * Draws the button that takes our block back out of the config. * * Shown whenever there is something of ours in the file - a block, lines we * commented out, or both - regardless of whether logging is on. It is the * mirror of the button above it, which is why it lives on this tab: it * talks about the block shown right here. * * ⚠️ Its description says what the toggle does not do, because that is the * one thing people get wrong about it: switching logging off leaves the * owner's own constants commented out and this plugin in charge of them. * * The line numbers below it are not decoration either. When WordPress * cannot write the file, they are the whole instruction for doing it by * hand - there is no fragment to paste for a removal, only lines to delete * and prefixes to drop. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_removal( array $snapshot ) { $config = $snapshot['config']; if ( empty( $config['blocks'] ) && empty( $config['disabled'] ) ) { return; } echo '

'; self::icon( 'wrench-off' ); echo esc_html__( 'Taking the plugin out of wp-config.php', 'debug-log-router' ) . '

'; echo '

' . esc_html__( 'This is not the same as switching logging off. Switching off leaves the block where it is, so the constants below stay commented out and this plugin keeps deciding what WP_DEBUG is. Removing the block hands them back to the site.', 'debug-log-router' ) . '

'; $lines = array(); foreach ( $config['blocks'] as $block ) { $lines[] = sprintf( /* translators: 1: first line number, 2: last line number. */ __( 'block on lines %1$d-%2$d', 'debug-log-router' ), (int) $block['line_start'], (int) $block['line_end'] ); } foreach ( $config['disabled'] as $line ) { $lines[] = sprintf( /* translators: %d: line number in wp-config.php. */ __( 'prefix to drop on line %d', 'debug-log-router' ), (int) $line['line'] ); } if ( ! empty( $lines ) ) { echo '

' . esc_html( implode( '; ', $lines ) ) . '

'; } self::render_removal_preview( $config ); echo '
'; wp_nonce_field( self::NONCE ); echo ''; /* * ⚠️ Off while the file itself is closed for writing (dorabotki.md, * пункт 16): the handler refuses it before touching the file, and a working * button under "Blocking: wp-config.php is not writable" contradicted * itself. The same sign that switches off the enable button - no guess * about the directory: a directory WordPress cannot write in is found * out by the press, which then changes nothing. The handler is not * changed, so a forged POST meets the same refusal. */ $writable = (bool) $config['writable']; if ( ! $writable ) { self::render_disabled_reason( array( array( 'short' => __( 'wp-config.php is not writable', 'debug-log-router' ) ) ) ); } /* * "Setting", not "block": the word the label uses is the one the reader * has (заказчик, 2026-09-05). Inside the code and the documents this * stays the block, because that is what it is - a block of PHP between * two markers. * * Primary since 2026-09-28 (заказчик). It never meets the other primary * button of this tab: that one is drawn while the block is missing, this * one while it is there. */ submit_button( __( 'Remove the setting from wp-config.php', 'debug-log-router' ), 'primary', 'submit', true, $writable ? array() : array( 'disabled' => 'disabled' ) ); if ( $writable ) { echo '

' . esc_html__( 'Logging is switched off, while the log directory, the log files and the settings are all kept. Do this before deleting the plugin: uninstall works blind and cannot report a failure.', 'debug-log-router' ) . '

'; } else { echo '

' . esc_html__( 'WordPress cannot change wp-config.php here, so take the block out by hand: delete the lines of the block and drop the DLOGR-DISABLED prefixes listed above.', 'debug-log-router' ) . '

'; } echo '
'; } /** * Shows the block that would go into wp-config.php. * * Shown whenever the block is not in the file, no matter how it is going to * get there. Not a fallback screen for failures, and that is the point: * the person has to be able to read what the button will put into the file * that runs their whole site before pressing it. This is also why no * separate "here is what will happen, confirm" screen exists - the text is * already on the page. * * When the write cannot happen at all - a read-only wp-config.php is a * standard hardening step, not an accident - the same text is the only way * forward, so it has to say where the block goes and what else has to be * done by hand. * * @param array $snapshot Result of snapshot(). * @return void */ private static function render_fragment( array $snapshot ) { $config = $snapshot['config']; if ( false === $config['path'] || ! empty( $config['blocks'] ) ) { return; } $block = DLOGR_Config::block( $config['path'], DLOGR_PLUGIN_DIR . 'boot.php', DLOGR_Log_Dir::base(), $config['eol'] ); echo '

'; self::icon( 'file-text' ); echo esc_html__( 'What goes into wp-config.php', 'debug-log-router' ) . '

'; if ( 2 === $config['anchor']['type'] && $config['anchor']['line'] !== $config['require'] ) { // Above the comment that sits on that line - see DLOGR_Config::anchor(). printf( '

%s

', esc_html( sprintf( /* translators: 1: line number of a comment in wp-config.php, 2: line number of the wp-settings.php line below it. */ __( 'It goes right before line %1$d, the comment above the line that loads wp-settings.php (line %2$d).', 'debug-log-router' ), $config['anchor']['line'], $config['require'] ) ) ); } elseif ( 2 === $config['anchor']['type'] ) { printf( '

%s

', esc_html( sprintf( /* translators: %d: line number in wp-config.php. */ __( 'It goes right before line %d, the line that loads wp-settings.php.', 'debug-log-router' ), $config['anchor']['line'] ) ) ); } /* * The same code block as the lines on the removal side, without * numbers: the block is not in the file yet (заказчик, 2026-09-28). It * used to be a read-only textarea; the copy script reads textContent * now instead of value. */ printf( '
%s
', esc_html( $block ) ); /* * ⚠️ Hidden by an inline style, not by the hidden attribute. That * attribute is display:none from the browser's own stylesheet, and * .wp-core-ui .button in wp-includes/css/buttons.css sets * display:inline-block - an author rule, which wins. The button would * be visible with scripts off, which is the one thing this must not do. */ printf( '

', esc_html__( 'Copy to clipboard', 'debug-log-router' ) ); if ( ! empty( $config['defines'] ) ) { printf( '

%s

', esc_html( sprintf( /* translators: %s: the marker put in front of a disabled line, for example DLOGR-DISABLED. */ __( 'These lines have to be commented out first, each one prefixed with "// %s ". Without that they win: PHP keeps the first define() it sees and ignores ours. The marker is what makes them restorable later.', 'debug-log-router' ), DLOGR_Config::MARKER_DISABLED ) ) ); echo '
    '; foreach ( $config['defines'] as $define ) { printf( '
  • %s
  • ', esc_html( sprintf( /* translators: 1: constant name, 2: line number. */ __( '%1$s on line %2$d', 'debug-log-router' ), $define['name'], $define['line_start'] ) ) ); } echo '
'; } echo '

' . esc_html__( 'Until this block is in wp-config.php nothing is written to the log files.', 'debug-log-router' ) . '

'; } /** * Explains our directory inside the core security check. * * ⚠️ The status is deliberately left alone. Core marks any log inside * ABSPATH as critical and cannot tell our protected directory with its * random suffix from a public debug.log. Passing somebody else's security * check off as satisfied would be dishonest and would not survive review; * what we may do is add a paragraph to the panel the reader already opened. * * Written defensively - the result structure belongs to core, and a change * there must not take us down with it. * * @param array $result One test result. * @return array */ public static function explain_core_check( $result ) { if ( ! is_array( $result ) || ! isset( $result['test'], $result['description'] ) ) { return $result; } if ( 'is_in_debug_mode' !== $result['test'] ) { return $result; } if ( ! DLOGR_Settings::get( 'logging_enabled' ) ) { return $result; } $result['description'] .= '

' . esc_html__( 'Debug Log Router note: the log directory this plugin uses lives in the uploads folder of this site, which is why the check above counts it. It is created with a random name that cannot be guessed and carries an .htaccess file denying direct access. The check looks only at the path, not at those protections - and on nginx the .htaccess file has no effect.', 'debug-log-router' ) . '

'; return $result; } }