fix: decode HTML entities in event ingestion text before storage - #845
Merged
Merged
Conversation
Event ingestion stored HTML-entity-encoded source text verbatim in post_title (5,392 published rows with &, 180 with ’, 126 with – on production), pushing entities into JSON-LD, REST/JSON consumers, search, and every plain-text surface. Root cause is a pipeline, not an extractor: source feeds deliver entity-encoded titles and the shared upsert path never decodes, AND the kses save filters re-encode any bare '&' back to '&' for writing contexts without unfiltered_html (multisite authors, wp-cron with no user). Decoding alone would have been undone on the way into the database. - TextNormalization: decode/detect helpers plus a bounded kses suspension for ingestion writes (restore-exact-state via kses_init). - EventUpsert: title decoded before sanitization at the single shared extraction boundary, before validation, lock keys, dedup hashing, and buildEventData; performer/artist and free-text event fields decoded; the persistence write runs with kses suspended. - VenueParameterProvider::resolveField decodes venue name/geography; Promoter_Taxonomy decodes organizer names; EventBlockContentBuilder decodes descriptions before wp_kses_post. - check quality gains entity_title rule; wp data-machine-events repair-entity-titles backfills stored rows (dry run by default, --execute applies) and verifies title-derived dedup hashes are unchanged rather than blindly rewriting them. Dedup identity keys already canonicalize: computeTitleHash() and source identity hash through normalizeBasic(), which entity-decodes before hashing, so existing datamachine_post_identity.title_hash rows and processed-item claims remain valid after repair. Closes #844
Contributor
Homeboy Results —
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #844
Summary
Event ingestion stored HTML-entity-encoded source text verbatim in
post_title: 5,392 published rows carry&/&, 180 carry’, 126 carry–(verified at the byte level in the issue). Entities leak into JSON-LD (Extra-Chill/extrachill-seo#61), REST/JSON consumers, search, and every plain-text surface.This closes both halves of the pipeline:
EventUpsert::executeUpsert()— the single funnel every fetch handler passes through — now runshtml_entity_decode( , ENT_QUOTES | ENT_HTML5, 'UTF-8' )beforesanitize_text_field(), and writes the decoded title back onto `` so every downstream consumer (validation gate, advisory-lock keys, duplicate detection,buildEventData) keys on the same canonical form. Decode-then-sanitize ordering means markup hidden behind entities (`<script>`) is exposed and stripped, not smuggled into storage. Decode, never strip.wp_filter_ksesontitle_save_prere-encodes every bare&to&whenever the writing context lacksunfiltered_html. On this multisite the ingestion author (user 32,author/extra_chill_team) has nounfiltered_html, and wp-cron runs with no user at all. Proven in production:wp_kses( 'Power Pilates & Matcha', … )→'Power Pilates & Matcha'.TextNormalization::with_kses_suspended()wraps the bounded persistence write and restores the exact prior filter state viakses_init(). Ingestion titles are already tag-free (sanitize_text_fieldruns first), so this removes a re-encoding side effect, not a sanitization layer.Same treatment applied to the other affected free-text fields (all measured in production):
post_titleEventUpserttitle extractionVenueParameterProvider::resolveField()(single venue-field resolution point: locks, dedup, term creation, block attrs)EventUpsertEventBlockContentBuilder(decode beforewp_kses_post) +buildEventDatafield allowlistPromoter_Taxonomy::find_or_create_promoter()URLs, dates, enums, coordinates, and timezone identifiers are deliberately not decoded (
is_decodable_text_field()allowlist).Which shared code path was actually at fault, and how it was proved
Not one bad extractor — a two-stage pipeline on the shared path. Evidence chain:
link.dice.fm— Ticketmaster) and some scrapers do not; that inconsistency produced the scatter. The shared upsert boundary is the correct backstop regardless of source.wp_kses( 'Move Wellness - Power Pilates & Matcha — A Night of R&B', array() )returns'Move Wellness - Power Pilates & Matcha — A Night of R&B'. The DB shows exactly this signature —&(kses re-encoded) dominating, plus raw’/–rows that bypassed kses (CLI-context saves; 8,097 published titles with bare&prove such contexts exist).unfiltered_html(multisite:has_cap → 0, not a super admin), so every web/cron-context ingestion save re-encoded.Dedup/identity hashes: verified unchanged, not assumed
The prompt asked whether existing hashes are invalidated. They are not, and this is proved at the byte level on production:
EventDuplicateStrategy::computeTitleHash()=md5( EventIdentifierGenerator::normalizeBasic( ) ), andnormalizeBasic()already entity-decodes before hashing (EventIdentifierGenerator.php:406). Thereforehash(encoded title) === hash(decoded title).datamachine_post_identity.title_hashequals the hash of the decoded title (5/5 sampled rows match, e.g. post 222428)._datamachine_event_source_identity/ DM processed-item claims) hashes throughEventIdentifierGenerator::generate()→ samenormalizeBasic()→generate(encoded) === generate(decoded)verified in production.So the repair backfill verifies hash stability per-row instead of rewriting hashes:
EntityTitleRepairAbilitiescomparescomputeTitleHash(old)vscomputeTitleHash(new)and the stored index row (DM corePostIdentityIndexwhen available), counts any drift inidentity_hash_drift, and reports it loudly rather than silently assuming.One related caveat worth knowing: DM core's
upsert-postno-change path compares only the block-content hash, so re-ingesting an entity-titled event with identical content returnsno_changeand does not heal the stored title. Re-ingestion alone was never going to fix the existing rows — the backfill command is genuinely required, which is why it ships in this PR.What this adds
TextNormalization(inc/Core/TextNormalization.php) — the ingestion text storage contract:decode_entities(),contains_entities()(sharedENTITY_PATTERN),with_kses_suspended().EventUpsert; venue name + geography inVenueParameterProvider::resolveField(); organizer inPromoter_Taxonomy; description inEventBlockContentBuilder; free-text fields inbuildEventDatabehind a decodable-field allowlist.check qualitygainsentity_title(--issue entity_titlefilter): published events whose stored title matches the entity pattern, listed with flow attribution. Flows intocheck allautomatically.wp data-machine-events repair-entity-titles(thin CLI adapter over thedata-machine-events/repair-entity-titlesability, mirroringrepair-series-ends). Dry run by default;--executeapplies. Repair writes run under the same kses suspension; decoded rows are left alone; per-rowhash_stableis reported. No production data was mass-mutated by this PR — the command ships, it has not been run.Verification
TextNormalizationTest— named/decimal/hex/quote entity decoding, idempotence, no-strip guarantee, detection (bare&is not an entity), kses suspension removes/restores filters, survives a throwing callback, no-op when inactive.EventUpsertEntityDecodeTest— end-to-end throughexecuteUpsert:&/&/’/–/"titles stored decoded; entity-hidden markup stripped; real markup sanitized away; a decoded re-ingestion resolves to the existing entity-titled event (no duplicate); and withwp_filter_kseshooked (simulated no-unfiltered_htmlcontext) the stored title still comes out decoded.EventQualityAuditEntityTitleTest— the new audit rule flags named and numeric entity titles, skips decoded titles and bare&.EntityTitleRepairAbilitiesTest— dry run is a no-op; execute decodes stored titles (named, numeric, curly quotes, dashes) without re-encoding even withwp_filter_ksesactive; decoded rows untouched; per-row hash stability reported.origin/mainwith the identical harness — failure sets are byte-identical (71 pre-existing environment failures each: retained-venue lifecycle tests, VenueMergeHelper merge-command tests, multisite/lifecycle assumptions of the sandbox). Zero new failures.php -l.wp --url=events.extrachill.com data-machine-events repair-entity-titles(dry run) then--execute.Notes for review
post_contentrows and the 157 venue / ~2.3k artist entity-bearing term names are measured but deliberately not backfilled here — term repair needs merge semantics (two terms for the same venue, one encoded) and belongs with the VenueMergeHelper tooling; happy to file follow-ups. This PR closes the ingestion path (primary deliverable per the issue) and repairs the title rows the issue quantifies.post_nameslugs are not entity-contaminated (sanitize_title()already strips entities — verified on production rows and directly), so the backfill does not touch slugs and no URLs change.EncodingFixrepair: agreed, and this is why the fix had to include the ingestion path — without it this class recurs a third time.datamachine/upsert-postexecution inside the upsert lock, and the repair command'swp_update_post. Manual editorial saves keep WordPress-standard behavior.This PR was authored by an AI coding agent (Extra Chill Bot) and reviewed by nobody yet.