Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions assets/css/admin-polish.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
118 changes: 118 additions & 0 deletions assets/js/admin-link-health-editor.js
Original file line number Diff line number Diff line change
@@ -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();
});
})();
14 changes: 12 additions & 2 deletions assets/js/admin-link-health.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
Expand All @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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);
})();
3 changes: 3 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions docs/link-health-edit-verification.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/plans/2026-10-02-link-health-filters-editor.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading