Skip to content

FEATURE: Synchronization - #102

Open
JamesAlias wants to merge 86 commits into
sitegeist:3.3from
sandstorm:3.3-merge-candidate
Open

FEATURE: Synchronization#102
JamesAlias wants to merge 86 commits into
sitegeist:3.3from
sandstorm:3.3-merge-candidate

Conversation

@JamesAlias

@JamesAlias JamesAlias commented Jul 28, 2026

Copy link
Copy Markdown

Overview

Rule-based, automatic reconciliation of translations across workspaces and/or dimensions, built on the stale-translation projection that came with Retranslation. Where the inspector's Retranslate button is a per-node, on-demand action, a synchronization rule keeps a target dimension continuously in step with its source: content published into live/en is translated into de-review/de, reviewed there, and published from there.

Configuration is based on #54 by @mficzel:

Sitegeist:
  LostInTranslation:
    nodeTranslation:
      synchronization:
        - sourceWorkspaceName: live      # a publish into this workspace triggers the rule
          sourceDimension: en            # must equal the target dimension preset's referenceLanguage
          targetWorkspaceName: de-review # translated variants are written here
          targetDimension: de
          scope: Document                # Document (documents + content) | Content (content only)
          mode: auto                     # auto (translate on publish) | ask (defer to a manual "sync now")

Rules are opt-in — the default is [], and with no rules configured nothing in this PR runs.

Under the Hood

The design rests on one core decision:

The target dimension is a projection of the source dimension. The source language is the single source
of truth for properties, structure and subtree tags. Target-side changes are overwritten when a
synchronization runs — that is intended, and nothing tries to preserve them.

A rule has only two knobs:

  • scope governs what synchronization may create, and nothing else. Content only fills in content below Documents that already exist in the target (adopting a Document into a language stays a deliberate manual act); Document also creates the missing Document variants.
  • mode governs when the translation runs, never what synchronization does. For the same source-side change both modes reach the same target state. Everything cheap runs inline on every publish under either mode — subtree tags, deletions, the cross-workspace rebase. Only translating, which costs a DeepL call per node, is deferred by ask.

Two consequences worth reviewing explicitly:

  • Deletion is tagging. Neos 9 soft-removes by applying the removed subtree tag, so deletions ride the tag mirror — and restoring from the trash bin mirrors back, which the old hard-removal mirror could never do. Any SubtreeTag is mirrored, not a fixed list; sync owns the target dimension's whole tag space.
  • Tags sync as state, properties sync as deltas. Tags are diffed and target-side changes reverted; properties are stale-driven, so a target-side edit survives until the source changes that property. Both obey the axiom — they differ in how aggressively they reassert it, because reasserting a property costs a DeepL call and reasserting a tag costs nothing.

Entry points

Driver Trigger Coverage
SynchronizationCommandHook a publish into a rule's source workspace the publish delta only — incremental, no tree walk
WorkspaceSynchronizer "Sync now" / "Sync all" in the backend module, post-publish prompt in the Neos UI, lostintranslation:synchronize every stale record in the target dimension
FullWorkspaceSynchronizer lostintranslation:synchronize --full the whole source subtree, stale or not
  • Backend module gains a Synchronization overview: every rule with its pending count, or why it cannot
    run at all (target workspace missing — rules never create it — or target not based on the source workspace,
    which a cross-workspace rule requires). A blocked rule reports no pending count, because nothing would act
    on it. A fresh install without ./flow cr:setup gets a guidance banner instead of an error.
  • Neos UI raises a post-publish prompt for mode: ask rules ("translations are out of date — synchronize
    now?"). auto rules need none.
  • CLI: lostintranslation:synchronize (--full, --dry-run, --skip-existing),
    lostintranslation:retranslate-node, lostintranslation:reconcile (prunes orphaned stale rows left by
    descendants of deleted aggregates).

Notes

  • uriPathSegment slug coercion moved out of two hardcoded if ($name === 'uriPathSegment') sites into a
    per-property TranslatedPropertyPostProcessorInterface (options.translationPostProcessor, read parallel to
    options.automaticTranslation). UriPathSegmentPostProcessor is registered on
    Neos.Neos:Document.uriPathSegment but inactive unless the integrator also enables automaticTranslation
    there.
  • A missing language dimension no longer bricks the Content Repository when the package is disabled. Both
    command hook factories return a no-op DisabledCommandHook when the feature is off; they still throw when it
    is on, because a missing dimension under an enabled package is a real misconfiguration.
  • AI attribution/authorization state no longer leaks past a throwing cascade onto unrelated commands
    (SynchronizationCommandHook matches on set membership, and clears the tracker at the start of every publish).
  • --dry-run is honest now. It previews variants, property updates, removals and tag changes without
    dispatching anything, without rebasing the target, and — the part that was actually broken — without paying
    the full DeepL bill to produce a report of zeros.
  • Docs: Documentation/RetranslationAndSynchronization.md (811 lines) covers the projection, both features,
    all workflows, cross-workspace mechanics, superseded decisions and known gaps. README.md gains an
    integrate-into-an-existing-project section and per-command reference. TESTING.md documents the harness.
  • Test harness & CI: a self-contained Docker Behat setup (make e2e, ~9s for the full suite) plus a Behat
    job in the GitHub workflow across PHP 8.2–8.4 × Neos 9.0–9.1; the build now also runs on 3.* branches.

JamesAlias added 30 commits May 26, 2026 14:10
Resolves conflicts after the branch's base (retranslationUpmerge, PR sitegeist#95) was
merged into 3.x and released as v3.3, and 3.3 then gained independent
retranslation work (notably whole-workspace retranslation).

Key resolutions:
- Retranslator: keep this branch's cross-workspace per-node engine, renamed
  retranslateNode -> retranslateSubtree to match released v3.3; keep 3.3's
  retranslateWorkspace() + helpers (both whole-workspace paths kept,
  alongside FullWorkspaceSynchronizer). Callers renamed accordingly.
- ReferenceDimensionSpacePointResolver: adopt 3.3's options.referenceLanguage
  config key; keep this branch's findAllTargetDimensionSpacePoints() API.
  All dimension-config fixtures/settings updated to options.referenceLanguage.
- StaleTranslationProjection: keep this branch's handlers (empty structural
  rows retained); keep 3.3's automaticTranslation guard where auto-merged.
- Neos.Ui: take 3.3's contentRepositoryId + isPending; Plugin.js rebuilt.
- CI/tooling: take 3.3's phpcs.xml.dist + phpstan scanDirectories; keep this
  branch's Behat job.
- Behat: keep this branch's suites; 3.3's workspace-retranslation scenario
  preserved in a new standalone WorkspaceRetranslation.feature.
…e with composer stability exception when installing neos/builtessentials
…uth) leak; Remove retranslateWorkpace - keep workspace synchronization; Fix unwanted workspace rebasing
@JamesAlias
JamesAlias marked this pull request as ready for review July 30, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant