- New extension fe_comment (Evomedien, framework-agnostic): floating page

feedback widget served from /api/fe-comment/widget.js, note pad in
  localStorage, submissions mailed via /api/fe-comment/submit with
  recipients from the extension configuration; classic sites get the
  script injected via AssetCollector when the jsonOutput setting is off.
  An enabled master switch serves the widget EMPTY when off (no 404, no
  console errors) and rejects submissions - the go-live kill switch, no
  front-end build change needed
- URL swap finished: /_json prefix middleware, SPA fallback live with a
  dedicated root rule (REQUEST_FILENAME of / is a directory) and a
  query-string exception for the TYPO3 v14 install tool entry
  (?__typo3_install on the site root - the backend Settings/Environment
  modules depend on it); /api/ as a whole stays with TYPO3
- MailMessage::send() no longer exists in v14: fe_comment and the form
  EmailDelivery (latently broken since the core upgrade) now send through
  the Mailer service. Mail transport moved to SMTP against the local exim
  so delivery errors surface instead of bouncing into the blackhole -
  exim verifies the envelope sender, which therefore must be a routable
  existing address (transport config lives in the untracked settings.php)
- Related-product card texts editable on the product record (table rebuilt
  with uid/pid, TCA + inline field, one-time migration script); shared
  RteCompare for the module badge and the CLI comparison - batch dry-run
  now reports 0 writes instead of flagging every richtext field
- Workbook clarification cases executed: VisionIO-SD14, VisionSC-SD14 and
  both Diamond OG records imported (40 fields, live-verified); ArtioSign
  and ArtioView created hidden (uid 109/110) via
  create_artio_records.php - the product table has no sorting column;
  WallControl_10 mapping REVERTED and parked: the dry run revealed
  aetria-wallcontrol already carries its own agency workbook, which
  WallControl_10 would have overwritten. Manifest now 75 product /
  24 family / 1 open
This commit is contained in:
orasche committed 2026-09-30 13:59:55 +02:00
1 parent 6db2234851
commit f0b0694842
28 files changed
+1272 -54

No files matched your search

