From 089bc2406fc98af4ba8e3de182c7c0f3c8f06521 Mon Sep 17 00:00:00 2001
From: DCCONSTRUCTIONS
Date: Thu, 23 Jul 2026 13:47:57 +0300
Subject: [PATCH] Generalize semantic block editing across page workspaces
---
seo_mode/seo_mode/app/src/App.tsx | 1818 ++++++++++++++---
seo_mode/seo_mode/app/src/api.ts | 5 +
seo_mode/seo_mode/app/src/styles.css | 421 +++-
.../seo_mode/server/src/preview/routes.ts | 1600 +++++++++++++--
.../server/src/preview/semanticBlockRules.ts | 19 +-
.../seo_mode/server/src/projects/routes.ts | 5 +
.../server/src/rewrite/rewriteDiff.ts | 23 +-
.../scripts/checkSemanticBlockUniversality.ts | 275 ++-
.../server/src/workspace/pageWorkspace.ts | 222 +-
.../server/src/workspace/previewTargets.ts | 331 ++-
.../server/src/workspace/semanticBlocks.ts | 76 +-
11 files changed, 4229 insertions(+), 566 deletions(-)
diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx
index f74aca4..9b797a3 100644
--- a/seo_mode/seo_mode/app/src/App.tsx
+++ b/seo_mode/seo_mode/app/src/App.tsx
@@ -18,6 +18,7 @@ import {
Loader2,
Maximize2,
Minimize2,
+ MousePointer2,
Pencil,
Plug,
Plus,
@@ -7076,6 +7077,7 @@ function getPipelineStageAccess(
hasProject: boolean;
keywordMap: KeywordMapContract | null;
marketEnrichment: MarketEnrichmentContract | null;
+ rewritePlan: RewritePlanContract | null;
scan: ProjectScanSummary | null | undefined;
semanticAnalysis: SemanticAnalysisRun | null;
seoStrategy: SeoStrategyContract | null;
@@ -7088,6 +7090,7 @@ function getPipelineStageAccess(
const marketReady = Boolean(input.marketEnrichment && input.marketEnrichment.state !== "not_ready");
const keywordReady = Boolean(input.keywordMap && input.keywordMap.readiness.mappedItemCount > 0);
const strategyReady = Boolean(input.seoStrategy && input.seoStrategy.state !== "blocked");
+ const planReady = strategyReady || Boolean(input.rewritePlan);
const synthesisReady = strategyReady && input.strategySynthesis?.state === "ready";
const qualityReady = synthesisReady && input.strategyQualityReview?.state === "ready";
@@ -7145,9 +7148,9 @@ function getPipelineStageAccess(
if (stageIndex === 6) {
return {
- reason: strategyReady ? "Стратегия посадочных собрана, можно смотреть план правок." : "План правок откроется после формирования стратегии.",
- status: strategyReady ? "ready" : "locked",
- unlocked: strategyReady
+ reason: planReady ? "Стратегия посадочных собрана, можно смотреть план правок." : "План правок откроется после формирования стратегии.",
+ status: planReady ? "ready" : "locked",
+ unlocked: planReady
};
}
@@ -8307,9 +8310,10 @@ function getWorkspaceTargetPreviewNeedles(target: WorkspaceTarget) {
function buildVisualComposerGroupText(
groupId: string,
targets: WorkspaceTarget[],
- sectionTextMap: WorkspaceSectionTextMap | undefined
+ sectionTextMap: WorkspaceSectionTextMap | undefined,
+ semanticGroupContextTexts: Record | undefined
) {
- return targets
+ const boundText = targets
.filter((target) => target.semanticGroup?.id === groupId && target.permission === "editable")
.sort((left, right) => (left.semanticGroup?.fieldOrder ?? left.number) - (right.semanticGroup?.fieldOrder ?? right.number))
.map((target) => {
@@ -8319,6 +8323,13 @@ function buildVisualComposerGroupText(
})
.filter(Boolean)
.join("\n");
+ const contextText = (semanticGroupContextTexts?.[groupId] ?? [])
+ .map((value) => value.replace(/\s+/g, " ").trim())
+ .filter(Boolean)
+ .map((value) => `context: ${value}`)
+ .join("\n");
+
+ return [boundText, contextText].filter(Boolean).join("\n");
}
function buildVisualComposerPageContextText(targets: WorkspaceTarget[], sectionTextMap: WorkspaceSectionTextMap | undefined) {
@@ -8356,7 +8367,8 @@ function buildVisualComposerSeedInputs(
pageId: string | null,
pageTitle: string | null,
targets: WorkspaceTarget[],
- sectionTextMap: WorkspaceSectionTextMap | undefined
+ sectionTextMap: WorkspaceSectionTextMap | undefined,
+ semanticGroupContextTexts?: Record
): VisualComposerSeedInput[] {
if (!pageId) {
return [];
@@ -8382,7 +8394,7 @@ function buildVisualComposerSeedInputs(
fieldCount: target.semanticGroup.fieldCount,
fieldOrder: target.semanticGroup.fieldOrder,
fieldRole: target.semanticGroup.fieldRole,
- groupText: buildVisualComposerGroupText(target.semanticGroup.id, targets, sectionTextMap),
+ groupText: buildVisualComposerGroupText(target.semanticGroup.id, targets, sectionTextMap, semanticGroupContextTexts),
id: target.semanticGroup.id,
source: target.semanticGroup.source,
title: target.semanticGroup.title
@@ -8921,7 +8933,7 @@ function getSectionWorkspaceScopeItem(
section: ProjectScanSummary["siteSections"][number],
fallbackOrder: number
): WorkspaceScopeItem | null {
- if (!section.pageId || !section.enabled || !section.selected) {
+ if (!section.pageId || !section.enabled) {
return null;
}
@@ -8952,61 +8964,133 @@ function getSectionWorkspaceScopeItem(
};
}
-function isSafeWorkspaceDomIdFragment(value: string | null | undefined) {
- return Boolean(value && /^[a-zA-Z][\w:-]*$/.test(value));
-}
-
-function getWorkspaceScopedAnchorFallbackSelector(scopeItem: WorkspaceScopeItem | null | undefined) {
- if (
- !scopeItem ||
- scopeItem.kind !== "section" ||
- (scopeItem.previewTargetIndex ?? 0) <= 0 ||
- !scopeItem.sectionId ||
- !scopeItem.previewTargetSelector?.startsWith("#")
- ) {
- return null;
- }
-
- const anchorId = scopeItem.previewTargetSelector.slice(1);
-
- if (!isSafeWorkspaceDomIdFragment(scopeItem.sectionId) || !isSafeWorkspaceDomIdFragment(anchorId)) {
- return null;
- }
-
- return `#${scopeItem.sectionId}-${anchorId}`;
-}
-
function getWorkspaceScopePreviewFocusSelector(scopeItem: WorkspaceScopeItem | null | undefined) {
- return getWorkspaceScopedAnchorFallbackSelector(scopeItem) ?? scopeItem?.previewTargetSelector ?? null;
+ return scopeItem?.previewTargetSelector ?? null;
}
function getWorkspaceScopePreviewFocusIndex(scopeItem: WorkspaceScopeItem | null | undefined) {
- return getWorkspaceScopedAnchorFallbackSelector(scopeItem) ? 0 : scopeItem?.previewTargetIndex ?? null;
+ return scopeItem?.previewTargetIndex ?? null;
}
function normalizeWorkspacePreviewSourceKey(value: string | null | undefined) {
return (value ?? "")
.replace(/\\/g, "/")
- .replace(/^\/+/, "")
- .toLocaleLowerCase("ru-RU");
+ .replace(/^\/+/, "");
}
function getWorkspaceScopePreviewSourceKey(scopeItem: WorkspaceScopeItem | null | undefined) {
return normalizeWorkspacePreviewSourceKey(scopeItem?.previewSourcePath ?? scopeItem?.sourcePath);
}
+type WorkspaceSectionHostRef = {
+ pageId: string | null;
+ previewSourcePath: string | null;
+ sourcePath: string;
+};
+
+function getWorkspaceSectionHostPage(scan: ProjectScanSummary, section: WorkspaceSectionHostRef) {
+ const exactPage = section.pageId
+ ? scan.pages.find(
+ (page) =>
+ page.pageId === section.pageId &&
+ page.scope !== "template_block" &&
+ isWorkspaceScopePage(page)
+ ) ?? null
+ : null;
+
+ if (exactPage) {
+ return exactPage;
+ }
+
+ const previewSourceKey = normalizeWorkspacePreviewSourceKey(section.previewSourcePath ?? section.sourcePath);
+
+ if (!previewSourceKey) {
+ return null;
+ }
+
+ // A source path is only a safe fallback identity when the whole scan has one
+ // logical, non-template page for it. One selected route among several routes
+ // served by the same SPA index is still ambiguous and must not inherit blocks.
+ const logicalHostPages = scan.pages.filter(
+ (page) =>
+ page.scope !== "template_block" &&
+ normalizeWorkspacePreviewSourceKey(page.previewSourcePath ?? page.sourcePath) === previewSourceKey
+ );
+
+ return logicalHostPages.length === 1 && isWorkspaceScopePage(logicalHostPages[0]!)
+ ? logicalHostPages[0]!
+ : null;
+}
+
+function getWorkspaceRenderedPageKey(
+ scopeItem: WorkspaceScopeItem | null | undefined,
+ scopeItems: WorkspaceScopeItem[],
+ scan: ProjectScanSummary
+) {
+ if (!scopeItem) {
+ return "";
+ }
+
+ // Logical pages are distinct render instances even when an SPA serves all
+ // routes from one index.html. Sections may join a page only when the scanner
+ // result has one unambiguous host page for their exact, case-sensitive source.
+ if (scopeItem.kind === "page") {
+ return `page:${scopeItem.pageId}`;
+ }
+
+ const hostPage = getWorkspaceSectionHostPage(scan, scopeItem);
+ const hostScopeItem = hostPage
+ ? scopeItems.find((item) => item.kind === "page" && item.pageId === hostPage.pageId)
+ : null;
+
+ if (hostScopeItem) {
+ return `page:${hostScopeItem.pageId}`;
+ }
+
+ // Ambiguous SPA/template output must remain isolated until the scanner can
+ // provide an opaque render-instance id. Isolation is safer than cross-page
+ // merging and destructive replacement during persistence.
+ return `scope:${scopeItem.scopeId}`;
+}
+
+function normalizeWorkspaceScopeSearchText(value: string | null | undefined) {
+ return (value ?? "")
+ .normalize("NFKC")
+ .toLocaleLowerCase()
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function filterWorkspaceScopeItems(scopeItems: WorkspaceScopeItem[], query: string) {
+ const normalizedQuery = normalizeWorkspaceScopeSearchText(query);
+
+ if (!normalizedQuery) {
+ return scopeItems;
+ }
+
+ return scopeItems.filter((item) =>
+ normalizeWorkspaceScopeSearchText(
+ [item.title, item.pathLabel, item.sourcePath, item.previewSourcePath, ...item.headings].filter(Boolean).join(" ")
+ ).includes(normalizedQuery)
+ );
+}
+
function getSelectedWorkspaceScopeItems(scan: ProjectScanSummary): WorkspaceScopeItem[] {
+ const eligibleSections = scan.siteSections.filter(
+ (section) =>
+ section.enabled &&
+ Boolean(section.pageId) &&
+ (section.selected || Boolean(getWorkspaceSectionHostPage(scan, section)))
+ );
const sectionPageIds = new Set(
- scan.siteSections
- .filter((section) => section.selected && section.enabled && section.pageId)
- .map((section) => section.pageId!)
+ eligibleSections.map((section) => section.pageId!)
);
const pageItems = scan.pages
.map((page, index) => ({ index, page }))
.filter(({ page }) => isWorkspaceScopePage(page))
.filter(({ page }) => page.scope !== "template_block" || !sectionPageIds.has(page.pageId))
.map(({ page, index }) => getPageWorkspaceScopeItem(page, index));
- const sectionItems = scan.siteSections
+ const sectionItems = eligibleSections
.map((section, index) => getSectionWorkspaceScopeItem(section, index))
.filter((item): item is WorkspaceScopeItem => Boolean(item));
@@ -9204,6 +9288,14 @@ function getWorkspaceScopeIdFromWorkspaceKey(workspaceKey: string) {
return workspaceKey.split(":").slice(1).join(":");
}
+function omitProjectWorkspaceEntries(record: Record, projectId: string) {
+ const workspacePrefix = `${projectId}:`;
+
+ return Object.fromEntries(
+ Object.entries(record).filter(([key]) => key !== projectId && !key.startsWith(workspacePrefix))
+ ) as Record;
+}
+
type WorkspaceSection = PageWorkspace["sections"][number];
type WorkspaceSectionTextMap = Record;
@@ -9272,6 +9364,7 @@ const WORKSPACE_PREVIEW_MODES: Array<{ id: WorkspacePreviewMode; label: string }
const SEMANTIC_BLOCK_EDITOR_ENABLED = true;
const SEMANTIC_BLOCK_OVERRIDES_ENABLED = true;
+const SEMANTIC_BLOCK_EDITOR_MODEL = "semantic_membership.v2";
type PreviewMarker = {
targetId: string;
@@ -9288,6 +9381,7 @@ type WorkspaceSemanticBlockOverride = {
capturedEntities?: WorkspaceSemanticBlockCapturedEntity[];
geometry?: WorkspaceSemanticBlockGeometry;
id: string;
+ identity?: Record;
title: string;
targetIds: string[];
};
@@ -9299,6 +9393,42 @@ type WorkspaceSemanticBlockDraftBox = WorkspaceSemanticBlockOverride & {
width?: number;
};
+function markStage7SemanticMembership(box: T): T {
+ return {
+ ...box,
+ identity: {
+ ...(box.identity ?? {}),
+ editorModel: SEMANTIC_BLOCK_EDITOR_MODEL,
+ membershipAuthority: "explicit_members"
+ }
+ };
+}
+
+function isStage7SemanticMembership(box: WorkspaceSemanticBlockOverride | WorkspaceSemanticBlockDraftBox) {
+ return box.identity?.editorModel === SEMANTIC_BLOCK_EDITOR_MODEL || box.identity?.membershipAuthority === "explicit_members";
+}
+
+type WorkspaceTextInspectorSelection = {
+ characterCount: number;
+ childElementCount: number;
+ draftApplied: boolean;
+ editable: boolean;
+ id: string;
+ keywordBindings: Array<{ phrase?: string; role?: string }>;
+ previewPatchSafe: boolean;
+ rect: { height: number; left: number; top: number; width: number };
+ role: string;
+ sectionId: string;
+ selector: string;
+ selectorIndex: number;
+ semanticBlockId: string;
+ tagName: string;
+ targetId: string;
+ targetSource: string;
+ targetTitle: string;
+ text: string;
+};
+
type WorkspaceSemanticBlockAnchor = {
entityType: string;
fingerprint?: {
@@ -9317,9 +9447,73 @@ type WorkspaceSemanticBlockAnchor = {
text?: string;
};
+function sanitizeWorkspaceTextInspectorSelection(value: unknown): WorkspaceTextInspectorSelection | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return null;
+ }
+
+ const record = value as Record;
+ const id = typeof record.id === "string" ? record.id.trim() : "";
+ const selector = typeof record.selector === "string" ? record.selector.trim() : "";
+ const text = typeof record.text === "string" ? record.text.trim().slice(0, 4000) : "";
+ const rectRecord = record.rect && typeof record.rect === "object" && !Array.isArray(record.rect)
+ ? record.rect as Record
+ : null;
+
+ if (
+ !id ||
+ !selector ||
+ !text ||
+ typeof rectRecord?.left !== "number" ||
+ typeof rectRecord?.top !== "number" ||
+ typeof rectRecord?.width !== "number" ||
+ typeof rectRecord?.height !== "number"
+ ) {
+ return null;
+ }
+
+ const keywordBindings = Array.isArray(record.keywordBindings)
+ ? record.keywordBindings.flatMap((binding): Array<{ phrase?: string; role?: string }> => {
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) return [];
+ const bindingRecord = binding as Record;
+ const phrase = typeof bindingRecord.phrase === "string" ? bindingRecord.phrase.trim().slice(0, 300) : undefined;
+ const role = typeof bindingRecord.role === "string" ? bindingRecord.role.trim().slice(0, 80) : undefined;
+ return phrase || role ? [{ phrase, role }] : [];
+ }).slice(0, 12)
+ : [];
+
+ return {
+ characterCount: typeof record.characterCount === "number" ? Math.max(0, record.characterCount) : text.length,
+ childElementCount: typeof record.childElementCount === "number" ? Math.max(0, record.childElementCount) : 0,
+ draftApplied: Boolean(record.draftApplied),
+ editable: Boolean(record.editable),
+ id,
+ keywordBindings,
+ previewPatchSafe: Boolean(record.previewPatchSafe),
+ rect: {
+ height: Math.max(0, rectRecord.height),
+ left: Math.max(0, rectRecord.left),
+ top: Math.max(0, rectRecord.top),
+ width: Math.max(0, rectRecord.width)
+ },
+ role: typeof record.role === "string" ? record.role.slice(0, 80) : "text",
+ sectionId: typeof record.sectionId === "string" ? record.sectionId.slice(0, 180) : "",
+ selector,
+ selectorIndex: typeof record.selectorIndex === "number" ? Math.max(0, record.selectorIndex) : 0,
+ semanticBlockId: typeof record.semanticBlockId === "string" ? record.semanticBlockId.slice(0, 180) : "",
+ tagName: typeof record.tagName === "string" ? record.tagName.slice(0, 40) : "text",
+ targetId: typeof record.targetId === "string" ? record.targetId.slice(0, 240) : "",
+ targetSource: typeof record.targetSource === "string" ? record.targetSource.slice(0, 80) : "unbound_dom",
+ targetTitle: typeof record.targetTitle === "string" ? record.targetTitle.slice(0, 300) : "",
+ text
+ };
+}
+
type WorkspaceSemanticBlockCapturedEntity = {
id: string;
kind: string;
+ pageId?: string;
+ scopeId?: string;
selector: string;
selectorIndex: number;
text?: string;
@@ -9336,6 +9530,7 @@ type WorkspaceSemanticBlockGeometry = {
documentWidth?: number;
height: number;
left: number;
+ mode?: "anchor" | "manual";
offsets?: {
bottom: number;
left: number;
@@ -9349,6 +9544,12 @@ type WorkspaceSemanticBlockGeometry = {
const SEMANTIC_BLOCK_OVERRIDES_STORAGE_KEY = "seo-mode.semanticBlockOverrides.v3";
const WORKSPACE_UI_CONTEXT_STORAGE_KEY = "seo-mode.workspaceUiContext.v1";
+function sanitizeSemanticBlockIdentity(value: unknown): Record | undefined {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? { ...(value as Record) }
+ : undefined;
+}
+
function sanitizeSemanticBlockCapturedEntities(value: unknown): WorkspaceSemanticBlockCapturedEntity[] {
if (!Array.isArray(value)) {
return [];
@@ -9370,6 +9571,8 @@ function sanitizeSemanticBlockCapturedEntities(value: unknown): WorkspaceSemanti
{
id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : selector,
kind: typeof record.kind === "string" && record.kind.trim() ? record.kind.trim() : "element",
+ pageId: typeof record.pageId === "string" && record.pageId.trim() ? record.pageId.trim() : undefined,
+ scopeId: typeof record.scopeId === "string" && record.scopeId.trim() ? record.scopeId.trim() : undefined,
selector,
selectorIndex: typeof record.selectorIndex === "number" && Number.isFinite(record.selectorIndex) ? Math.max(0, record.selectorIndex) : 0,
text: typeof record.text === "string" && record.text.trim() ? record.text.trim().slice(0, 500) : undefined
@@ -9462,6 +9665,8 @@ function getAnchorsFromCapturedEntities(entities: WorkspaceSemanticBlockCaptured
text: entity.text
},
id: `captured:${entity.selector}:${entity.selectorIndex}`,
+ pageId: entity.pageId,
+ scopeId: entity.scopeId,
selector: entity.selector,
selectorIndex: entity.selectorIndex,
source: "captured_dom",
@@ -9517,6 +9722,7 @@ function sanitizeSemanticBlockGeometry(value: unknown): WorkspaceSemanticBlockGe
documentWidth: typeof record.documentWidth === "number" && Number.isFinite(record.documentWidth) ? record.documentWidth : undefined,
height: Math.max(2, height),
left: Math.max(0, left),
+ mode: record.mode === "manual" ? "manual" : record.mode === "anchor" ? "anchor" : undefined,
offsets,
top: Math.max(0, top),
width: Math.max(2, width)
@@ -9544,6 +9750,7 @@ function getMergedSemanticBlockGeometry(
documentWidth: Math.max(current.documentWidth ?? 0, next.documentWidth ?? 0) || (current.documentWidth ?? next.documentWidth),
height: Math.max(2, bottom - top),
left,
+ mode: current.mode === "manual" || next.mode === "manual" ? "manual" : current.mode ?? next.mode,
top,
width: Math.max(2, right - left)
};
@@ -9564,21 +9771,17 @@ function collapseCarouselVisualSemanticBlockOverrides(overrides: WorkspaceSemant
const visualMatch = /^(.*):visual-\d+$/i.exec(override.id);
const titleMatch = /^(.*?)(?:\s+|[-_#])\d+$/i.exec(override.title);
const baseTitle = (titleMatch?.[1] ?? override.title).trim();
- const hasGeometry = Boolean(override.geometry);
- const baseId =
- visualMatch?.[1] ??
- (titleMatch && hasGeometry && CAROUSEL_LIKE_SEMANTIC_PATTERN.test(baseTitle.toLocaleLowerCase("ru-RU"))
- ? `manual-carousel:${normalizeSemanticBlockIdFragment(baseTitle).toLocaleLowerCase("ru-RU")}`
- : "");
+ const baseId = visualMatch?.[1] ?? "";
const searchableValue = `${baseId} ${baseTitle} ${override.title}`.toLocaleLowerCase("ru-RU");
+ // A stable :visual-N id may represent several projections of one entity.
+ // Geometry/title similarity alone must never create semantic membership.
if (!baseId || !CAROUSEL_LIKE_SEMANTIC_PATTERN.test(searchableValue)) {
result.push(override);
return;
}
- const band = Math.round((override.geometry?.top ?? 0) / Math.max(220, override.geometry?.height ?? 220));
- const mergeKey = baseId.startsWith("manual-carousel:") ? `${baseId}:band-${band}` : baseId;
+ const mergeKey = baseId;
const current = merged.get(mergeKey);
if (!current) {
@@ -9596,11 +9799,36 @@ function collapseCarouselVisualSemanticBlockOverrides(overrides: WorkspaceSemant
current.anchors = mergeSemanticBlockAnchors(current.anchors, override.anchors);
current.capturedEntities = sanitizeSemanticBlockCapturedEntities([...(current.capturedEntities ?? []), ...(override.capturedEntities ?? [])]);
current.geometry = getMergedSemanticBlockGeometry(current.geometry, override.geometry);
+ current.identity = {
+ ...(override.identity ?? {}),
+ ...(current.identity ?? {})
+ };
});
return result;
}
+function mergeSemanticBlockOverrideMembership(
+ current: WorkspaceSemanticBlockOverride | WorkspaceSemanticBlockDraftBox,
+ next: WorkspaceSemanticBlockOverride | WorkspaceSemanticBlockDraftBox
+): WorkspaceSemanticBlockOverride {
+ return {
+ anchors: mergeSemanticBlockAnchors(current.anchors, next.anchors),
+ capturedEntities: sanitizeSemanticBlockCapturedEntities([
+ ...(current.capturedEntities ?? []),
+ ...(next.capturedEntities ?? [])
+ ]),
+ geometry: getMergedSemanticBlockGeometry(current.geometry, next.geometry),
+ id: current.id,
+ identity: {
+ ...(next.identity ?? {}),
+ ...(current.identity ?? {})
+ },
+ targetIds: Array.from(new Set([...current.targetIds, ...next.targetIds])),
+ title: current.title || next.title
+ };
+}
+
function sanitizeStoredWorkspaceSemanticBlockOverrides(value: unknown): Record {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
@@ -9628,9 +9856,10 @@ function sanitizeStoredWorkspaceSemanticBlockOverrides(value: unknown): Record 0 || capturedEntities.length > 0 || anchors.length > 0)
- ? [{ anchors, capturedEntities, geometry, id, targetIds, title }]
+ ? [{ anchors, capturedEntities, geometry, id, identity, targetIds, title }]
: [];
});
@@ -9676,6 +9905,7 @@ function sanitizeSemanticBlockDraftBoxes(value: unknown): WorkspaceSemanticBlock
...getAnchorsFromCapturedEntities(capturedEntities)
]);
const geometry = sanitizeSemanticBlockGeometry(record.geometry);
+ const identity = sanitizeSemanticBlockIdentity(record.identity);
if (!id) {
return [];
@@ -9688,6 +9918,7 @@ function sanitizeSemanticBlockDraftBoxes(value: unknown): WorkspaceSemanticBlock
geometry,
height: typeof record.height === "number" ? record.height : undefined,
id,
+ identity,
left: typeof record.left === "number" ? record.left : undefined,
targetIds,
title,
@@ -9721,6 +9952,7 @@ function commitSemanticBlockDraftBoxes(boxes: WorkspaceSemanticBlockDraftBox[] |
capturedEntities,
geometry,
id: box.id,
+ identity: sanitizeSemanticBlockIdentity(box.identity),
targetIds,
title: box.title
}
@@ -9740,7 +9972,25 @@ function getSemanticBlockOverridesFromModels(
return [];
}
- const capturedEntities = sanitizeSemanticBlockCapturedEntities(block.capturedEntities);
+ const capturedEntities = sanitizeSemanticBlockCapturedEntities([
+ ...block.capturedEntities,
+ ...block.sourceBindings.flatMap((binding) => {
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) return [];
+ const record = binding as Record;
+ const selector = typeof record.selector === "string" ? record.selector : "";
+ if (!selector || (record.contextOnly !== true && record.source !== "captured_dom")) return [];
+
+ return [{
+ id: typeof record.id === "string" ? record.id : `binding:${selector}:${Number(record.selectorIndex) || 0}`,
+ kind: typeof record.kind === "string" ? record.kind : "text",
+ pageId: typeof record.sourcePageId === "string" ? record.sourcePageId : undefined,
+ scopeId: typeof record.sourceScopeId === "string" ? record.sourceScopeId : undefined,
+ selector,
+ selectorIndex: typeof record.selectorIndex === "number" ? record.selectorIndex : 0,
+ text: typeof record.textSnapshot === "string" ? record.textSnapshot : undefined
+ }];
+ })
+ ]);
const anchors = sanitizeSemanticBlockAnchors([
...block.anchors,
...getAnchorsFromCapturedEntities(capturedEntities)
@@ -9752,13 +10002,14 @@ function getSemanticBlockOverridesFromModels(
.filter((targetId): targetId is string => Boolean(targetId));
const targetIds = Array.from(new Set([...block.targetIds.filter(Boolean), ...anchorTargetIds]));
- return blockKey && targetIds.length > 0
+ return blockKey && (targetIds.length > 0 || capturedEntities.length > 0 || anchors.length > 0 || block.sourceBindings.length > 0)
? [
{
anchors,
capturedEntities,
geometry,
id: blockKey,
+ identity: sanitizeSemanticBlockIdentity(block.identity),
targetIds,
title: block.title || "Semantic block"
}
@@ -9769,48 +10020,133 @@ function getSemanticBlockOverridesFromModels(
return collapseCarouselVisualSemanticBlockOverrides(overrides);
}
-function buildSemanticBlockSourceBindings(override: WorkspaceSemanticBlockOverride, targets: WorkspaceTarget[]) {
- const targetsById = new Map(targets.map((target) => [target.id, target]));
- const anchorTargetIds = sanitizeSemanticBlockAnchors(override.anchors)
+type SemanticBlockBindingTarget = WorkspaceTarget & {
+ semanticSourcePageId?: string;
+ semanticSourceScopeId?: string;
+ semanticSourceWorkspaceKey?: string;
+};
+
+function getSemanticBlockResolvedTargets(
+ override: WorkspaceSemanticBlockOverride,
+ targets: SemanticBlockBindingTarget[]
+) {
+ const targetsById = targets.reduce
Actual editable paragraph