diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index c90c300..3ab6587 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -98,6 +98,9 @@ jobs: functionalities/assets/css/admin-polish.css \ functionalities/assets/js/admin-polish.js \ functionalities/assets/js/admin-link-health.js \ + functionalities/assets/js/admin-link-health-editor.js \ + functionalities/includes/features/class-link-health-editor.php \ + functionalities/includes/features/class-link-health-report.php \ functionalities/assets/icons/settings.svg \ functionalities/assets/icons/arrow-left.svg \ functionalities/assets/icons/LICENSE \ diff --git a/README.md b/README.md index 826ff89..9db81eb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ All-in-one WordPress optimization toolkit with 19 modules for performance, secur All modules are accessed through a unified dashboard at `wp-admin/admin.php?page=functionalities`. Click any module card to configure its settings. -Link Health shows live scan activity, a progress bar, checked-link counts, and 50 results per page across all scanned sources. Keep its workspace open for continuous bounded checking; Stop finishes the current batch and Resume continues from saved progress. See [live scan verification](docs/link-health-live-verification.md). +Link Health shows live scan activity, a progress bar, checked-link counts, and 50 results per page across all scanned sources. Filter by result, source type, or URL/source-title text; CSV export uses the same filters. Preview and replace or unlink matching URLs in an individual source post, with revision support and concurrent-save protection. Keep its workspace open for continuous bounded checking; Stop finishes the current batch and Resume continues from saved progress. See [live scan verification](docs/link-health-live-verification.md). ## Backend branding and controls diff --git a/assets/css/admin-polish.css b/assets/css/admin-polish.css index 6d74bd3..1241c8a 100644 --- a/assets/css/admin-polish.css +++ b/assets/css/admin-polish.css @@ -203,6 +203,22 @@ .functionalities-link-report :is(th,td):nth-child(3) { min-width: 100px; white-space: nowrap; } .functionalities-link-report :is(th,td):nth-child(4) { min-width: 135px; } .functionalities-link-report :is(th,td):last-child { min-width: 100px; } +.functionalities-module form.functionalities-link-filters { display: flex; flex-wrap: wrap; gap: 16px; align-items: end; margin: 24px 0 0; padding: 16px; } +.functionalities-link-filters label, .functionalities-link-editor label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; } +.functionalities-link-filters label { flex: 1 1 170px; min-width: 0; } +.functionalities-link-filters .functionalities-link-search { flex-grow: 2; } +.functionalities-link-filters :is(input,select) { width: 100%; } +.functionalities-link-report [data-link-edit] { margin: 0 4px 4px 0; } +.functionalities-link-editor { width: min(560px,calc(100vw - 48px)); max-height: calc(100vh - 80px); box-sizing: border-box; border: 1px solid var(--func-line); border-radius: 8px; padding: 24px; color: var(--func-ink); } +.functionalities-link-editor::backdrop { background: rgb(0 0 0 / 35%); } +.functionalities-link-editor h2 { margin: 0 0 16px; } +.functionalities-link-editor form { margin: 0; padding: 0; border: 0; background: transparent; } +.functionalities-link-editor label { margin: 16px 0; } +.functionalities-link-editor [data-edit-url] { overflow-wrap: anywhere; } +.functionalities-link-editor [data-edit-preview] { padding: 12px; background: #f0f6fc; border-inline-start: 3px solid var(--func-accent); } +.functionalities-link-editor .functionalities-utility-actions { flex-wrap: wrap; gap: 8px; } +.functionalities-module [data-link-edit-notice] { padding: 12px 16px; margin: 16px 0; } +.functionalities-link-editor [hidden], .functionalities-module [data-link-edit-notice][hidden] { display: none !important; } @media (prefers-reduced-motion:reduce) { .functionalities-link-state .spinner { animation: none; } } @media (min-width:1180px) { .functionalities-settings-layout--with-guide { grid-template-columns: minmax(0,1fr) 320px; } diff --git a/assets/js/admin-link-health-editor.js b/assets/js/admin-link-health-editor.js new file mode 100644 index 0000000..a2a81dc --- /dev/null +++ b/assets/js/admin-link-health-editor.js @@ -0,0 +1,118 @@ +/* Keep source edits separate from live report rendering and require a current preview. */ +(function () { + 'use strict'; + const dialog = document.querySelector('[data-link-editor]'); + if (!dialog || typeof functionalitiesLinkHealth === 'undefined') return; + const root = dialog.closest('.functionalities-module'); + const form = dialog.querySelector('[data-link-edit-form]'); + const mode = dialog.querySelector('[data-edit-mode]'); + const destination = dialog.querySelector('[data-edit-destination]'); + const input = dialog.querySelector('[data-edit-new-url]'); + const feedback = dialog.querySelector('[data-edit-feedback]'); + const preview = dialog.querySelector('[data-edit-preview]'); + const reviewButton = dialog.querySelector('[data-edit-review]'); + const applyButton = dialog.querySelector('[data-edit-apply]'); + const cancelButton = dialog.querySelector('[data-edit-cancel]'); + const config = functionalitiesLinkHealth; + let source = null; + let trigger = null; + let revision = 0; + let reviewed = null; + let saving = false; + let savedView = null; + const format = (template, data) => template.replace('%1$d', () => String(data.count)).replace('%2$s', () => data.title); + function invalidate() { + revision++; + reviewed = null; + applyButton.disabled = true; + preview.hidden = true; + feedback.textContent = ''; + destination.hidden = mode.value !== 'replace'; + input.required = mode.value === 'replace'; + input.disabled = mode.value !== 'replace'; + } + function busy(value) { + saving = value; + mode.disabled = value; + input.disabled = value || mode.value !== 'replace'; + reviewButton.disabled = value; + cancelButton.disabled = value; + applyButton.disabled = value || !reviewed; + } + async function request(operation, fields) { + const body = new FormData(); + const filters = JSON.parse(root.querySelector('[data-link-filters]')?.dataset?.filters || '{}'); + const page = root.querySelector('[data-link-page]')?.dataset?.linkPage || '1'; + Object.entries({action: 'functionalities_link_health_live', nonce: config.nonce, operation, report: operation === 'apply_edit' ? '1' : '0', report_page: page, ...filters, ...fields}).forEach(([key, value]) => body.append(key, value)); + const abort = new AbortController(); + const timeout = setTimeout(() => abort.abort(), 25000); + try { + const response = await fetch(config.ajaxUrl, {method: 'POST', credentials: 'same-origin', body, signal: abort.signal}); + const result = await response.json(); + if (!result.success) throw new Error(result.data?.message || config.requestError); + if (operation === 'apply_edit') savedView = result.data; + return result.data.edit; + } finally { clearTimeout(timeout); } + } + root.addEventListener('click', event => { + const button = event.target.closest('[data-link-edit]'); + if (!button || dialog.open) return; + trigger = button; + savedView = null; + source = {post_id: button.dataset.postId, url: button.dataset.url}; + mode.value = button.dataset.linkEdit; + input.value = ''; + dialog.querySelector('[data-edit-source]').textContent = button.dataset.source; + dialog.querySelector('[data-edit-url]').textContent = button.dataset.url; + invalidate(); + dialog.showModal(); + root.dispatchEvent(new CustomEvent('functionalities:link-editor', {detail: {open: true}})); + if (mode.value === 'replace') input.focus(); + else reviewButton.focus(); + }); + mode.addEventListener('change', invalidate); + input.addEventListener('input', invalidate); + form.addEventListener('submit', async event => { + event.preventDefault(); + if (saving || !form.reportValidity()) return; + invalidate(); + const current = revision; + feedback.textContent = config.previewing; + reviewButton.disabled = true; + try { + const data = await request('preview_edit', {...source, edit_mode: mode.value, replacement: input.value}); + if (current !== revision || !dialog.open) return; + reviewed = data; + preview.textContent = format(data.operation === 'unlink' ? config.previewUnlink : config.previewReplace, data); + preview.hidden = false; + feedback.textContent = ''; + applyButton.disabled = false; + } catch (error) { + if (current === revision && dialog.open) feedback.textContent = error.message || config.requestError; + } finally { reviewButton.disabled = false; } + }); + applyButton.addEventListener('click', async () => { + if (!reviewed || saving) return; + const current = reviewed; + busy(true); + feedback.textContent = config.applying; + try { + const data = await request('apply_edit', {post_id: current.post_id, token: current.token}); + const notice = root.querySelector('[data-link-edit-notice]'); + notice.textContent = format(config.saved, data) + (data.refreshed ? '' : ' ' + config.rescan); + notice.hidden = false; + dialog.close(); + } catch (error) { + invalidate(); + feedback.textContent = error.message || config.requestError; + } finally { busy(false); } + }); + cancelButton.addEventListener('click', () => dialog.close()); + dialog.addEventListener('cancel', event => { if (saving) event.preventDefault(); }); + dialog.addEventListener('close', () => { + invalidate(); + root.dispatchEvent(new CustomEvent('functionalities:link-editor', {detail: {open: false, view: savedView}})); + if (trigger?.isConnected) trigger.focus(); + else root.querySelector('[data-link-filters] button')?.focus(); + }); +})(); diff --git a/assets/js/admin-link-health.js b/assets/js/admin-link-health.js index 97ee41f..356f071 100644 --- a/assets/js/admin-link-health.js +++ b/assets/js/admin-link-health.js @@ -6,6 +6,7 @@ const config = functionalitiesLinkHealth; const root = panel.closest('.functionalities-module'); const report = root.querySelector('[data-link-report]'); + const filters = JSON.parse(root.querySelector('[data-link-filters]')?.dataset?.filters || '{}'); const label = panel.querySelector('[data-link-label]'); const summary = panel.querySelector('[data-link-summary]'); const meter = panel.querySelector('[data-link-meter]'); @@ -20,6 +21,7 @@ let suspended = false; let autoDrive = true; let actionError = false; + let editing = false; function render() { const active = state.status === 'running' || state.status === 'stopping'; @@ -47,7 +49,7 @@ } async function request(operation, fields = {}) { const body = new FormData(); - Object.entries({action: 'functionalities_link_health_live', nonce: config.nonce, operation, run: state.run, report: '1', report_page: page(), ...fields}).forEach(([key, value]) => body.append(key, value)); + Object.entries({action: 'functionalities_link_health_live', nonce: config.nonce, operation, run: state.run, report: editing ? '0' : '1', report_page: page(), ...filters, ...fields}).forEach(([key, value]) => body.append(key, value)); const abort = new AbortController(); const timeout = setTimeout(() => abort.abort(), 25000); try { @@ -63,7 +65,7 @@ } function accept(data) { state = data.progress; - if (report && typeof data.html === 'string' && report.innerHTML !== data.html) report.innerHTML = data.html; + if (!editing && report && typeof data.html === 'string' && report.innerHTML !== data.html) report.innerHTML = data.html; if (!actionError) error.hidden = true; render(); } @@ -143,6 +145,14 @@ if (document.visibilityState === 'hidden') clearTimeout(timer); else schedule(0); }); + root.addEventListener('functionalities:link-editor', event => { + editing = !!event.detail.open; + if (!editing && event.detail.view?.progress) { + epoch++; + accept(event.detail.view); + } + if (!editing) schedule(0); + }); render(); schedule(0); })(); diff --git a/build.sh b/build.sh index 746c808..66ce645 100755 --- a/build.sh +++ b/build.sh @@ -47,6 +47,9 @@ REQUIRED=( "assets/css/admin-polish.css" "assets/js/admin-polish.js" "assets/js/admin-link-health.js" + "assets/js/admin-link-health-editor.js" + "includes/features/class-link-health-editor.php" + "includes/features/class-link-health-report.php" "assets/icons/settings.svg" "assets/icons/arrow-left.svg" "assets/icons/LICENSE" diff --git a/docs/link-health-edit-verification.md b/docs/link-health-edit-verification.md new file mode 100644 index 0000000..e7340d9 --- /dev/null +++ b/docs/link-health-edit-verification.md @@ -0,0 +1,24 @@ +# Link Health filtering and source edits + +The report now filters by effective result status, source type, and URL/source-title text before applying 50-row pagination. Applied filters survive live refreshes, page navigation, and CSV export. Filtered reads use bounded source batches and read only Link Health metadata. The unfiltered count-index path remains available. + +Each report row offers Replace URL and Unlink for users who can edit its source post. A preview identifies that post and counts matching anchors. The apply token is bound to the current actor, operation, URLs, and source fingerprint, and expires after ten minutes. Apply rechecks permissions and source state; a binary compare-and-swap prevents overwriting a save that arrives after validation. WordPress revisions, cache invalidation, and post-save notifications are preserved. New destinations appear unchecked; unchanged URLs retain their earlier results. + +The HTML API targets anchor tokens without reserializing surrounding block comments, scripts, or markup. Replacement preserves original fragments unless explicitly replaced. Unlink retains child text/media and preserves native button structure. Ambiguous target markup and URLs duplicated in serialized block settings are rejected with a post-editor fallback. The small bookmark adapter handles WordPress 6.3's inclusive end offset and newer core's length representation, with coverage against both implementations. URL inputs retain percent encoding instead of passing through text sanitization that would remove encoded bytes. + +## Verification + +- PHP syntax, WordPress coding standards/PHP 7.4 compatibility, and 167 PHP tests / 892 assertions passed. The 39 utility-module tests also passed against newer local core. +- All 18 JavaScript tests passed. New coverage includes filter preservation, report refresh suspension while editing, invalidating changed/cancelled previews, literal source titles, late responses, and applying only a reviewed token. +- PHP cases cover filtering before pagination, source types, ignored status, literal zero searches, percent encoding, fragments, quoted attributes, raw scripts, inline media, native buttons, unclosed links, actor permissions, stale previews, single-use application, failed writes, and a concurrent save arriving after validation. +- Gatilab's real report returned 200 broken links. Page two contained 50 broken rows and preserved the filter; CSV export contained exactly those 200 broken results. Combining Broken with Pages returned one matching result. A fixture title search completed in approximately 0.37 seconds against the retained report set. +- On the temporary public fixture, Replace updated three matching anchors and retained their distinct fragments. Unlink then preserved strong text, the image, button structure, block types, and the unrelated anchor. Readback found three WordPress revisions and a reconciled report containing only the remaining URL. The anonymous frontend output matched these changes. +- A further fixture replacement preserved `link%20editor` through the browser request and stored content. +- At 390px, the dialog measured 342px, with no page overflow. Switching an invalid replacement input to Unlink disabled the hidden destination field and successfully produced a preview. +- After removing the verified fixtures and their revisions, all 19 module settings, all 1,203 existing post/page content/type/status hashes, and all existing link-report metadata matched the snapshot. The original completed scan retained 1,161 posts and 3,500 link checks. All 150 deployed runtime files matched the package. + +## Visual-check limitation + +Gutenberg's iframe canvas remained empty in the test browser on both the edited fixture and an untouched control containing the original markup. No successful canvas-validation claim is made. Stored block structure, exact surrounding markup, revisions, and public frontend output were verified independently. The existing editor-iframe visual release gate remains applicable. + +Recovery and private receipts are under `/home/gatilab/functionalities-link-editor-20261002`. Only the temporary fixture and control were edited or deleted during live content testing; cleanup validates their expected content before removal. diff --git a/docs/plans/2026-10-02-link-health-filters-editor.md b/docs/plans/2026-10-02-link-health-filters-editor.md new file mode 100644 index 0000000..6573136 --- /dev/null +++ b/docs/plans/2026-10-02-link-health-filters-editor.md @@ -0,0 +1,11 @@ +# Link Health filters and source-link editing + +Add status, source-type, and URL/source-title search filters. Apply filters before the existing 50-row pagination, preserve them across live refreshes and navigation, and allow CSV export of the same selection. Keep the existing fast count-index path for unfiltered reports; filtered reads load report metadata in bounded chunks rather than all post metadata. + +Each row gains Replace URL and Unlink actions scoped to its source post. The editor previews the occurrence count and source title, then requires Apply. Preview tokens belong to the current administrator, expire, and bind the exact operation, source URL, replacement, and current content fingerprint. Applying revalidates edit_post permission, public-source membership, current content, and token ownership. + +Use the WordPress HTML API for exact anchor targeting. Replacement changes href attributes only, retaining fragments unless the new URL supplies one. Unlink removes matching anchor wrappers while keeping their inner content; native button anchors retain their structural element with navigation attributes removed. Use HTML API bookmark offsets only through a small compatibility adapter tested against the minimum and current core implementations. Refuse ambiguous/unclosed target anchors. + +Save through a compare-and-swap source update, preserve revisions when enabled, clear post caches, fire normal post-save notifications, and reconcile report rows after the edit. Existing unchanged URLs retain their results; newly introduced URLs appear unchecked. Report refreshes pause while the edit dialog is open so input and review state are not replaced. Editing a preview invalidates it; late responses cannot re-enable Apply for different inputs. + +Verify filtering across sources, pagination, ignored statuses, URL entities/fragments, block comments, inline markup, scripts, native buttons, stale previews, actor permissions, one-use application, source-write failure, and changes during save. On Gatilab, modify only dedicated temporary fixtures and preserve existing user content/settings. diff --git a/includes/admin/class-link-health-controller.php b/includes/admin/class-link-health-controller.php index 31db7f8..9440476 100644 --- a/includes/admin/class-link-health-controller.php +++ b/includes/admin/class-link-health-controller.php @@ -7,6 +7,8 @@ namespace Functionalities\Admin; use Functionalities\Features\Link_Health; +use Functionalities\Features\Link_Health_Editor; +use Functionalities\Features\Link_Health_Report; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -19,9 +21,16 @@ public static function init(): void { \add_action( 'wp_ajax_functionalities_link_health_live', array( __CLASS__, 'ajax' ) ); } + /** Preserve percent-encoded URLs; downstream checks validate protocol and source membership. */ + public static function request_url( string $field = 'url' ): string { + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Authorized callers validate URLs; sanitize_text_field would corrupt percent-encoded paths and queries. + return isset( $_POST[ $field ] ) && is_string( $_POST[ $field ] ) ? trim( \wp_unslash( $_POST[ $field ] ) ) : ''; + } + /** Load only in the Link Health workspace, including its submenu alias. */ public static function enqueue(): void { \wp_enqueue_script( 'functionalities-link-health', FUNCTIONALITIES_URL . 'assets/js/admin-link-health.js', array(), FUNCTIONALITIES_VERSION . '-' . filemtime( FUNCTIONALITIES_DIR . 'assets/js/admin-link-health.js' ), true ); + \wp_enqueue_script( 'functionalities-link-editor', FUNCTIONALITIES_URL . 'assets/js/admin-link-health-editor.js', array( 'functionalities-link-health' ), FUNCTIONALITIES_VERSION . '-' . filemtime( FUNCTIONALITIES_DIR . 'assets/js/admin-link-health-editor.js' ), true ); \wp_localize_script( 'functionalities-link-health', 'functionalitiesLinkHealth', @@ -34,6 +43,15 @@ public static function enqueue(): void { 'resume' => \__( 'Resume scan', 'functionalities' ), 'connectionError' => \__( 'Live updates were interrupted. Reconnecting… Your background scan can continue.', 'functionalities' ), 'requestError' => \__( 'The request failed. Refresh this page and try again.', 'functionalities' ), + 'previewing' => \__( 'Preparing preview…', 'functionalities' ), + 'applying' => \__( 'Saving link changes…', 'functionalities' ), + /* translators: 1: matching link count, 2: source post title. */ + 'previewReplace' => \__( 'Replace %1$d matching link(s) in “%2$s”. Existing fragments are kept unless the new URL specifies one.', 'functionalities' ), + /* translators: 1: matching link count, 2: source post title. */ + 'previewUnlink' => \__( 'Unlink %1$d matching link(s) in “%2$s”, preserving their text and media.', 'functionalities' ), + /* translators: 1: changed link count, 2: source post title. */ + 'saved' => \__( 'Updated %1$d link(s) in “%2$s”.', 'functionalities' ), + 'rescan' => \__( 'Run a new scan to refresh this post’s report.', 'functionalities' ), ) ); } @@ -65,7 +83,7 @@ public static function ajax(): void { } $operation = \sanitize_key( \wp_unslash( $_POST['operation'] ?? 'status' ) ); $run = \sanitize_text_field( \wp_unslash( $_POST['run'] ?? '' ) ); - if ( ! in_array( $operation, array( 'status', 'start', 'batch', 'resume', 'stop', 'recheck', 'ignore' ), true ) ) { + if ( ! in_array( $operation, array( 'status', 'start', 'batch', 'resume', 'stop', 'recheck', 'ignore', 'preview_edit', 'apply_edit' ), true ) ) { \wp_send_json_error( array( 'message' => \__( 'Unknown action.', 'functionalities' ) ), 400 ); return; } @@ -94,18 +112,26 @@ public static function ajax(): void { case 'recheck': case 'ignore': $post_id = \absint( $_POST['post_id'] ?? 0 ); - $url = \sanitize_text_field( \wp_unslash( $_POST['url'] ?? '' ) ); + $url = self::request_url(); $result = 'recheck' === $operation ? Link_Health::recheck( $post_id, $url ) : Link_Health::set_ignored( $post_id, $url, true ); break; + case 'preview_edit': + $result = Link_Health_Editor::preview( \absint( $_POST['post_id'] ?? 0 ), self::request_url(), \sanitize_key( \wp_unslash( $_POST['edit_mode'] ?? '' ) ), self::request_url( 'replacement' ) ); + break; + case 'apply_edit': + $result = Link_Health_Editor::apply( \absint( $_POST['post_id'] ?? 0 ), \sanitize_text_field( \wp_unslash( $_POST['token'] ?? '' ) ) ); + break; } if ( \is_wp_error( $result ) && ! ( 'batch' === $operation && 'update_aborted' === $result->get_error_code() ) ) { \wp_send_json_error( array( 'message' => $result->get_error_message() ), 409 ); return; } $data = array( 'progress' => self::progress() ); + if ( in_array( $operation, array( 'preview_edit', 'apply_edit' ), true ) ) { + $data['edit'] = $result; } if ( ! empty( $_POST['report'] ) && \Functionalities\Core\Module_Registry::is_enabled( 'link-health' ) ) { ob_start(); - Module_Controller::render_link_health_report( max( 1, \absint( $_POST['report_page'] ?? 1 ) ) ); + Module_Controller::render_link_health_report( max( 1, \absint( $_POST['report_page'] ?? 1 ) ), Link_Health_Report::filters( \wp_unslash( $_POST ) ) ); $data['html'] = ob_get_clean(); } \wp_send_json_success( $data ); diff --git a/includes/admin/trait-admin-utilities-ui.php b/includes/admin/trait-admin-utilities-ui.php index 76f8c76..8f4ea3f 100644 --- a/includes/admin/trait-admin-utilities-ui.php +++ b/includes/admin/trait-admin-utilities-ui.php @@ -91,7 +91,7 @@ public static function handle_link_health_action(): void { \check_admin_referer( 'functionalities_link_health' ); $operation = \sanitize_key( \wp_unslash( $_POST['operation'] ?? '' ) ); $post_id = \absint( $_POST['post_id'] ?? 0 ); - $url = \sanitize_text_field( \wp_unslash( $_POST['url'] ?? '' ) ); + $url = Link_Health_Controller::request_url(); if ( ! \Functionalities\Core\Module_Registry::is_enabled( 'link-health' ) ) { \wp_die( \esc_html__( 'Enable Link Health before running an action.', 'functionalities' ), '', array( 'response' => 403 ) ); } @@ -153,8 +153,10 @@ private static function export_link_health(): void { $stream = fopen( 'php://output', 'w' ); fputcsv( $stream, array( 'Post ID', 'Title', 'URL', 'Status', 'HTTP', 'Checked UTC', 'Stale', 'Complete', 'Truncated' ), ',', '"', '' ); $page = 1; + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Caller already verifies the administrator and export nonce. + $filters = \Functionalities\Features\Link_Health_Report::filters( \wp_unslash( $_POST ) ); do { - $report = \Functionalities\Features\Link_Health::report_page( $page ); + $report = \Functionalities\Features\Link_Health::report_page( $page, $filters ); foreach ( $report['rows'] as $row ) { fputcsv( $stream, array( $row['post_id'], self::csv_safe( $row['title'] ), self::csv_safe( $row['url'] ), $row['status'], $row['code'], $row['checked'] ? gmdate( 'c', $row['checked'] ) : '', (int) $row['stale'], (int) $row['complete'], (int) $row['truncated'] ), ',', '"', '' ); } @@ -174,6 +176,14 @@ private static function link_health_button( string $operation, string $label, in + $value ) { + echo ''; + } + } + ?> '; echo '
Text
Other
New edit
'; + $this->assertInstanceOf( WP_Error::class, \Functionalities\Features\Link_Health_Editor::apply( 10, $preview['token'] ) ); + $this->assertStringContainsString( 'New edit', $post->post_content ); + } + public function test_compare_and_swap_rejects_a_save_after_preview_validation(): void { + $this->require_core(); + $post = $this->post( 10, 'Text' ); + $preview = \Functionalities\Features\Link_Health_Editor::preview( 10, 'https://example.test/old', 'unlink' ); + $GLOBALS['functionalities_test_concurrent_edit'] = 'Concurrent author save
'; + $this->assertInstanceOf( WP_Error::class, \Functionalities\Features\Link_Health_Editor::apply( 10, $preview['token'] ) ); + $this->assertSame( 'Concurrent author save
', $post->post_content ); + } + public function test_failed_source_write_preserves_content_and_explicit_fragment_replaces_old_fragment(): void { + $this->require_core(); + $post = $this->post( 10, 'Text' ); + $preview = \Functionalities\Features\Link_Health_Editor::preview( 10, 'https://example.test/old', 'replace', 'https://example.test/new#two' ); + $GLOBALS['functionalities_test_source_write_fail'] = true; + $this->assertInstanceOf( WP_Error::class, \Functionalities\Features\Link_Health_Editor::apply( 10, $preview['token'] ) ); + $this->assertSame( 'Text', $post->post_content ); + unset( $GLOBALS['functionalities_test_source_write_fail'] ); + $this->assertIsArray( \Functionalities\Features\Link_Health_Editor::apply( 10, $preview['token'] ) ); + $this->assertStringContainsString( '/new#two', $post->post_content ); + $this->assertStringNotContainsString( '#one', $post->post_content ); + } + public function test_url_inputs_and_search_preserve_percent_encoding(): void { + $this->require_core(); + $url = 'https://example.test/a%20b?q=%2F'; + $_POST['url'] = $url; + $this->assertSame( $url, \Functionalities\Admin\Link_Health_Controller::request_url() ); + unset( $_POST['url'] ); + $this->assertSame( 'a%20b', \Functionalities\Features\Link_Health_Report::filters( array( 'link_search' => 'a%20b' ) )['link_search'] ); + $change = \Functionalities\Features\Link_Health_Editor::transform( 'Text', 'https://example.test/', 'https://example.test/old', 'replace', $url ); + $this->assertStringContainsString( $url, $change['content'] ); + } + public function test_serialized_block_urls_require_the_post_editor_instead_of_invalidating_the_block(): void { + $this->require_core(); + $content = 'Card'; + foreach ( array( 'replace', 'unlink' ) as $mode ) { + $result = \Functionalities\Features\Link_Health_Editor::transform( $content, 'https://example.test/', 'https://example.test/old', $mode, 'https://example.test/new' ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'block_settings', $result->get_error_code() ); + } + $this->assertIsArray( \Functionalities\Features\Link_Health_Editor::transform( str_replace( 'https://example.test/old', 'https://example.test/unrelated', $content ), 'https://example.test/', 'https://example.test/old', 'replace', 'https://example.test/new' ) ); + } public function test_content_edit_during_request_prevents_stale_result_publication(): void { $this->require_core(); $this->post( 10, 'Old' ); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e2b2702..ed28abe 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -493,6 +493,7 @@ function has_blocks( $content ) { if ( ! function_exists( 'parse_blocks' ) ) { function parse_blocks( $content ) { + if ( class_exists( 'WP_Block_Parser' ) ) { return ( new WP_Block_Parser() )->parse( $content ); } throw new LogicException( 'Use a real WordPress block parser for block fixtures.' ); } } @@ -642,6 +643,9 @@ static function ( $item ) use ( $field ) { '/wp-includes/html-api/html5-named-character-references.php', '/wp-includes/html-api/class-wp-html-decoder.php', '/wp-includes/html-api/class-wp-html-tag-processor.php', + '/wp-includes/class-wp-block-parser-block.php', + '/wp-includes/class-wp-block-parser-frame.php', + '/wp-includes/class-wp-block-parser.php', ) as $functionalities_html_file ) { $functionalities_html_path = $functionalities_wp_dir . $functionalities_html_file; if ( file_exists( $functionalities_html_path ) ) { @@ -726,6 +730,21 @@ function get_the_title( $id ) { return $GLOBALS['functionalities_test_posts'][ $id ]->post_title ?? ''; } } +if ( ! function_exists( 'maybe_unserialize' ) ) { + function maybe_unserialize( $value ) { return is_string( $value ) && preg_match( '/^[abisOdN]:/', $value ) ? unserialize( $value, array( 'allowed_classes' => false ) ) : $value; } +} +if ( ! function_exists( 'wp_strip_all_tags' ) ) { + function wp_strip_all_tags( $value, $remove_breaks = false ) { return strip_tags( $value ); } +} +if ( ! function_exists( 'clean_post_cache' ) ) { + function clean_post_cache( $id ) {} +} +if ( ! function_exists( 'esc_url' ) ) { + function esc_url( $value, $protocols = null, $context = 'display' ) { return esc_attr( $value ); } +} +if ( ! function_exists( 'wp_save_post_revision' ) ) { + function wp_save_post_revision( $id ) { $GLOBALS['functionalities_test_revisions'][] = get_post( $id )->post_content; return 1; } +} if ( ! function_exists( 'wp_generate_uuid4' ) ) { function wp_generate_uuid4() { return bin2hex( random_bytes( 16 ) ); diff --git a/tests/link-health-editor.test.mjs b/tests/link-health-editor.test.mjs new file mode 100644 index 0000000..2b4dea8 --- /dev/null +++ b/tests/link-health-editor.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import vm from 'node:vm'; + +const script = readFileSync(new URL('../assets/js/admin-link-health-editor.js', import.meta.url), 'utf8'); +const flush = () => new Promise(resolve => setImmediate(resolve)); +function harness() { + const nodes = {}; + for (const name of ['form','mode','destination','new-url','feedback','preview','review','apply','cancel','source','url']) nodes[name] = {listeners: {}, disabled: name === 'apply', hidden: true, value: '', textContent: '', focus(){}, addEventListener(type, fn){this.listeners[type]=fn;}, reportValidity(){return true;}}; + const notice = {hidden: true}; + const root = {listeners: {}, events: [], querySelector(){return notice;}, addEventListener(type, fn){this.listeners[type]=fn;}, dispatchEvent(event){this.events.push(event.detail.open);}}; + const dialog = {open:false,listeners:{},closest(){return root;},querySelector(selector){return nodes[selector==='[data-link-edit-form]'?'form':selector.match(/data-edit-([a-z-]+)/)[1]];},addEventListener(type,fn){this.listeners[type]=fn;},showModal(){this.open=true;},close(){this.open=false;this.listeners.close();}}; + const requests=[]; + vm.runInNewContext(script,{document:{querySelector(){return dialog;}},AbortController,setTimeout,clearTimeout,CustomEvent:class{constructor(type,opts){this.detail=opts.detail;}},functionalitiesLinkHealth:{ajaxUrl:'/ajax',nonce:'nonce',previewing:'Previewing',applying:'Saving',previewReplace:'Replace %1$d links in %2$s',previewUnlink:'Unlink %1$d links in %2$s',saved:'Updated %1$d links in %2$s',rescan:'Rescan',requestError:'Failed'}, + FormData:class{constructor(){this.values={};}append(k,v){this.values[k]=v;}},fetch(url,options){return new Promise(resolve=>requests.push({fields:options.body.values,reply(edit,success=true){resolve({json:async()=>({success,data:success?{edit}:{message:'Conflict'}})});}}));}}); + const trigger={dataset:{postId:'10',url:'https://example.test/old',source:'Source',linkEdit:'replace'},isConnected:true,focus(){},closest(){return this;}}; + return {nodes,dialog,root,notice,requests,open(mode='replace'){trigger.dataset.linkEdit=mode;root.listeners.click({target:trigger});},async preview(){nodes.form.listeners.submit({preventDefault(){}});await flush();},async reply(index,success=true){requests[index].reply({token:'reviewed-token',post_id:10,count:2,title:'Source',operation:requests[index].fields.edit_mode||'replace',refreshed:true},success);await flush();}}; +} + +test('editing the destination invalidates a preview, including a late response',async()=>{ + const h=harness();h.open();h.nodes['new-url'].value='https://new.test/one';await h.preview(); + h.nodes['new-url'].value='https://new.test/two';h.nodes['new-url'].listeners.input(); + await h.reply(0);assert.equal(h.nodes.apply.disabled,true);assert.equal(h.nodes.preview.hidden,true); + await h.preview();await h.reply(1);assert.equal(h.nodes.apply.disabled,false); + h.nodes['new-url'].listeners.input();assert.equal(h.nodes.apply.disabled,true); +}); + +test('unlink previews require no destination and apply uses only the reviewed token',async()=>{ + const h=harness();h.open('unlink');assert.equal(h.nodes.destination.hidden,true);assert.equal(h.nodes['new-url'].required,false);assert.equal(h.nodes['new-url'].disabled,true); + await h.preview();await h.reply(0);assert.match(h.nodes.preview.textContent,/Unlink 2 links/); + h.nodes.apply.listeners.click();await flush(); + assert.equal(h.requests[1].fields.operation,'apply_edit');assert.equal(h.requests[1].fields.token,'reviewed-token');assert.equal(h.requests[1].fields.replacement,undefined); + await h.reply(1);assert.equal(h.dialog.open,false);assert.equal(h.notice.hidden,false);assert.deepEqual(h.root.events,[true,false]); +}); + +test('cancelled previews cannot enable Apply in a newly opened dialog and failed saves require a new preview',async()=>{ + const h=harness();h.open();await h.preview();h.dialog.close();h.open('unlink');await h.reply(0);assert.equal(h.nodes.apply.disabled,true); + await h.preview();await h.reply(1);h.nodes.apply.listeners.click();await flush();await h.reply(2,false); + assert.equal(h.nodes.apply.disabled,true);assert.equal(h.nodes.feedback.textContent,'Conflict');assert.equal(h.dialog.open,true); +}); + +test('preview titles are literal text even when they contain replacement-string syntax',async()=>{ + const h=harness();h.open('unlink');await h.preview(); + h.requests[0].reply({token:'token',post_id:10,count:1,title:'Literal $& title',operation:'unlink'});await flush(); + assert.equal(h.nodes.preview.textContent,'Unlink 1 links in Literal $& title'); +}); diff --git a/tests/link-health-live.test.mjs b/tests/link-health-live.test.mjs index 2580715..fb54e7f 100644 --- a/tests/link-health-live.test.mjs +++ b/tests/link-health-live.test.mjs @@ -6,7 +6,7 @@ import vm from 'node:vm'; const source = readFileSync(new URL('../assets/js/admin-link-health.js', import.meta.url), 'utf8'); const flush = () => new Promise(resolve => setImmediate(resolve)); const progress = (status = 'running', overrides = {}) => ({run: 'scan-a', status, phase: status === 'running' ? 'waiting' : status, label: status, summary: '2 of 10 posts · 8 links', posts: 2, total: 10, links: 8, percent: 20, ...overrides}); -function harness(initial = progress()) { +function harness(initial = progress(), filters = {}) { const nodes = {}; const forms = {}; for (const key of ['label', 'summary', 'meter', 'spinner', 'error']) nodes[key] = {hidden: true, textContent: '', classList: {active: false, toggle(name, value) { this.active = value; }}}; @@ -16,7 +16,7 @@ function harness(initial = progress()) { nodes[operation] = {hidden: false}; } const report = {innerHTML: '', querySelector() { return {dataset: {linkPage: '2'}}; }}; - const root = {listeners: {}, querySelector() { return report; }, querySelectorAll() { return Object.values(forms).map(f => f.button); }, addEventListener(type, fn) { this.listeners[type] = fn; }}; + const root = {listeners: {}, querySelector(selector) { return selector === '[data-link-filters]' ? {dataset: {filters: JSON.stringify(filters)}} : report; }, querySelectorAll() { return Object.values(forms).map(f => f.button); }, addEventListener(type, fn) { this.listeners[type] = fn; }}; const panel = {dataset: {progress: JSON.stringify(initial)}, closest() { return root; }, querySelector(selector) { const control = selector.match(/data-link-control="(\w+)"/); if (control) return selector.endsWith('button') ? forms[control[1]].button : nodes[control[1]]; @@ -30,7 +30,7 @@ function harness(initial = progress()) { setTimeout(fn, delay) { const id = ++timerId; timers.set(id, {fn, delay}); return id; }, clearTimeout(id) { timers.delete(id); }, fetch(url, options) { return new Promise((resolve, reject) => requests.push({fields: Object.fromEntries(options.body), reject, resolve(data, success = true, status = 200) { resolve({status, json: async () => ({success, data})}); }})); } }); - return {nodes, forms, report, requests, document, + return {nodes, forms, report, requests, document, root, async next() { const item = [...timers].find(([, value]) => value.delay < 25000); assert.ok(item, 'a progress timer must exist'); timers.delete(item[0]); item[1].fn(); await flush(); }, async reply(index, state = progress(), html = '