@@ -154,19 +154,14 @@ final class ImportProductWorkbookCommand extends Command
}
/**
* Compare tolerant of the RTE storage transform, which wraps bare
* content in <p>...</p> and strips newlines on save - without this every
* re-run reported already-imported richtext fields as WRITE (the known
* false-positive from the module's preview).
* Compare tolerant of the RTE storage transform - shared with the
* module's "differs" badge, see RteCompare. The local version missed
* inter-tag whitespace ("</p> <ul>" vs "</p><ul>") and entity
* re-encoding, which made the g: fields report WRITE on every run.
*/
private function isUnchanged(string $old, string $new): bool
{
$normalize = static fn(string $s): string => trim((string)preg_replace('/\s+/u', ' ', $s));
if ($normalize($old) === $normalize($new)) {
return true;
}
return $normalize($old) === $normalize('<p>' . $new . '</p>');
return \Evomedien\Vitec\Service\RteCompare::equal($old, $new);
}
/** @return array<string,mixed>|null */
@@ -530,7 +530,10 @@ final class ProductTextImportController
$selector = $submittedMap[$field] ?? $saved[$field] ?? self::DEFAULT_MAPPING[$field] ?? '';
$old = (string)($product[$field] ?? '');
$new = ($parsed !== null && $selector !== '') ? $this->reader->valueFor($parsed, $selector) : null;
$changed = $new !== null && trim($new) !== trim($old);
// RTE-tolerant, shared with the CLI - a plain trim comparison
// flagged "differs" on pure storage-transform noise (inter-tag
// whitespace, <p> wrap, entity re-encoding).
$changed = $new !== null && !\Evomedien\Vitec\Service\RteCompare::equal($old, $new);
$rows[] = [
'field' => $field,
'label' => $label,
@@ -720,9 +723,25 @@ final class ProductTextImportController
$keys = ['product_uid' => $productUid, 'related_uid' => $relatedUid];
if ($connection->count('product_uid', 'tx_vitec_product_related_text', $keys) > 0) {
$connection->update('tx_vitec_product_related_text', ['cardtext' => $text, 'tstamp' => time()], $keys);
} else {
$connection->insert('tx_vitec_product_related_text', $keys + ['cardtext' => $text, 'tstamp' => time()]);
return;
}
// Since the table became TCA-visible (inline on the product,
// 2026-09-28) new rows need pid, crdate and the pair's MM sorting -
// the relation is written right before this call, so it exists.
$pid = (int)$connection->fetchOne(
'SELECT pid FROM ' . self::TABLE . ' WHERE uid = ?', [$productUid]
);
$sorting = (int)$connection->fetchOne(
'SELECT sorting FROM tx_vitec_product_related_mm WHERE uid_local = ? AND uid_foreign = ?',
[$productUid, $relatedUid]
);
$connection->insert('tx_vitec_product_related_text', $keys + [
'cardtext' => $text,
'tstamp' => time(),
'crdate' => time(),
'pid' => $pid,
'sorting' => $sorting,
]);
}
private function preview(string $value): string
@@ -41,8 +41,10 @@ final class EmailDelivery implements DeliveryInterface
$mail->replyTo($submitterEmail);
}
if (!$mail->send()) {
throw new \RuntimeException('MailMessage::send() reported failure.');
}
// v14: MailMessage::send() was removed - sending goes through the
// Mailer service now (failures throw). Found 2026-09-28 via the
// identical call in EXT:fe_comment: this delivery was latently
// broken since the core upgrade.
GeneralUtility::makeInstance(\TYPO3\CMS\Core\Mail\Mailer::class)->send($mail);
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* /_json/<path> serves the TYPO3 (headless JSON) response for <path>.
*
* Part of the URL swap requested 2026-09-28: page URLs are for visitors
* (the React app - the .htaccess SPA fallback of phase B), the JSON moves
* under the /_json prefix. Apache cannot do the strip itself in a useful
* way - TYPO3 routes by the original REQUEST_URI - so the prefix is
* removed here, before site/page resolution; everything downstream sees
* the plain path and behaves exactly as a direct request would. Same
* pattern as SuccessStoryPathRewrite. The query string is untouched, so
* /_json/?type=834 and the suggest page type work as before.
*
* Runs before the other vitec path middlewares: a front end that prefixes
* ALL its calls may also send /_json/download/... or
* /_json/api/vitec/form/... - after the strip those reach their middleware
* unchanged, so both the prefixed and the direct form keep working.
*
* Exception-safe: any failure leaves the request untouched.
*/
final class JsonPrefixRewrite implements MiddlewareInterface
{
private const PREFIX = '/_json';
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$path = $request->getUri()->getPath();
if ($path === self::PREFIX || str_starts_with($path, self::PREFIX . '/')) {
$stripped = substr($path, strlen(self::PREFIX));
$request = $request->withUri(
$request->getUri()->withPath($stripped === '' || $stripped === '/' ? '/' : $stripped)
);
}
} catch (\Throwable) {
// untouched request
}
return $handler->handle($request);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
/**
* Equality check tolerant of TYPO3's RTE storage transform - THE shared
* comparator for "does the workbook differ from the database?", used by the
* import module's "differs" badge and the CLI's write/unchanged decision.
* One implementation on purpose: the two used to disagree, and the stricter
* one cried wolf on every richtext field.
*
* What the transform changes on save, and what is neutralized here:
* - bare content gets wrapped in <p>...</p> -> wrap simulation
* - newlines are stripped, whitespace collapses -> \s+ collapse
* - whitespace BETWEEN tags is dropped
* ("</p> <ul>" becomes "</p><ul>") -> >\s+< join
* - entities may be re-encoded ("&times;" vs "×") -> entity decode
*
* Anything beyond that (real markup changes) still counts as different.
* The Phase-2 change watch compares workbook against imported_values instead
* of the database precisely because storage transforms exist - this class
* only keeps the LIVE comparison honest.
*/
final class RteCompare
{
public static function equal(string $stored, string $incoming): bool
{
$storedNorm = self::normalize($stored);
if ($storedNorm === self::normalize($incoming)) {
return true;
}
return $storedNorm === self::normalize('<p>' . $incoming . '</p>');
}
private static function normalize(string $html): string
{
$s = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$s = (string)preg_replace('/\s+/u', ' ', $s);
$s = (string)preg_replace('/>\s+</', '><', $s);
return trim($s);
}
}
@@ -7,6 +7,18 @@ declare(strict_types=1);
*/
return [
'frontend' => [
'vitec/json-prefix-rewrite' => [
'target' => \Evomedien\Vitec\Middleware\JsonPrefixRewrite::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
'before' => [
'typo3/cms-frontend/site',
'vitec/form-submission',
'vitec/success-story-path-rewrite',
'vitec/download-file',
],
],
'vitec/form-submission' => [
'target' => \Evomedien\Vitec\Middleware\FormSubmissionMiddleware::class,
'after' => [
@@ -23,7 +23,7 @@ return [
],
],
'types' => [
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, solutions,
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, related_texts, solutions,
--div--;Resources, resources, datasheets, downloads,
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
@@ -433,6 +433,23 @@ return [
'enableMultiSelectFilterTextfield' => true, // Optional: Adds a search box for filtering
],
],
'related_texts' => [
'exclude' => true,
'label' => 'Related product card texts',
'description' => 'Card copy per related product (workbook column E). A text is shown only while the same product is also selected under "Related Products" above; extra rows stay dormant, they are not lost.',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_vitec_product_related_text',
'foreign_field' => 'product_uid',
'foreign_sortby' => 'sorting',
'appearance' => [
'collapseAll' => true,
'useSortable' => true,
'expandSingle' => true,
'newRecordLinkTitle' => 'Add card text',
],
],
],
'solutions' => [
'exclude' => true,
'label' => 'Related Solutions',
@@ -0,0 +1,63 @@
<?php
/**
* Per-pair card copy for the related-product cards (workbook column E).
*
* Storage predates this TCA: the import module has been writing these rows
* since 2026-08-21, keyed (product_uid, related_uid), precisely because an MM
* column would be lost - DataHandler rewrites the MM rows on every product
* save. This file only makes the same rows editable, as an inline list on
* the product's General tab (field `related_texts`).
*
* A row whose related product is NOT selected in `relatedprodukt` stays
* dormant: the JSON join is MM-driven, so the text simply does not appear
* until the relation (re)appears. Deliberate - texts survive relation
* round-trips, matching the table's original purpose.
*/
return [
'ctrl' => [
'title' => 'Related product card text',
'label' => 'cardtext',
'label_alt' => 'related_uid',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'sortby' => 'sorting',
'hideTable' => true,
'iconfile' => 'EXT:vitec/Resources/Public/Icons/Extension.svg',
'security' => [
'ignorePageTypeRestriction' => true,
],
],
'types' => [
'1' => ['showitem' => 'related_uid, cardtext'],
],
'columns' => [
'product_uid' => [
'config' => [
'type' => 'passthrough',
],
],
'related_uid' => [
'label' => 'Related product this text belongs to',
'description' => 'The text is shown on the card of this product. It only reaches the JSON while the same product is also selected under "Related products" above.',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'foreign_table' => 'tx_vitec_domain_model_product',
'sortItems' => ['label' => 'asc'],
'items' => [
['label' => '— choose product —', 'value' => 0],
],
'default' => 0,
],
],
'cardtext' => [
'label' => 'Card text',
'description' => 'Card copy from the workbook (Related Product Card, column E). Plain text, no CTA label - the front end appends its own "Learn more".',
'config' => [
'type' => 'text',
'rows' => 3,
],
],
],
];
+14 -1
View File
@@ -58,6 +58,7 @@ CREATE TABLE tx_vitec_domain_model_product (
capabilities text,
portfolio varchar(1024) DEFAULT '' NOT NULL,
textrelatedproducts text,
related_texts int(11) DEFAULT '0' NOT NULL,
solutions int(11) DEFAULT '0' NOT NULL,
resources int(11) DEFAULT '0' NOT NULL,
datasheets int(11) DEFAULT '0' NOT NULL,
@@ -377,12 +378,24 @@ CREATE TABLE tx_vitec_seo_research (
-- (relation later removed) are simply never joined. No TCA on purpose -
-- written by the product-text import module, read by ProductShowJsonRenderer.
--
#
# Since 2026-09-28 a real TCA table (inline on the product, field
# related_texts). Existing installations MUST run
# migrations/rebuild_related_text_table.php BEFORE extension:setup -
# MySQL cannot grow the auto_increment uid onto the old composite-key
# table through the schema compare.
#
CREATE TABLE tx_vitec_product_related_text (
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
product_uid int(11) DEFAULT '0' NOT NULL,
related_uid int(11) DEFAULT '0' NOT NULL,
sorting int(11) DEFAULT '0' NOT NULL,
cardtext text,
tstamp int(11) DEFAULT '0' NOT NULL,
PRIMARY KEY (product_uid, related_uid)
crdate int(11) DEFAULT '0' NOT NULL,
PRIMARY KEY (uid),
UNIQUE KEY pair (product_uid, related_uid)
);
--