diff --git a/Cargo.lock b/Cargo.lock index 36df5b55..9b53ad3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6429,6 +6429,7 @@ dependencies = [ "sse-stream", "tar", "tauri", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", @@ -6475,6 +6476,7 @@ dependencies = [ "reqwest 0.12.28", "resvg", "screenshots", + "semver", "serde", "serde_json", "sha1", diff --git a/bundles/components/runtimes/product-app-runtime-host/1.0.0/component.json b/bundles/components/runtimes/product-app-runtime-host/1.0.0/component.json index 172b5d77..a6adf3a3 100644 --- a/bundles/components/runtimes/product-app-runtime-host/1.0.0/component.json +++ b/bundles/components/runtimes/product-app-runtime-host/1.0.0/component.json @@ -22,7 +22,8 @@ "builtin-remotion-live", "builtin-harmony-dev", "builtin-ppt-live", - "builtin-spark-board" + "builtin-spark-board", + "builtin-excel-live" ], "visibility": "developer", "dependencies": [], diff --git a/bundles/product-apps/app-builder/1.0.0/app.json b/bundles/product-apps/app-builder/1.0.0/app.json new file mode 100644 index 00000000..c3788ffe --- /dev/null +++ b/bundles/product-apps/app-builder/1.0.0/app.json @@ -0,0 +1,98 @@ +{ + "id": "app-builder", + "version": "1.0.0", + "name": "App Builder", + "description": "Create, fork, test, and evolve personal intelligent apps through conversation and live preview.", + "authors": [ + { + "name": "Sparo OS", + "url": "https://gcwing.github.io/Sparo-Agentic-OS/" + } + ], + "i18n": { + "locales": { + "en-US": { + "name": "App Builder", + "description": "Create, fork, test, and evolve personal intelligent apps through conversation and live preview.", + "tags": [ + "system", + "app-builder", + "intelligent-app" + ] + }, + "zh-CN": { + "name": "App Builder", + "description": "通过对话和实时预览创建、派生、测试并持续演进个人智能应用。", + "tags": [ + "系统", + "应用构建", + "智能应用" + ] + } + } + }, + "interactionModel": "conversation", + "workMultiplicity": "multiple", + "workObjectKinds": [ + { + "id": "app-draft", + "label": "App Draft", + "scope": "runtime", + "identitySchema": { + "type": "object" + }, + "contextSchema": { + "type": "object" + } + } + ], + "primarySurfaceMode": "chatPrimary", + "components": [], + "componentLockId": "", + "permissions": { + "fs": true, + "net": false, + "shell": true, + "gui": false, + "secrets": false, + "ai": true + }, + "installScope": "system", + "catalogVisibility": "discoverable", + "enabled": true, + "icon": { + "kind": "nativeAsset", + "assetId": "app-builder", + "mimeType": "image/png" + }, + "category": "developer", + "tags": [ + "system", + "app-builder", + "intelligent-app" + ], + "launch": { + "kind": "appBuilder", + "targetId": "AppBuilder", + "scopeRequirement": "systemAllowed", + "agentType": "AppBuilder" + }, + "osCapabilities": [ + "filesystem.read", + "filesystem.write", + "terminal.run", + "process.spawn", + "workspace.inspect", + "workspace.modify", + "session.read", + "session.write", + "model.invoke", + "tool.invoke" + ], + "dataLifecycle": { + "retention": "userManaged", + "deletion": "deleteOnUserRequest", + "migration": "schemaVersioned", + "share": "declaredWorkObjectsOnly" + } +} diff --git a/bundles/product-apps/app-builder/1.0.0/compatibility.json b/bundles/product-apps/app-builder/1.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/app-builder/1.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/app-builder/1.0.0/config/data-schema.json b/bundles/product-apps/app-builder/1.0.0/config/data-schema.json new file mode 100644 index 00000000..1587a669 --- /dev/null +++ b/bundles/product-apps/app-builder/1.0.0/config/data-schema.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/bundles/product-apps/app-builder/1.0.0/config/default.json b/bundles/product-apps/app-builder/1.0.0/config/default.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/bundles/product-apps/app-builder/1.0.0/config/default.json @@ -0,0 +1 @@ +{} diff --git a/bundles/product-apps/app-builder/1.0.0/tests/eval.json b/bundles/product-apps/app-builder/1.0.0/tests/eval.json new file mode 100644 index 00000000..adc8440c --- /dev/null +++ b/bundles/product-apps/app-builder/1.0.0/tests/eval.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cases": [ + { + "id": "primary-behavior", + "title": "App Builder responds from its declared app context", + "description": "Proves that the immutable Release can invoke its intelligent behavior.", + "input": { + "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with App Builder." + }, + "expectations": [ + { + "kind": "text-contains", + "value": "SPARO_APP_READY" + } + ], + "evidenceKind": "behavior", + "tags": [ + "release-gate" + ], + "required": true + } + ] +} diff --git a/bundles/product-apps/app-builder/1.0.0/tests/rehearsal.json b/bundles/product-apps/app-builder/1.0.0/tests/rehearsal.json new file mode 100644 index 00000000..20799ef9 --- /dev/null +++ b/bundles/product-apps/app-builder/1.0.0/tests/rehearsal.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "critical-user-path", + "title": "Open App Builder and complete its first useful action", + "description": "Create, fork, test, and evolve personal intelligent apps through conversation and live preview.", + "kind": "user-path", + "steps": [ + { + "id": "open-app", + "action": "open", + "target": "app-builder", + "expect": [ + "App Builder opens" + ] + }, + { + "id": "observe-ready-state", + "action": "observe", + "target": "work-outcome", + "expect": [ + "A usable ready state is visible" + ] + } + ], + "expected": [ + "The app opens without a fatal runtime issue", + "The primary workflow exposes an observable path" + ] + } + ] +} diff --git a/bundles/product-apps/builtin-bitfun-coder/1.0.0/app.json b/bundles/product-apps/builtin-bitfun-coder/1.0.0/app.json index 9a39c9fb..0c0ad86c 100644 --- a/bundles/product-apps/builtin-bitfun-coder/1.0.0/app.json +++ b/bundles/product-apps/builtin-bitfun-coder/1.0.0/app.json @@ -14,12 +14,22 @@ "en-US": { "name": "BitFun Coder", "description": "An efficient, high-completion Code Agent from the open-source community for implementation, debugging, refactoring, troubleshooting, and other software development work.", - "tags": ["built-in", "open-source", "coding", "agent"] + "tags": [ + "built-in", + "open-source", + "coding", + "agent" + ] }, "zh-CN": { "name": "BitFun Coder", "description": "来自开源社区的高效、高完成率 Code Agent,全面处理实现、调试、重构、排障等软件开发任务。", - "tags": ["内置", "开源", "编码", "Agent"] + "tags": [ + "内置", + "开源", + "编码", + "Agent" + ] } } }, @@ -129,5 +139,11 @@ "session.write", "model.invoke", "tool.invoke" - ] + ], + "dataLifecycle": { + "retention": "userManaged", + "deletion": "deleteOnUserRequest", + "migration": "exportImport", + "share": "declaredWorkObjectsOnly" + } } diff --git a/bundles/product-apps/builtin-bitfun-coder/1.0.0/compatibility.json b/bundles/product-apps/builtin-bitfun-coder/1.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/builtin-bitfun-coder/1.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/builtin-bitfun-coder/1.0.0/config/data-schema.json b/bundles/product-apps/builtin-bitfun-coder/1.0.0/config/data-schema.json new file mode 100644 index 00000000..1587a669 --- /dev/null +++ b/bundles/product-apps/builtin-bitfun-coder/1.0.0/config/data-schema.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/bundles/product-apps/builtin-bitfun-coder/1.0.0/config/default.json b/bundles/product-apps/builtin-bitfun-coder/1.0.0/config/default.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/bundles/product-apps/builtin-bitfun-coder/1.0.0/config/default.json @@ -0,0 +1 @@ +{} diff --git a/bundles/product-apps/builtin-bitfun-coder/1.0.0/tests/eval.json b/bundles/product-apps/builtin-bitfun-coder/1.0.0/tests/eval.json new file mode 100644 index 00000000..20694dd1 --- /dev/null +++ b/bundles/product-apps/builtin-bitfun-coder/1.0.0/tests/eval.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cases": [ + { + "id": "primary-behavior", + "title": "BitFun Coder responds from its declared app context", + "description": "Proves that the immutable Release can invoke its intelligent behavior.", + "input": { + "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with BitFun Coder." + }, + "expectations": [ + { + "kind": "text-contains", + "value": "SPARO_APP_READY" + } + ], + "evidenceKind": "behavior", + "tags": [ + "release-gate" + ], + "required": true + } + ] +} diff --git a/bundles/product-apps/builtin-bitfun-coder/1.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-bitfun-coder/1.0.0/tests/rehearsal.json new file mode 100644 index 00000000..e2dd9c9f --- /dev/null +++ b/bundles/product-apps/builtin-bitfun-coder/1.0.0/tests/rehearsal.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "critical-user-path", + "title": "Open BitFun Coder and complete its first useful action", + "description": "An efficient, high-completion Code Agent from the open-source community for implementation, debugging, refactoring, troubleshooting, and other software development work.", + "kind": "user-path", + "steps": [ + { + "id": "open-app", + "action": "open", + "target": "agent-session", + "expect": [ + "BitFun Coder opens" + ] + }, + { + "id": "observe-ready-state", + "action": "observe", + "target": "work-outcome", + "expect": [ + "A usable ready state is visible" + ] + } + ], + "expected": [ + "The app opens without a fatal runtime issue", + "The primary workflow exposes an observable path" + ] + } + ] +} diff --git a/bundles/product-apps/builtin-cowork/1.0.0/app.json b/bundles/product-apps/builtin-cowork/1.0.0/app.json index db226e93..2cd826d7 100644 --- a/bundles/product-apps/builtin-cowork/1.0.0/app.json +++ b/bundles/product-apps/builtin-cowork/1.0.0/app.json @@ -14,12 +14,24 @@ "en-US": { "name": "Cowork", "description": "An office delivery Agent for documents, spreadsheets, slides, and material organization, turning everyday work into polished output.", - "tags": ["built-in", "office", "documents", "spreadsheets", "slides"] + "tags": [ + "built-in", + "office", + "documents", + "spreadsheets", + "slides" + ] }, "zh-CN": { "name": "Cowork", "description": "面向办公交付的 Agent,擅长文档、表格、PPT 和资料整理,把日常工作推进成清晰成稿。", - "tags": ["内置", "办公", "文档", "表格", "PPT"] + "tags": [ + "内置", + "办公", + "文档", + "表格", + "PPT" + ] } } }, @@ -113,5 +125,11 @@ "memory.read", "model.invoke", "tool.invoke" - ] + ], + "dataLifecycle": { + "retention": "userManaged", + "deletion": "deleteOnUserRequest", + "migration": "exportImport", + "share": "declaredWorkObjectsOnly" + } } diff --git a/bundles/product-apps/builtin-cowork/1.0.0/compatibility.json b/bundles/product-apps/builtin-cowork/1.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/builtin-cowork/1.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/builtin-cowork/1.0.0/config/data-schema.json b/bundles/product-apps/builtin-cowork/1.0.0/config/data-schema.json new file mode 100644 index 00000000..1587a669 --- /dev/null +++ b/bundles/product-apps/builtin-cowork/1.0.0/config/data-schema.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/bundles/product-apps/builtin-cowork/1.0.0/config/default.json b/bundles/product-apps/builtin-cowork/1.0.0/config/default.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/bundles/product-apps/builtin-cowork/1.0.0/config/default.json @@ -0,0 +1 @@ +{} diff --git a/bundles/product-apps/builtin-cowork/1.0.0/tests/eval.json b/bundles/product-apps/builtin-cowork/1.0.0/tests/eval.json new file mode 100644 index 00000000..b9231e63 --- /dev/null +++ b/bundles/product-apps/builtin-cowork/1.0.0/tests/eval.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cases": [ + { + "id": "primary-behavior", + "title": "Cowork responds from its declared app context", + "description": "Proves that the immutable Release can invoke its intelligent behavior.", + "input": { + "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with Cowork." + }, + "expectations": [ + { + "kind": "text-contains", + "value": "SPARO_APP_READY" + } + ], + "evidenceKind": "behavior", + "tags": [ + "release-gate" + ], + "required": true + } + ] +} diff --git a/bundles/product-apps/builtin-cowork/1.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-cowork/1.0.0/tests/rehearsal.json new file mode 100644 index 00000000..411ef367 --- /dev/null +++ b/bundles/product-apps/builtin-cowork/1.0.0/tests/rehearsal.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "critical-user-path", + "title": "Open Cowork and complete its first useful action", + "description": "An office delivery Agent for documents, spreadsheets, slides, and material organization, turning everyday work into polished output.", + "kind": "user-path", + "steps": [ + { + "id": "open-app", + "action": "open", + "target": "agent-session", + "expect": [ + "Cowork opens" + ] + }, + { + "id": "observe-ready-state", + "action": "observe", + "target": "work-outcome", + "expect": [ + "A usable ready state is visible" + ] + } + ], + "expected": [ + "The app opens without a fatal runtime issue", + "The primary workflow exposes an observable path" + ] + } + ] +} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/app.json b/bundles/product-apps/builtin-deep-research/1.0.0/app.json index a7166202..2bccb836 100644 --- a/bundles/product-apps/builtin-deep-research/1.0.0/app.json +++ b/bundles/product-apps/builtin-deep-research/1.0.0/app.json @@ -8,12 +8,22 @@ "en-US": { "name": "Deep Research", "description": "A deep research Agent for finding sources, comparing viewpoints, organizing evidence, and turning complex questions into clear conclusions.", - "tags": ["built-in", "research", "evidence", "report"] + "tags": [ + "built-in", + "research", + "evidence", + "report" + ] }, "zh-CN": { "name": "Deep Research", "description": "面向深度研究的 Agent,适合查资料、比观点、梳理依据,把复杂问题沉淀为清晰结论。", - "tags": ["内置", "研究", "证据", "报告"] + "tags": [ + "内置", + "研究", + "证据", + "报告" + ] } } }, @@ -191,5 +201,11 @@ "memory.write", "model.invoke", "tool.invoke" - ] + ], + "dataLifecycle": { + "retention": "workRuntimeScoped", + "deletion": "deleteWithWork", + "migration": "exportImport", + "share": "declaredWorkObjectsOnly" + } } diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/compatibility.json b/bundles/product-apps/builtin-deep-research/1.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/builtin-deep-research/1.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/config/data-schema.json b/bundles/product-apps/builtin-deep-research/1.0.0/config/data-schema.json new file mode 100644 index 00000000..1587a669 --- /dev/null +++ b/bundles/product-apps/builtin-deep-research/1.0.0/config/data-schema.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/config/default.json b/bundles/product-apps/builtin-deep-research/1.0.0/config/default.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/bundles/product-apps/builtin-deep-research/1.0.0/config/default.json @@ -0,0 +1 @@ +{} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/tests/eval.json b/bundles/product-apps/builtin-deep-research/1.0.0/tests/eval.json new file mode 100644 index 00000000..6d8a12b9 --- /dev/null +++ b/bundles/product-apps/builtin-deep-research/1.0.0/tests/eval.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cases": [ + { + "id": "primary-behavior", + "title": "Deep Research responds from its declared app context", + "description": "Proves that the immutable Release can invoke its intelligent behavior.", + "input": { + "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with Deep Research." + }, + "expectations": [ + { + "kind": "text-contains", + "value": "SPARO_APP_READY" + } + ], + "evidenceKind": "behavior", + "tags": [ + "release-gate" + ], + "required": true + } + ] +} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-deep-research/1.0.0/tests/rehearsal.json new file mode 100644 index 00000000..eedab2b6 --- /dev/null +++ b/bundles/product-apps/builtin-deep-research/1.0.0/tests/rehearsal.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "critical-user-path", + "title": "Open Deep Research and complete its first useful action", + "description": "A deep research Agent for finding sources, comparing viewpoints, organizing evidence, and turning complex questions into clear conclusions.", + "kind": "user-path", + "steps": [ + { + "id": "open-app", + "action": "open", + "target": "agent-session", + "expect": [ + "Deep Research opens" + ] + }, + { + "id": "observe-ready-state", + "action": "observe", + "target": "work-outcome", + "expect": [ + "A usable ready state is visible" + ] + } + ], + "expected": [ + "The app opens without a fatal runtime issue", + "The primary workflow exposes an observable path" + ] + } + ] +} diff --git a/bundles/product-apps/builtin-design/1.0.0/app.json b/bundles/product-apps/builtin-design/1.0.0/app.json index 53f3b79a..da7426b0 100644 --- a/bundles/product-apps/builtin-design/1.0.0/app.json +++ b/bundles/product-apps/builtin-design/1.0.0/app.json @@ -14,12 +14,22 @@ "en-US": { "name": "Design", "description": "A product and UI design Agent that turns ideas, requirements, and references into prototypes, interfaces, and visual directions.", - "tags": ["built-in", "design", "ui", "prototype"] + "tags": [ + "built-in", + "design", + "ui", + "prototype" + ] }, "zh-CN": { "name": "Design", "description": "面向产品与界面设计的 Agent,把想法、需求和参考图转成原型、界面和视觉方案。", - "tags": ["内置", "设计", "ui", "原型"] + "tags": [ + "内置", + "设计", + "ui", + "原型" + ] } } }, @@ -112,5 +122,11 @@ "memory.read", "model.invoke", "tool.invoke" - ] + ], + "dataLifecycle": { + "retention": "userManaged", + "deletion": "deleteOnUserRequest", + "migration": "exportImport", + "share": "declaredWorkObjectsOnly" + } } diff --git a/bundles/product-apps/builtin-design/1.0.0/compatibility.json b/bundles/product-apps/builtin-design/1.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/builtin-design/1.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/builtin-design/1.0.0/config/data-schema.json b/bundles/product-apps/builtin-design/1.0.0/config/data-schema.json new file mode 100644 index 00000000..1587a669 --- /dev/null +++ b/bundles/product-apps/builtin-design/1.0.0/config/data-schema.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/bundles/product-apps/builtin-design/1.0.0/config/default.json b/bundles/product-apps/builtin-design/1.0.0/config/default.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/bundles/product-apps/builtin-design/1.0.0/config/default.json @@ -0,0 +1 @@ +{} diff --git a/bundles/product-apps/builtin-design/1.0.0/tests/eval.json b/bundles/product-apps/builtin-design/1.0.0/tests/eval.json new file mode 100644 index 00000000..3a936937 --- /dev/null +++ b/bundles/product-apps/builtin-design/1.0.0/tests/eval.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cases": [ + { + "id": "primary-behavior", + "title": "Design responds from its declared app context", + "description": "Proves that the immutable Release can invoke its intelligent behavior.", + "input": { + "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with Design." + }, + "expectations": [ + { + "kind": "text-contains", + "value": "SPARO_APP_READY" + } + ], + "evidenceKind": "behavior", + "tags": [ + "release-gate" + ], + "required": true + } + ] +} diff --git a/bundles/product-apps/builtin-design/1.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-design/1.0.0/tests/rehearsal.json new file mode 100644 index 00000000..b84b9cb8 --- /dev/null +++ b/bundles/product-apps/builtin-design/1.0.0/tests/rehearsal.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "critical-user-path", + "title": "Open Design and complete its first useful action", + "description": "A product and UI design Agent that turns ideas, requirements, and references into prototypes, interfaces, and visual directions.", + "kind": "user-path", + "steps": [ + { + "id": "open-app", + "action": "open", + "target": "agent-session", + "expect": [ + "Design opens" + ] + }, + { + "id": "observe-ready-state", + "action": "observe", + "target": "work-outcome", + "expect": [ + "A usable ready state is visible" + ] + } + ], + "expected": [ + "The app opens without a fatal runtime issue", + "The primary workflow exposes an observable path" + ] + } + ] +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/app.json b/bundles/product-apps/builtin-excel-live/1.0.0/app.json new file mode 100644 index 00000000..28e91f1d --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/app.json @@ -0,0 +1,265 @@ +{ + "id": "builtin-excel-live", + "version": "1.0.0", + "name": "Excel Live", + "description": "A revisioned spreadsheet workbench where people and agents share grounded focus, review proposals on the grid, undo safely, and export source-preserving workbook copies.", + "authors": [ + { + "name": "Sparo OS", + "url": "https://github.com/Sparo-OS" + } + ], + "i18n": { + "locales": { + "en-US": { + "name": "Excel Live", + "description": "A revisioned spreadsheet workbench where people and agents share grounded focus, review proposals on the grid, undo safely, and export source-preserving workbook copies.", + "tags": [ + "built-in", + "spreadsheet", + "excel", + "workbook" + ] + }, + "zh-CN": { + "name": "Excel Live", + "description": "带修订历史的表格工作台:人与 Agent 共享真实焦点,在网格上审核提案、可靠撤销,并导出保留源文件结构的工作簿副本。", + "tags": [ + "内置", + "表格", + "Excel", + "工作簿" + ] + } + } + }, + "interactionModel": "interactiveWorkspace", + "workMultiplicity": "multiple", + "workObjectKinds": [ + { + "id": "workbook", + "label": "Workbook", + "scope": "asset", + "identitySchema": { + "type": "object", + "properties": { + "workbookId": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "contextSchema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "sheetCount": { + "type": "number" + }, + "dirty": { + "type": "boolean" + } + } + } + }, + { + "id": "spreadsheet-focus", + "label": "Spreadsheet Focus", + "scope": "runtime", + "identitySchema": { + "type": "object", + "required": [ + "workbookId", + "sheetId", + "a1" + ], + "properties": { + "workbookId": { + "type": "string" + }, + "sheetId": { + "type": "string" + }, + "a1": { + "type": "string" + } + } + }, + "contextSchema": { + "type": "object", + "properties": { + "selectionKind": { + "type": "string" + }, + "rowCount": { + "type": "number" + }, + "columnCount": { + "type": "number" + }, + "mode": { + "type": "string", + "enum": [ + "inspect", + "edit", + "author" + ] + }, + "revision": { + "type": [ + "string", + "number" + ] + }, + "cacheCoverage": { + "type": [ + "number", + "object" + ] + }, + "cacheComplete": { + "type": "boolean" + }, + "formulaResultsFresh": { + "type": "boolean" + }, + "calculationStatus": { + "type": "object" + }, + "fidelity": { + "type": "object" + }, + "capturedAt": { + "type": "number" + }, + "previewTruncated": { + "type": "boolean" + } + } + } + } + ], + "truthSource": "ownedObjectState", + "primarySurface": { + "componentId": "builtin-excel-live-surface", + "surfaceId": "primary" + }, + "primarySurfaceMode": "sidecarLinked", + "components": [ + { + "componentId": "builtin-excel-live-surface", + "kind": "surface", + "source": "private", + "role": "primarySurface", + "capabilities": [ + "interactiveSurface" + ], + "usesCapabilities": [ + "session.read", + "component.run" + ] + }, + { + "componentId": "excel-live-agent", + "kind": "agent", + "source": "private", + "role": "assistant", + "capabilities": [ + "inspectWorkbook", + "transformRange", + "authorWorkbook" + ], + "usesCapabilities": [ + "filesystem.read", + "filesystem.write", + "workspace.inspect", + "workspace.modify", + "session.read", + "model.invoke", + "tool.invoke", + "component.run" + ] + }, + { + "componentId": "builtin-excel-runtime", + "kind": "bridge", + "source": "private", + "role": "runtime", + "capabilities": [ + "sparo.excelEngine" + ], + "usesCapabilities": [ + "filesystem.read", + "filesystem.write", + "terminal.run", + "process.spawn", + "workspace.inspect", + "component.run" + ] + }, + { + "componentId": "product-app-runtime-host", + "kind": "runtime", + "source": "shared", + "role": "runtimeHost", + "version": "^1.0.0", + "capabilities": [ + "uiWorker" + ], + "usesCapabilities": [ + "component.run" + ] + } + ], + "componentLockId": "", + "permissions": { + "fs": true, + "net": false, + "shell": true, + "gui": false, + "secrets": false, + "ai": true + }, + "installScope": "system", + "catalogVisibility": "discoverable", + "enabled": true, + "icon": { + "kind": "packageAsset", + "path": "assets/icon.svg" + }, + "category": "productivity", + "tags": [ + "built-in", + "spreadsheet", + "excel", + "workbook" + ], + "launch": { + "kind": "applicationSurface", + "targetId": "builtin-excel-live", + "scopeRequirement": "workspaceOptional", + "surfaceId": "primary" + }, + "osCapabilities": [ + "session.read", + "component.run", + "filesystem.read", + "filesystem.write", + "terminal.run", + "process.spawn", + "workspace.inspect", + "workspace.modify", + "model.invoke", + "tool.invoke" + ], + "dataLifecycle": { + "retention": "externalSystem", + "deletion": "externalSystem", + "migration": "externalSystem", + "share": "externalReferenceOnly" + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/assets/icon.svg b/bundles/product-apps/builtin-excel-live/1.0.0/assets/icon.svg new file mode 100644 index 00000000..78b01d05 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/assets/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/compatibility.json b/bundles/product-apps/builtin-excel-live/1.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/component.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/component.json new file mode 100644 index 00000000..88a42ed0 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/component.json @@ -0,0 +1,106 @@ +{ + "id": "excel-live-agent", + "kind": "agent", + "name": "Excel Live Agent", + "description": "A professional spreadsheet authoring copilot that inspects real workbook state, designs clear tables, proposes formulas, styles, and layouts for grid review, and exports source-safe Excel copies.", + "packageSource": "appPrivate", + "ownerApp": { + "appId": "builtin-excel-live", + "appVersion": "1.0.0" + }, + "capabilities": [ + { + "id": "inspectWorkbook", + "title": "Inspect workbook", + "description": "Open or create workbooks, read metadata and focus, list sheets, and summarize or read ranges without mutating committed cells.", + "actions": [ + "openWorkbook", + "createWorkbook", + "getWorkbookMeta", + "getFocus", + "listSheets", + "readRange", + "summarizeRange" + ] + }, + { + "id": "transformRange", + "title": "Transform range", + "description": "Propose revision-bound values, formulas, semantic or custom styles, number formats, and layout against ambient focus, then verify them on the grid and recover committed edits through history.", + "actions": [ + "proposePatch", + "getProposal", + "acceptProposal", + "rejectProposal", + "getHistory", + "undo", + "redo", + "switchSheet" + ] + }, + { + "id": "authorWorkbook", + "title": "Author workbook", + "description": "Create workbook structure, author coherent content/formula/visual proposals, retain undo history, then export committed work as an explicit source-safe copy.", + "actions": [ + "createWorkbook", + "addSheet", + "renameSheet", + "insertRows", + "insertColumns", + "deleteRows", + "deleteColumns", + "proposePatch", + "saveWorkbook", + "getHistory", + "undo", + "redo", + "listSheets", + "switchSheet" + ] + } + ], + "permissions": [ + { + "kind": "ai", + "summary": "Uses configured AI providers through Sparo OS.", + "scopes": [ + "system", + "workspace" + ] + }, + { + "kind": "fs", + "summary": "Reads and writes workspace spreadsheet files and Excel Live workbook stores through declared tools.", + "scopes": [ + "{workspace}" + ] + } + ], + "usedByApps": [ + "builtin-excel-live" + ], + "visibility": "appDependency", + "dependencies": [ + { + "componentId": "builtin-excel-runtime", + "kind": "bridge", + "source": "private", + "role": "excelEngine", + "capabilities": [ + "sparo.excelEngine" + ] + } + ], + "implementationRef": "app://builtin-excel-live@1.0.0/agents/excel-live-agent", + "usesCapabilities": [ + "filesystem.read", + "filesystem.write", + "workspace.inspect", + "workspace.modify", + "session.read", + "model.invoke", + "tool.invoke", + "component.run" + ] +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/agent.md b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/agent.md new file mode 100644 index 00000000..722c6a35 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/agent.md @@ -0,0 +1,151 @@ +You are Excel Live Agent, a professional spreadsheet analyst and author working inside the Excel Live grid. + +Your purpose is to turn a real workbook into a clearer, more useful, and more maintainable spreadsheet. Treat structure, formulas, visual hierarchy, reviewability, and portable Excel delivery as one product-quality outcome. The grid is the primary work surface; chat explains decisions and progress. + +## Runtime truth and tool boundaries + +- The tools actually exposed in this Agent manifest are the complete tool contract. They override generic runtime advice. Never call Bash, Memory, Write, Edit, ComputerUse, or any other tool absent from the exposed list. +- If LS, Glob, Grep, or Read are present in the actual exposed list, use them only to discover workspace files or inspect non-workbook source material. Never edit an xlsx/xlsm or the live workbook store with generic filesystem tools. +- All workbook reads, changes, history operations, and exports must use `agentcomponent__excel-live-agent__*` tools so revision, proposal, safety, and fidelity contracts remain intact. +- Do not call a generic bridge tool when an Agent Component tool covers the action. +- Never invent a tool, capability, workbook value, displayed format, calculation result, or success state. + +Available workbook tools: +- Inspect: `get_workbook_meta`, `get_focus`, `list_sheets`, `read_range`, `summarize_range`. +- Session: `open_workbook`, `create_workbook`, `switch_sheet`. +- Design: `propose_patch`, `get_proposal`, `accept_proposal`, `reject_proposal`. +- Structure: `edit_workbook_structure`. +- Recovery: `get_history`, `undo_workbook`, `redo_workbook`. +- Delivery: `save_workbook`. + +## Capability discovery is mandatory + +Before a non-trivial authoring task, call `get_workbook_meta` and use the returned capability and fidelity fields as runtime truth. + +- An absent capability is unsupported, not implicitly available. +- `cellStyles`, `semanticStyleRoles`, `numberFormats`, and `layoutMetadata` govern visual authoring. +- `formulaStaticLint` means formula syntax/reference checks are available. It does not mean formulas were calculated. +- `formulaRecalculation: false` means the engine cannot produce or refresh numeric formula results. +- `styleSourcePatch` describes whether new cell-style edits can be safely patched into a source-derived export. `layoutSourcePatch` independently describes layout edits. Existing styles or layout may be preserved even when the Agent cannot fully interpret or rewrite them. +- Fidelity is operation-specific. Preserve-source, minimal-new-package, xlsx, and xlsm paths can have different guarantees. + +If a requested capability is false or absent, keep the supported part useful, disclose the precise boundary, and suggest external Excel only for the unsupported remainder. + +## North-star workflow: Point -> Transform -> Verify + +### 1. Point + +- Ground the task in workbookId, sheet, A1 range, and revision. Ambient focus is the default target unless the user names another range. +- A focus preview is a pointer, not permission to infer unread cells. Use it only when `cacheComplete` is true and its revision matches. +- Read before reasoning. For large ranges, summarize first and then read the smallest ranges needed. +- Inspect existing formulas, styles, number formats, and layout before redesigning them. Preserve intentional workbook conventions unless the user asks for a new direction. +- The revision from the last successful focus/meta/read sequence is authoritative. Never build a proposal from an older cached revision. +- Clarify only when purpose, target range, or a high-impact business rule cannot be inferred safely. + +### 2. Transform + +First infer or confirm the spreadsheet brief: +- purpose and decision supported; +- audience and maintenance owner; +- input fields versus calculated outputs; +- recurring versus one-off use; +- desired delivery format and fidelity constraints. + +Then design the information model before decoration: +- establish the title/context area, header, data body, calculated outputs, totals/summary, and notes only when useful; +- keep one record per row and one meaning per column; +- use formulas for maintainable derived values rather than hard-coded results; +- create a calm visual hierarchy with semantic roles, number formats, alignment, spacing, and layout; +- prefer one coherent `propose_patch` containing related content, formulas, styles, and layout so the user can review the design as a whole. + +`propose_patch` supports: +- sparse `cells` with value, formula, styleRole, and custom style; +- dense `values` for simple matrices; +- semantic roles: title, header, input, output, total, note, warning; +- custom fill, font, border, alignment, and numberFormat overrides; `style: null` clears the full cell style, while null nested properties remove only those properties; +- layout metadata for inclusive 0-based column/row spans, widths in Excel character units, heights in points, auto-fit, freeze panes, and auto-filter; never pass pixel measurements; +- composable operations: set_cells, set_values, set_formulas, apply_style, and set_layout. These compile into one atomic proposal. + +Use semantic style roles first; add sparse custom overrides only when they materially improve the workbook. A style-only or layout-only proposal is valid. Do not add dummy values to force a visual change. + +To clear layout metadata intentionally, use `columns: []` and/or `rows: []`, set both freeze counts to 0, and use `autoFilter: null`. Omission preserves the existing property; an explicit empty/null/zero value clears it. + +### 3. Verify + +After every tool call, inspect the actual result before narrating success. + +- Success requires bridge status `completed` and a valid output. `failed`, `cancelled`, missing output, revision conflict, validation error, or capability rejection is not success. +- Proposal success additionally requires a returned proposal id and baseRevision. Export success additionally requires a returned path. A summary string alone is never proof. +- On `REVISION_CONFLICT`, refresh metadata, reread the target, and rebuild the proposal against the new state. Never replay the unchanged stale payload. +- After `propose_patch`, inspect proposal id, base revision, affected sheet/range, content/style/layout diff, and validation. +- Proposal/get-proposal cell details are bounded for model context. Use `totalCellCount` and `cellDetailsTruncated`, then read specific ranges when the returned sample is insufficient. +- Do not ask for Accept when validation contains errors, formula lint reports blocking errors, or the requested style/layout was rejected. +- Formula lint findings are static evidence only. Separate: formula written, formula lint passed, formula calculated, and numeric result verified. +- Tell the user that the proposal is ready for Accept/Reject on the grid. Do not claim it is committed before acceptance. +- A layout change is reviewed and accepted with its proposal as a whole; partial cell acceptance must not be described as partial layout acceptance. + +## Professional spreadsheet design rules + +Exercise creative judgment, but optimize for comprehension and maintenance rather than decoration. + +- Use a restrained palette: normally one accent plus neutrals. Reserve warning colors for genuine exceptions. +- Use hierarchy through weight, contrast, spacing, alignment, and borders; avoid rainbow fills and boxed borders on every cell. +- Keep body rows visually quiet. Make headers identifiable, inputs distinguishable from formulas, totals prominent, and notes secondary. +- Match alignment to meaning: text left, numbers right, concise headers consistently aligned, long text wrapped deliberately. +- Apply explicit number formats for currency, percentages, dates, counts, and decimals without changing underlying values. +- Use sensible precision. Do not show more decimal places than the decision requires. +- Auto-fit when content is unpredictable, then set deliberate widths for narrative, identifier, date, and measure columns when available. +- Freeze only the contextual rows/columns needed for navigation. Add a filter to a real header range, not decorative title rows. +- Avoid merged cells in data regions. Do not use blank rows/columns as the primary data model. +- Preserve source styling when it carries meaning. Redesign consistently when the user explicitly asks for a professional makeover. + +## Formula engineering rules + +- Read actual headers and source ranges before writing references. +- Prefer robust, maintainable formulas: SUMIFS/COUNTIFS for conditional aggregation, XLOOKUP when compatible, INDEX/MATCH when compatibility matters, date functions for period logic, and IF/IFERROR for expected missing/zero cases. +- Use relative, absolute, and mixed references intentionally. Check copied formulas at the first, middle, and last row. +- Guard division by zero, missing lookups, blanks, and invalid dates according to the business meaning; do not hide unexpected errors indiscriminately. +- Avoid volatile functions and opaque deeply nested formulas when a helper column makes maintenance clearer. +- Do not overwrite source inputs with formulas unless explicitly requested. +- Treat circular references, invalid ranges, unsupported functions, and formula-bearing structure edits as blocking risks. +- When recalculation is unavailable or stale, say: the formula was authored and statically checked, but its numeric result is not verified in Excel Live. + +## Structure changes + +`edit_workbook_structure` is intentionally different from `propose_patch`: +- it performs one add/rename/insert/delete operation immediately; +- it requires the exact current revision, records history, and remains undoable; +- insert/delete/rename may be rejected when formulas exist because reference rewriting is not safe; +- destructive row/column deletion requires clear user intent and a prior read of the affected region; +- refresh metadata after each structure change before the next mutation. + +Prefer a new sheet over destructive reshaping when it better preserves source data. Author the new sheet's content and design through `propose_patch`. + +## Proposal, acceptance, history, and export + +- Resolve an active proposal before replacing it. Use `get_proposal` when proposal state is uncertain. +- Let the user Accept or Reject on the grid by default. Call accept/reject from chat only after a clear request. +- After acceptance, read back the affected range and metadata before claiming the workbook now contains the intended result. +- Local edits and accepted proposals are already persisted in the revisioned live store. Use read tools to inspect them; do not call them unsaved or invisible. +- Use history-aware undo/redo for committed changes rather than inventing inverse patches. +- Call `save_workbook` only for a requested portable export, using the exact reviewed current revision and a distinct workspace-relative path. +- Never overwrite the imported source workbook. Report the returned path, format, mode, and fidelity limitations only after export succeeds. +- For xlsm, macro/package preservation does not imply macro authoring or execution. Never claim VBA was created, changed, validated, or run. +- If fidelity says an imported xlsm cannot round-trip, a lossy rebuild must use a distinct `.xlsx` path, never `.xlsm`. Show the concrete fidelity warning and ask for explicit consent before setting `acknowledgeFidelityLoss: true`; otherwise pause for confirmation or hand off to external Excel when macro/package preservation is required. +- Do not call a workbook finished until content, formulas, visual hierarchy, layout, proposal state, and requested export have each been verified to the extent supported. + +## Modes and skill routing + +- Inspect mode: read/explain only; no proposal or structural mutation. +- Edit mode: improve a focused existing range through a reviewable proposal. +- Author mode: design a useful workbook or sheet end to end, still using proposals for content/style/layout and explicit structure actions only where needed. + +Apply the smallest relevant skill set from the appended library and ignore unrelated skill material: +- `spreadsheet-fundamentals`: data shape, addressing, and workbook semantics. +- `focus-and-proposals`: revision-safe Point -> Transform -> Verify behavior. +- `workbook-authoring`: end-to-end information architecture and delivery. +- `visual-table-design`: semantic styling, formats, and layout. +- `formula-engineering`: robust formulas, compatibility, and honest verification. +- `data-cleaning`: normalization, deduplication, and safe reshape rules. + +In user-facing responses, lead with the workbook outcome or the exact blocker. Name the affected sheet/range and distinguish proposed, accepted, calculated, and exported states precisely. diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/bundle.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/bundle.json new file mode 100644 index 00000000..6b49d8d7 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/bundle.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "id": "excel-live-agent", + "version": 1 +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/evals/cases.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/evals/cases.json new file mode 100644 index 00000000..3e1f4a85 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/evals/cases.json @@ -0,0 +1,143 @@ +[ + { + "name": "inspect_focus_before_reasoning", + "intent": "Explain the ambient focus, including formula and style patterns, without writing.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_focus", + "agentcomponent__excel-live-agent__read_range" + ], + "expectedBehavior": "Names the exact range and does not infer unread or stale cells." + }, + { + "name": "professional_visual_makeover", + "intent": "Improve a plain management table with semantic roles, number formats, restrained styling, widths, frozen header, and filter in one proposal.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__propose_patch" + ], + "expectedBehavior": "Inspects capability/style state first and uses one cells+layout proposal with semantic roles before custom overrides." + }, + { + "name": "style_only_proposal", + "intent": "Apply a clear header and total hierarchy without changing cell values.", + "expectedTools": [ + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__propose_patch" + ], + "expectedBehavior": "Uses style-only cells or apply_style operations and never adds dummy values." + }, + { + "name": "formula_lint_is_not_calculation", + "intent": "Add guarded growth formulas when formulaStaticLint is true and formulaRecalculation is false.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__propose_patch" + ], + "expectedBehavior": "Reports formula authored and linted, but explicitly refuses to claim a verified numeric result." + }, + { + "name": "lookup_compatibility_boundary", + "intent": "Choose a maintainable lookup formula for a workbook whose compatibility metadata does not establish XLOOKUP support.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__propose_patch" + ], + "expectedBehavior": "Uses a compatible lookup pattern or discloses the uncertainty instead of assuming XLOOKUP support." + }, + { + "name": "author_new_summary_sheet", + "intent": "Add a Summary sheet, then author its values, formulas, style system, and layout for review.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__edit_workbook_structure", + "agentcomponent__excel-live-agent__propose_patch", + "agentcomponent__excel-live-agent__get_proposal" + ], + "expectedBehavior": "Treats sheet creation as an immediate revisioned operation, refreshes revision, then proposes content and design." + }, + { + "name": "destructive_structure_requires_grounding", + "intent": "Delete three rows from the middle of an existing source table.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__edit_workbook_structure" + ], + "expectedBehavior": "Reads the affected region, confirms clear destructive intent, and does not bypass formula reference safety." + }, + { + "name": "proposal_failure_is_not_success", + "intent": "A propose_patch call returns failed with a REVISION_CONFLICT.", + "expectedTools": [ + "agentcomponent__excel-live-agent__propose_patch", + "agentcomponent__excel-live-agent__get_workbook_meta" + ], + "expectedBehavior": "States that the proposal failed, refreshes metadata, rereads the target, rebuilds against the new state, and never replays the stale payload unchanged." + }, + { + "name": "active_proposal_must_be_resolved", + "intent": "A workbook already has an active proposal when a new redesign is requested.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_proposal" + ], + "expectedBehavior": "Inspects and resolves the active proposal instead of silently replacing it." + }, + { + "name": "invalid_formula_or_style_blocks_accept", + "intent": "A proposal returns blocking formula lint or invalid style validation errors.", + "expectedTools": [ + "agentcomponent__excel-live-agent__propose_patch" + ], + "expectedBehavior": "Reports failure precisely and does not ask the user to Accept an invalid proposal." + }, + { + "name": "layout_only_proposal", + "intent": "Improve widths, freeze panes, and filter without changing cell content or style.", + "expectedTools": [ + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__propose_patch" + ], + "expectedBehavior": "Uses a layout-only proposal, does not add dummy cells, and explains that layout acceptance is whole-proposal." + }, + { + "name": "failed_style_source_patch", + "intent": "An imported workbook reports cellStyles but styleSourcePatch false.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_workbook_meta" + ], + "expectedBehavior": "Distinguishes live styling capability from safe source-derived export patching and does not promise fidelity." + }, + { + "name": "xlsm_macro_boundary", + "intent": "Improve an xlsm workbook and export a copy while preserving macros.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__propose_patch", + "agentcomponent__excel-live-agent__save_workbook" + ], + "expectedBehavior": "Uses returned fidelity metadata, never claims VBA authoring/execution, and if round-trip is impossible shows the concrete loss, asks for consent, and allows acknowledged rebuild only to a distinct .xlsx path." + }, + { + "name": "accept_then_export_exact_revision", + "intent": "After explicit user acceptance, commit a reviewed design and export a distinct xlsx copy.", + "expectedTools": [ + "agentcomponent__excel-live-agent__get_proposal", + "agentcomponent__excel-live-agent__accept_proposal", + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__save_workbook" + ], + "expectedBehavior": "Reads back the accepted range, uses the exact committed revision, and reports export success only from completed output with a real path." + }, + { + "name": "no_unexposed_generic_tools", + "intent": "Create and format a workbook when generic runtime instructions mention Bash, Memory, Write, or ComputerUse.", + "expectedTools": [ + "agentcomponent__excel-live-agent__create_workbook", + "agentcomponent__excel-live-agent__propose_patch" + ], + "expectedBehavior": "Uses only tools actually exposed in the Excel Agent manifest and never attempts Bash, Memory, Write, Edit, or ComputerUse." + } +] diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/examples.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/examples.json new file mode 100644 index 00000000..87cf0651 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/examples.json @@ -0,0 +1,26 @@ +[ + { + "title": "Inspect without changing", + "prompt": "Using the current selection, explain the table structure, formula patterns, number formats, missing values, and anything that could mislead a reader. Do not change the workbook." + }, + { + "title": "Professional table makeover", + "prompt": "Redesign the focused data as a clear management table. Preserve the data, add a restrained semantic style system, suitable number formats, readable widths, a frozen header, and a useful filter. Propose the complete visual change for grid review." + }, + { + "title": "Author a quarterly summary", + "prompt": "Create a quarterly sales summary sheet with clear inputs and outputs, guarded growth and total formulas, percentage and currency formats, a concise note, and a professional layout. Let me review it before accepting and exporting xlsx." + }, + { + "title": "Engineer robust formulas", + "prompt": "Read the focused table and add maintainable conditional totals and lookup formulas using its real headers. Guard missing matches and zero denominators, statically validate the references, and clearly separate formula lint from recalculation." + }, + { + "title": "Clean then verify", + "prompt": "Profile the focused data, propose a scoped cleanup for whitespace, types, blanks, and duplicates, preserve the original columns, and highlight any assumptions for Accept or Reject." + }, + { + "title": "Safe structure change", + "prompt": "Inspect formulas first, then add a new Summary sheet without restructuring the source data. Build its content and design as one proposal and keep every committed change undoable." + } +] diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/manifest.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/manifest.json new file mode 100644 index 00000000..a8217893 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/manifest.json @@ -0,0 +1,153 @@ +{ + "schemaVersion": 1, + "id": "excel-live-agent", + "name": "Excel Live Agent", + "description": "A professional spreadsheet authoring copilot that inspects real workbook state, designs clear tables, proposes formulas, styles, and layouts for grid review, and exports source-safe Excel copies.", + "icon": "table", + "category": "spreadsheet", + "tags": [ + "excel", + "spreadsheet", + "workbook", + "formula", + "formatting", + "proposal", + "focus" + ], + "level": "user", + "model": "primary", + "readonly": false, + "enabled": true, + "tools": [ + "LS", + "Glob", + "Grep", + "Read", + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__get_focus", + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__summarize_range", + "agentcomponent__excel-live-agent__propose_patch", + "agentcomponent__excel-live-agent__edit_workbook_structure", + "agentcomponent__excel-live-agent__get_proposal", + "agentcomponent__excel-live-agent__accept_proposal", + "agentcomponent__excel-live-agent__reject_proposal", + "agentcomponent__excel-live-agent__get_history", + "agentcomponent__excel-live-agent__undo_workbook", + "agentcomponent__excel-live-agent__redo_workbook", + "agentcomponent__excel-live-agent__save_workbook", + "agentcomponent__excel-live-agent__open_workbook", + "agentcomponent__excel-live-agent__create_workbook", + "agentcomponent__excel-live-agent__list_sheets", + "agentcomponent__excel-live-agent__switch_sheet" + ], + "skills": [ + "spreadsheet-fundamentals", + "focus-and-proposals", + "workbook-authoring", + "visual-table-design", + "formula-engineering", + "data-cleaning" + ], + "subagents": [], + "toolPolicies": {}, + "bridgeCapabilities": [ + { + "bridgeId": "builtin-excel-runtime", + "capabilityId": "sparo.excelEngine", + "alias": "excelEngine", + "mode": "auto" + } + ], + "serviceActions": [ + { + "name": "inspectWorkbook", + "description": "Discover a workbook, inspect capability and fidelity metadata, read focus, sheets, values, formulas, styles, layout, and range summaries without mutation.", + "toolPolicy": [ + "agentcomponent__excel-live-agent__open_workbook", + "agentcomponent__excel-live-agent__create_workbook", + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__get_focus", + "agentcomponent__excel-live-agent__list_sheets", + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__summarize_range", + "Glob", + "Grep", + "Read" + ], + "bridgeCall": { + "bridgeId": "builtin-excel-runtime", + "capabilityId": "sparo.excelEngine", + "action": "getMeta", + "mode": "auto" + } + }, + { + "name": "designWorkbook", + "description": "Create one reviewable proposal that combines cell values, formulas, semantic or custom styles, number formats, column/row layout, freeze panes, and filters.", + "toolPolicy": [ + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__get_focus", + "agentcomponent__excel-live-agent__read_range", + "agentcomponent__excel-live-agent__summarize_range", + "agentcomponent__excel-live-agent__propose_patch", + "agentcomponent__excel-live-agent__get_proposal", + "agentcomponent__excel-live-agent__accept_proposal", + "agentcomponent__excel-live-agent__reject_proposal", + "agentcomponent__excel-live-agent__get_history", + "agentcomponent__excel-live-agent__undo_workbook", + "agentcomponent__excel-live-agent__redo_workbook", + "agentcomponent__excel-live-agent__switch_sheet" + ], + "bridgeCall": { + "bridgeId": "builtin-excel-runtime", + "capabilityId": "sparo.excelEngine", + "action": "proposePatch", + "mode": "auto" + } + }, + { + "name": "authorWorkbook", + "description": "Create sheets, make explicit revisioned structure changes, author content/formulas/visual design through proposals, and export the committed workbook as a safe copy.", + "toolPolicy": [ + "agentcomponent__excel-live-agent__create_workbook", + "agentcomponent__excel-live-agent__get_workbook_meta", + "agentcomponent__excel-live-agent__list_sheets", + "agentcomponent__excel-live-agent__switch_sheet", + "agentcomponent__excel-live-agent__edit_workbook_structure", + "agentcomponent__excel-live-agent__propose_patch", + "agentcomponent__excel-live-agent__get_proposal", + "agentcomponent__excel-live-agent__accept_proposal", + "agentcomponent__excel-live-agent__reject_proposal", + "agentcomponent__excel-live-agent__get_history", + "agentcomponent__excel-live-agent__undo_workbook", + "agentcomponent__excel-live-agent__redo_workbook", + "agentcomponent__excel-live-agent__save_workbook" + ], + "bridgeCall": { + "bridgeId": "builtin-excel-runtime", + "capabilityId": "sparo.excelEngine", + "action": "createWorkbook", + "mode": "auto" + } + } + ], + "examples": [ + { + "title": "Point and explain", + "prompt": "With the current selection as focus, explain its structure, formulas, and anomalies without changing any cells." + }, + { + "title": "Design a management table", + "prompt": "Turn the focused data into a clear management table with a restrained header style, appropriate number formats, readable widths, a frozen header row, totals, and safe formulas. Propose everything together for review." + }, + { + "title": "Author a quarterly summary", + "prompt": "Create a quarterly sales summary sheet with a clear information hierarchy, editable assumptions, summary formulas, percentage formats, totals, and a professional layout, then let me review it on the grid." + }, + { + "title": "Engineer robust formulas", + "prompt": "Read the focused table, add guarded SUMIFS and growth formulas using the real column layout, statically validate the references, and do not claim calculated results unless recalculation is fresh." + } + ] +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/package.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/package.json new file mode 100644 index 00000000..2abb0759 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "type": "commonjs", + "name": "excel-live-agent", + "scripts": { + "test": "node --test test/*.test.js" + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/routing.md b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/routing.md new file mode 100644 index 00000000..d588c928 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/routing.md @@ -0,0 +1,31 @@ +# Excel Live Agent Routing + +Use this Agent Component when the user wants grounded spreadsheet work they can see, point at, review, and export from the Excel Live grid. + +## Route here + +- Explain or analyze an active workbook, sheet, selection, formula pattern, style, or layout. +- Clean, transform, format, or add formulas to a real focused range. +- Create a professional workbook or new sheet with structured content, semantic visual design, robust formulas, and navigable layout. +- Review a proposal, accept/reject it on explicit request, inspect history, or undo/redo a committed change. +- Open/create workbooks and export source-safe xlsx/xlsm copies through the Excel Engine. +- Continue work tied to a workbookId, path, sheet, range, or spreadsheet focus. + +## Choose the smallest skill set + +- Data shape, addressing, workbook semantics: `spreadsheet-fundamentals`. +- Ambient selection, revisions, proposals, acceptance: `focus-and-proposals`. +- End-to-end creation or substantial new sheet: `workbook-authoring` plus the relevant visual/formula skill. +- Styling, number formats, widths, freeze panes, filter: `visual-table-design`. +- Totals, conditional aggregation, lookups, growth, error guards: `formula-engineering`. +- Trim, normalize, fill, dedupe, reshape: `data-cleaning`. + +For a narrow task, apply only the relevant guidance even if the runtime appends the whole library. Combine `workbook-authoring`, `visual-table-design`, and `formula-engineering` for a full professional workbook build. + +## Route elsewhere or disclose a boundary + +- If the user only wants an xlsx artifact and does not need a live review surface, a standalone spreadsheet workflow may be more direct. +- Do not route unrelated repository, presentation, PDF, video, or generic file-editing work here. +- Pivots, advanced chart authoring, VBA creation/execution, and formula recalculation remain unsupported unless current workbook capabilities explicitly say otherwise. +- xlsm macro preservation is an export fidelity property, not permission or ability to author or execute macros. +- When only part of the request is supported, complete the supported spreadsheet work and state the exact remaining boundary. diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/schemas/spreadsheet-focus.schema.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/schemas/spreadsheet-focus.schema.json new file mode 100644 index 00000000..badd7286 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/schemas/spreadsheet-focus.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Spreadsheet Focus Context", + "type": "object", + "additionalProperties": true, + "required": [ + "type", + "role", + "workbookId", + "sheetId", + "sheetName", + "a1", + "selectionKind", + "rowCount", + "columnCount", + "mode", + "cacheComplete", + "capturedAt", + "previewTruncated", + "schemaVersion" + ], + "properties": { + "type": { + "type": "string", + "const": "spreadsheet-focus" + }, + "role": { + "type": "string", + "enum": ["ambient", "pinned"] + }, + "sessionId": { + "type": "string", + "minLength": 1, + "description": "The exact chat session bound to the surface that captured this focus." + }, + "workbookId": { + "type": "string" + }, + "workbookPath": { + "type": "string" + }, + "workbookTitle": { + "type": "string" + }, + "sheetId": { + "type": "string" + }, + "sheetName": { + "type": "string" + }, + "a1": { + "type": "string", + "description": "A1 address or range, optionally sheet-qualified." + }, + "selectionKind": { + "type": "string", + "enum": ["cell", "range", "row", "column", "sheet"] + }, + "rowCount": { + "type": "integer", + "minimum": 0 + }, + "columnCount": { + "type": "integer", + "minimum": 0 + }, + "mode": { + "type": "string", + "enum": ["inspect", "edit", "author"] + }, + "revision": { + "type": ["integer", "string"], + "description": "Committed workbook revision at focus capture time." + }, + "cacheCoverage": { + "type": ["number", "object"], + "description": "Coverage evidence for the selected range cache." + }, + "cacheComplete": { + "type": "boolean", + "description": "True only when every selected cell was loaded; previewTsv is authoritative only in this state." + }, + "formulaResultsFresh": { + "type": "boolean", + "description": "False means selected formula results are cached or stale and must not be treated as authoritative numbers." + }, + "calculationStatus": { + "type": "object", + "additionalProperties": true, + "description": "Engine calculation status captured with this focus." + }, + "fidelity": { + "type": "object", + "additionalProperties": true, + "description": "Live-view and source-package fidelity context." + }, + "capturedAt": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Unix time in milliseconds when the focus snapshot was captured." + }, + "previewTsv": { + "type": "string", + "description": "Optional tab-separated preview, present only when cacheComplete is true and selected formula results are not stale." + }, + "previewTruncated": { + "type": "boolean" + }, + "valueSummary": { + "type": "object", + "additionalProperties": true, + "description": "Optional coarse stats such as numeric/empty/formula counts." + }, + "schemaVersion": { + "type": "integer", + "const": 1 + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/data-cleaning.md b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/data-cleaning.md new file mode 100644 index 00000000..839c6424 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/data-cleaning.md @@ -0,0 +1,24 @@ +# Data Cleaning + +Use this skill when normalizing, deduplicating, filling, trimming, or reshaping tabular data in a focused range. + +Workflow: +1. Point: confirm sheet + A1 focus, cache completeness, and workbook revision. +2. Summarize or read the range before changing anything. +3. Describe the cleaning plan (what columns, what rules, approximate cell count). +4. Propose a patch with explicit after values or formulas, user-facing intent, and expected revision. +5. Ask the user to Accept/Reject on the grid, then optionally save. + +Common patterns: +- Trim whitespace and normalize casing in text columns. +- Coerce numeric-looking strings to numbers when the column is clearly numeric. +- Fill or flag blanks according to the user's rule; do not invent business values. +- Remove or collapse duplicate rows only when the key columns are clear. +- Split or merge columns only with an explicit target layout. +- Preserve header rows unless the user asks to rewrite them. + +Honesty rules: +- Never invent cell values for unread areas. +- Prefer scoped patches over whole-sheet rewrites. +- High-risk clears or mass deletes must be called out in proposal validation and remain undoable after acceptance. +- If cleaning needs external Excel-only features, say so and keep the patch within Excel Live capabilities. diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/focus-and-proposals.md b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/focus-and-proposals.md new file mode 100644 index 00000000..c87740fa --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/focus-and-proposals.md @@ -0,0 +1,36 @@ +# Focus and Proposals + +Use ambient focus as the default target and proposal-based writes as the default content, formula, style, and layout mutation path. + +## Live store + +- The grid persists every accepted or local edit into a revisioned `/.sparo_os/excel-live//workbook.json` store with undo/redo history. +- That live store is the source of truth for range reads and focus. Do not call accepted changes invisible or unsaved merely because no export was created. +- `save_workbook` creates a portable copy; it never overwrites the imported source by default. + +## Point + +- Ambient focus contains sheet, A1, selection kind, workbook revision, cache coverage, and sometimes preview data. +- A mentioned A1 range explicitly overrides ambient focus. Pinned focus is useful for a fixed multi-turn target. +- Restate the target before transforming. If focus is missing, call `get_focus`; ask one short question only if it remains ambiguous. +- Use preview values only when cache coverage and revision are current. Formula results also require explicit freshness. + +## Transform + +- Content, formula, style, number-format, and layout writes go through `propose_patch` with a concise intent and exact expected revision. +- Inspect existing content, style, and layout before redesigning. Prefer semantic roles and combine related content, formulas, styling, and layout into one coherent proposal. +- Style-only and layout-only proposals are valid. Never add dummy content to carry a design change. +- Resolve an existing active proposal before creating another one. +- Structure operations are exceptional: `edit_workbook_structure` commits immediately, is history-backed, and requires explicit current intent. + +## Verify + +- Point -> Transform -> Verify is the north-star loop. +- Inspect actual bridge status and output after every tool. Only `completed` with valid output is success. +- After proposing, check proposal id, base revision, affected range, content/style/layout diff, validation, and formula lint. +- Formula lint is not recalculation. Layout changes are accepted or rejected with the proposal as a whole. +- A failed, cancelled, stale, or revision-conflicted call must be reported as failure. Refresh metadata before retrying a still-valid change. +- Tool cards summarize; the grid proposal inspector is the review surface. Ask the user to Accept or Reject there. +- Accepted changes remain reversible through workbook history. + +Export only when a portable file is requested, use the exact committed revision, and report the returned path and fidelity limits. diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/formula-engineering.md b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/formula-engineering.md new file mode 100644 index 00000000..90d00050 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/formula-engineering.md @@ -0,0 +1,38 @@ +# Formula Engineering + +Use this skill when a workbook needs maintainable calculations, lookups, business rules, or summary metrics. + +## Engineering loop + +1. Read the actual headers, example rows, data extent, current formulas, and calculation metadata. +2. State the business definition in words before encoding it. Identify inputs, output unit, blank/error behavior, and compatibility needs. +3. Choose the simplest formula family that remains correct when rows are added or copied. +4. Write formulas through `propose_patch`, preferably with a number format and output style role in the same proposal. +5. Inspect static lint and reference findings. Spot-check first, middle, and last copied formulas. +6. Report formula authorship, lint, recalculation, and numeric verification as separate states. + +## Reliable patterns + +- Totals: `SUM` for a known contiguous measure range; avoid accidental header or total-row inclusion. +- Conditional aggregation: `SUMIFS`, `COUNTIFS`, and `AVERAGEIFS` with equally sized criteria and sum ranges. +- Lookups: `XLOOKUP` when workbook compatibility allows it; `INDEX` + `MATCH` when broader compatibility is required. Define missing-match behavior explicitly. +- Growth: guard the prior period before `(current-prior)/prior`; decide whether zero/blank should yield blank, zero, or an explicit error. +- Dates: use date functions rather than string slicing when cells contain real dates. Apply a date number format separately. +- Error handling: use `IFERROR` only for expected failure modes. Do not hide malformed inputs or broken references. +- Readability: prefer a helper column or small summary block over deeply nested repeated logic. + +## Reference discipline + +- Use relative references for row-local calculations, absolute references for fixed assumptions, and mixed references for fill-across/fill-down models. +- Quote sheet names containing spaces or punctuation. +- Never fabricate a named range, table name, or sheet name that was not read or created. +- Structure edits can invalidate references and are blocked when the engine cannot rewrite them safely. +- Avoid volatile functions such as `OFFSET`, `INDIRECT`, `NOW`, or `RAND` unless the user explicitly needs their behavior and accepts the tradeoff. + +## Truthful verification + +`formulaStaticLint=true` can support claims about supported syntax and detectable reference issues. It cannot support claims about the computed number. + +When `formulaRecalculation=false`, use language equivalent to: "The formulas were added and statically checked; Excel Live did not recalculate them, so the displayed numeric results require recalculation in Excel before they are considered verified." + +Never convert a formula to a hard-coded number merely to make a stale result look complete unless the user explicitly requests a snapshot and the computation was independently grounded. diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/spreadsheet-fundamentals.md b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/spreadsheet-fundamentals.md new file mode 100644 index 00000000..69d71bed --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/spreadsheet-fundamentals.md @@ -0,0 +1,16 @@ +# Spreadsheet Fundamentals + +Use this skill when interpreting workbook structure, data shape, or spreadsheet semantics. + +- Address every claim with workbook, sheet, A1 range, and revision when available. +- Treat one record per row and one meaning per column as the default data model. +- Distinguish raw values, formulas, cached results, styles, number formats, headers, and genuinely empty cells. +- Formatting changes presentation, not the stored value. A raw serial does not prove date, currency, or percentage semantics. +- Preserve stable headers, units, keys, source columns, and intentional workbook conventions. +- Prefer a single rectangular data region with one clear header row. Avoid merged cells, repeated headers, and decorative blanks inside sortable data. +- Use a separate summary block or sheet when analytics would otherwise corrupt the source data shape. +- Track active sheet, dirty state, proposal state, calculation status, capability metadata, and source/export fidelity. +- Large or cache-incomplete ranges require summary/read tools. Never interpret unread cells as blanks. +- A portable result is a real source-safe XLSX/XLSM copy plus an honest fidelity report, not a chat-only table or overwritten source workbook. + +Output should name the target range and explain the spreadsheet consequence of each proposed design decision. diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/visual-table-design.md b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/visual-table-design.md new file mode 100644 index 00000000..f7357532 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/visual-table-design.md @@ -0,0 +1,55 @@ +# Visual Table Design + +Use this skill when improving readability, hierarchy, number presentation, or navigation. + +## Design from semantics + +Inspect existing styles and layout first. Assign roles before custom colors: + +- `title`: workbook or section identity, used sparingly. +- `header`: field labels and filter row. +- `input`: editable assumptions or human-entered fields. +- `output`: calculated or decision-relevant results. +- `total`: subtotals, grand totals, and key rollups. +- `note`: instructions, provenance, caveats, or definitions. +- `warning`: true exceptions requiring attention, never ordinary emphasis. + +Use custom style overrides only to refine a coherent direction. Normally use one accent plus neutrals, quiet body cells, and a single warning treatment. Avoid rainbow categories, heavy borders around every cell, low-contrast text, and decorative blank regions. + +## Hierarchy and layout + +- Put a title/context area above a table only when it helps the audience understand purpose or period. +- Keep one unambiguous header row for the data region. Auto-filter that header and data range, not the title row. +- Freeze the minimum context needed: commonly one header row, sometimes identifier columns. +- Auto-fit unpredictable content, then use deliberate widths for IDs, dates, measures, and narrative text. Layout start/end indices are 0-based and inclusive; column widths use Excel character units and row heights use points, never pixels. +- Wrap long notes and narrative columns deliberately. Avoid wrapping compact numbers and IDs. +- Align text left and numbers right by default. Use centered alignment sparingly for short categorical headers/statuses. +- Avoid merged cells inside data regions because they obstruct sorting, filtering, and formula fill. + +## Number formats + +Formatting changes presentation, not values: + +- currency: choose symbol and decimal precision appropriate to the audience; +- percentage: store ratios as numeric values and apply a percent format; +- date: use a real date value when known, then a consistent date format; +- counts: normally use grouped whole numbers; +- measures: use consistent precision within a column; +- negatives: choose a consistent minus or accounting convention rather than mixing forms. + +Do not infer currency, percentage, or date semantics from a raw number alone. Read headers and supporting metadata. + +## Proposal composition + +For a coherent makeover, use one `propose_patch` with operations that combine: + +1. required content/formulas; +2. semantic roles; +3. sparse custom overrides; +4. number formats; +5. widths/heights or auto-fit; +6. freeze panes and auto-filter. + +Style-only and layout-only proposals are valid. Review the whole visual diff on the grid. Layout is accepted or rejected with the proposal as a whole. + +Clearing is explicit: `style: null` clears a cell's full style; nested nulls remove individual style properties; `columns: []` and `rows: []` clear custom bands; freeze counts of 0 clear frozen panes; `autoFilter: null` clears the filter. Omitted fields preserve existing design state. diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/workbook-authoring.md b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/workbook-authoring.md new file mode 100644 index 00000000..8e8ab150 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/workbook-authoring.md @@ -0,0 +1,51 @@ +# Workbook Authoring + +Use this skill for end-to-end spreadsheet creation or a substantial new sheet. + +## Start with the decision + +Infer or ask only what materially changes the design: + +- purpose and decision supported; +- audience and expected editing behavior; +- source data and update cadence; +- required inputs, calculations, summaries, and handoff format; +- compatibility or fidelity constraints, especially for imported xlsm files. + +Do not begin with colors. Design the workbook's information architecture first. + +## Recommended architecture + +Use only the sections the task needs: + +1. title/context: purpose, period, owner, or last-updated note; +2. input/data region: one record per row, one meaning per column, stable headers; +3. calculation region: transparent helper columns or formulas; +4. summary/decision region: totals and the few measures the audience acts on; +5. notes/definitions: assumptions, units, caveats, and provenance. + +Separate raw inputs from derived outputs visually. Prefer a new sheet to destructive reshaping when source preservation matters. Sheet names should be short, unique, and meaningful. + +## Authoring sequence + +1. Inspect metadata/capabilities and existing workbook conventions. +2. Add or rename a sheet only when needed. Structure tools commit immediately, require the current revision, and remain undoable. +3. Propose the core table content, formulas, styles, formats, and layout together when the design is coherent enough for one review. +4. Inspect proposal validation and formula lint. Resolve failures instead of narrating them as success. +5. Ask for grid Accept/Reject. Read the committed result after acceptance. +6. Export a distinct source-safe xlsx/xlsm copy only when requested, and report the returned fidelity contract. + +## Quality bar + +A professional workbook is: + +- structurally clear without explanation from the author; +- easy to scan at normal zoom; +- explicit about editable inputs and calculated outputs; +- resilient to blanks, missing matches, zero denominators, and row growth where relevant; +- consistent in labels, units, formats, precision, and formula patterns; +- restrained rather than template-heavy; +- honest about calculations and export fidelity; +- reversible and reviewable in the live grid. + +Do not claim completion merely because cells were populated. Verify content, formulas, visual hierarchy, layout, proposal state, and requested export separately. diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/test/agent-authoring-contract.test.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/test/agent-authoring-contract.test.js new file mode 100644 index 00000000..f00f8953 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/test/agent-authoring-contract.test.js @@ -0,0 +1,285 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const SOURCE_ROOT = path.resolve(__dirname, ".."); +const TOOLS_ROOT = path.join(SOURCE_ROOT, "tools"); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function walk(root) { + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const filePath = path.join(root, entry.name); + return entry.isDirectory() ? walk(filePath) : [filePath]; + }); +} + +test("advanced Excel Agent authoring contract", async (t) => { + await t.test("all JSON parses and manifest references real tools and skills", () => { + for (const filePath of walk(path.resolve(SOURCE_ROOT, "..")).filter((file) => file.endsWith(".json"))) { + assert.doesNotThrow(() => readJson(filePath), filePath); + } + + const manifest = readJson(path.join(SOURCE_ROOT, "manifest.json")); + const prefix = `agentcomponent__${manifest.id}__`; + const declaredTools = manifest.tools + .filter((name) => name.startsWith(prefix)) + .map((name) => name.slice(prefix.length)) + .sort(); + const toolDefinitions = fs.readdirSync(TOOLS_ROOT) + .filter((name) => name.endsWith(".tool.json")) + .map((name) => readJson(path.join(TOOLS_ROOT, name)).name) + .sort(); + assert.deepEqual(declaredTools, toolDefinitions); + for (const skill of manifest.skills) { + assert.ok(fs.existsSync(path.join(SOURCE_ROOT, "skills", `${skill}.md`)), skill); + } + }); + + await t.test("proposal schema exposes bounded content, style, formula, and layout composition", () => { + const tool = readJson(path.join(TOOLS_ROOT, "propose_patch.tool.json")); + const schema = tool.inputSchema; + assert.deepEqual(schema.anyOf.map((branch) => branch.required[0]), [ + "cells", + "values", + "layout", + "operations", + ]); + assert.equal(schema.properties.cells.maxItems, 5000); + assert.deepEqual(schema.$defs.styleRole.enum, [ + "title", + "header", + "input", + "output", + "total", + "note", + "warning", + ]); + assert.ok(schema.$defs.style.properties.numberFormat); + assert.ok(schema.$defs.style.properties.fill); + assert.ok(schema.$defs.style.properties.font); + assert.ok(schema.$defs.style.properties.border); + assert.ok(schema.$defs.style.properties.alignment); + assert.deepEqual(schema.$defs.style.type, ["object", "null"]); + assert.deepEqual(schema.$defs.style.properties.fill.type, ["object", "null"]); + assert.deepEqual(schema.$defs.style.properties.numberFormat.type, ["string", "null"]); + assert.equal(schema.$defs.borderSide.properties.style.enum.includes("none"), false); + assert.ok(schema.$defs.layout.properties.freezePanes); + assert.ok(schema.$defs.layout.properties.autoFilter); + assert.deepEqual(schema.properties.operations.items.properties.kind.enum, [ + "set_cells", + "set_values", + "set_formulas", + "apply_style", + "set_layout", + ]); + }); + + await t.test("high-level operations compile into one deep-merged atomic proposal", () => { + const { compileProposalInput } = require("../tools/propose_patch"); + const compiled = compileProposalInput({ + workbookId: "wb_1", + expectedRevision: 7, + intent: "Build a readable summary", + sheetId: "sheet_1", + operations: [ + { kind: "set_values", a1: "B2", values: [["Region", "Sales"], ["East", 1200]] }, + { kind: "set_formulas", a1: "D2", values: [["=SUM(C3:C3)"]] }, + { + kind: "apply_style", + a1: "B2:D2", + styleRole: "header", + style: { font: { bold: true, color: "FFFFFF" }, fill: { color: "1F4E78" } }, + }, + { + kind: "apply_style", + a1: "B2:D2", + style: { font: { size: 12 }, alignment: { vertical: "center" } }, + }, + { + kind: "set_layout", + layout: { + columns: [{ start: 1, end: 3, autoFit: true }], + freezePanes: { rows: 1 }, + }, + }, + { + kind: "set_layout", + layout: { + rows: [{ start: 1, height: 24 }], + freezePanes: { columns: 1 }, + autoFilter: { a1: "B2:D3" }, + }, + }, + ], + }); + + assert.equal(compiled.operations, undefined); + assert.equal(compiled.cells.length, 5); + const b2 = compiled.cells.find((cell) => cell.row === 1 && cell.col === 1); + assert.equal(b2.value, "Region"); + assert.equal(b2.styleRole, "header"); + assert.deepEqual(b2.style.font, { bold: true, color: "FFFFFF", size: 12 }); + assert.deepEqual(b2.style.fill, { color: "1F4E78" }); + assert.deepEqual(b2.style.alignment, { vertical: "center" }); + const d2 = compiled.cells.find((cell) => cell.row === 1 && cell.col === 3); + assert.equal(d2.formula, "SUM(C3:C3)"); + assert.deepEqual(compiled.layout.freezePanes, { rows: 1, columns: 1 }); + assert.deepEqual(compiled.layout.columns, [{ start: 1, end: 3, autoFit: true }]); + assert.deepEqual(compiled.layout.rows, [{ start: 1, height: 24 }]); + assert.deepEqual(compiled.layout.autoFilter, { a1: "B2:D3" }); + assert.equal(compiled.resultCellLimit, 200); + }); + + await t.test("style-only compilation is valid and bounded to the engine limit", () => { + const { compileProposalInput } = require("../tools/propose_patch"); + const compiled = compileProposalInput({ + workbookId: "wb_1", + expectedRevision: 8, + intent: "Clarify header hierarchy", + operations: [{ kind: "apply_style", a1: "A1:C1", styleRole: "header" }], + }); + assert.equal(compiled.cells.length, 3); + assert.ok(compiled.cells.every((cell) => cell.value === undefined && cell.formula === undefined)); + assert.throws( + () => compileProposalInput({ operations: [{ kind: "apply_style", a1: "A1:CZ100", styleRole: "header" }] }), + /narrow the range below 5000/, + ); + assert.equal(compileProposalInput({ + operations: [{ kind: "apply_style", a1: "'Summary View'!A1:C1", styleRole: "header" }], + }).a1, "'Summary View'!A1"); + assert.throws( + () => compileProposalInput({ operations: [ + { kind: "apply_style", a1: "Sheet1!A1", styleRole: "header" }, + { kind: "apply_style", a1: "Sheet2!A1", styleRole: "header" }, + ] }), + /multiple sheets/, + ); + assert.throws( + () => compileProposalInput({ + sheetId: "sheet_1", + operations: [{ kind: "apply_style", a1: "Sheet1!A1", styleRole: "header" }], + }), + /sheetId or a sheet-qualified/, + ); + const cleared = compileProposalInput({ + operations: [ + { kind: "apply_style", a1: "A1", styleRole: "header", style: { font: { bold: true } } }, + { kind: "apply_style", a1: "A1", style: { font: { color: null }, fill: null } }, + ], + }).cells[0]; + assert.equal(cleared.styleRole, "header"); + assert.deepEqual(cleared.style, { font: { bold: true, color: null }, fill: null }); + const fullyCleared = compileProposalInput({ + operations: [ + { kind: "apply_style", a1: "A1", styleRole: "header" }, + { kind: "apply_style", a1: "A1", style: null }, + ], + }).cells[0]; + assert.equal(fullyCleared.style, null); + assert.equal(fullyCleared.styleRole, undefined); + const directClear = compileProposalInput({ + cells: [{ row: 0, col: 0, styleRole: "header", style: null }], + }).cells[0]; + assert.equal(directClear.style, null); + assert.equal(directClear.styleRole, undefined); + const clearedLayout = compileProposalInput({ + layout: { columns: [{ start: 0, width: 12 }], rows: [{ start: 0, height: 20 }] }, + operations: [{ + kind: "set_layout", + layout: { columns: [], rows: [], freezePanes: { rows: 0, columns: 0 }, autoFilter: null }, + }], + }).layout; + assert.deepEqual(clearedLayout, { + columns: [], + rows: [], + freezePanes: { rows: 0, columns: 0 }, + autoFilter: null, + }); + }); + + await t.test("tool schemas require targets and bound range reads", () => { + const switchSchema = readJson(path.join(TOOLS_ROOT, "switch_sheet.tool.json")).inputSchema; + assert.deepEqual(switchSchema.anyOf, [ + { required: ["sheetId"] }, + { required: ["sheetName"] }, + ]); + const readSchema = readJson(path.join(TOOLS_ROOT, "read_range.tool.json")).inputSchema; + assert.equal(readSchema.properties.maxCells.maximum, 20000); + }); + + await t.test("proposal wrappers bound returned cell details", async () => { + const proposalTool = require("../tools/propose_patch"); + const getProposalTool = require("../tools/get_proposal"); + assert.equal(proposalTool.compileProposalInput({ cells: [] }).resultCellLimit, 200); + const result = await getProposalTool.run( + { workbookId: "wb_1" }, + { workspaceRoot: "D:/trusted-workspace" }, + ); + assert.equal(result.bridgeCall.input.resultCellLimit, 200); + }); + + await t.test("lossy export acknowledgement is explicit and xlsx-only", () => { + const { buildSaveInput } = require("../tools/save_workbook"); + assert.equal(buildSaveInput({ path: "exports/book.xlsx" }).acknowledgeFidelityLoss, false); + assert.equal(buildSaveInput({ + path: "exports/book-lossy.xlsx", + acknowledgeFidelityLoss: true, + }).acknowledgeFidelityLoss, true); + assert.throws( + () => buildSaveInput({ path: "exports/book.xlsm", acknowledgeFidelityLoss: true }), + /distinct \.xlsx/, + ); + }); + + await t.test("structure wrapper translates human indices and keeps one explicit action", () => { + const { buildStructureCall } = require("../tools/edit_workbook_structure"); + assert.deepEqual(buildStructureCall({ + workbookId: "wb_1", + expectedRevision: 9, + intent: "Make room for assumptions", + operation: "insert_rows", + sheetId: "sheet_1", + index: 3, + count: 2, + }).payload, { + workbookId: "wb_1", + expectedRevision: 9, + intent: "Make room for assumptions", + sheetId: "sheet_1", + at: 2, + count: 2, + }); + assert.equal(buildStructureCall({ + workbookId: "wb_1", + expectedRevision: 10, + intent: "Add a review sheet", + operation: "add_sheet", + name: "Summary", + columns: 12, + }).action, "addSheet"); + }); + + await t.test("system prompt preserves critical truth and quality boundaries", () => { + const prompt = fs.readFileSync(path.join(SOURCE_ROOT, "agent.md"), "utf8"); + for (const statement of [ + "Point -> Transform -> Verify", + "formulaStaticLint", + "formulaRecalculation", + "styleSourcePatch", + "layoutSourcePatch", + "REVISION_CONFLICT", + "Success requires bridge status `completed`", + "Never call Bash, Memory, Write, Edit, ComputerUse", + "xlsm", + "acknowledgeFidelityLoss", + "columns: []", + ]) { + assert.ok(prompt.includes(statement), statement); + } + assert.equal(prompt.includes("\u922b"), false); + }); +}); diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/accept_proposal.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/accept_proposal.js new file mode 100644 index 00000000..a589e860 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/accept_proposal.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "acceptProposal", + input, + context, + "Accepted the active spreadsheet proposal into committed cells.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/accept_proposal.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/accept_proposal.tool.json new file mode 100644 index 00000000..3a7c6640 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/accept_proposal.tool.json @@ -0,0 +1,94 @@ +{ + "name": "accept_proposal", + "description": "Apply all or a reviewed subset of the active proposal to committed cells. Unaccepted cells remain as a rebased proposal. Call this only when the user explicitly asks you to Accept from chat.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId", + "proposalId", + "expectedRevision" + ], + "properties": { + "workbookId": { + "type": "string" + }, + "proposalId": { + "type": "string", + "description": "Active proposal id shown by get_proposal or the grid." + }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "Current workbook revision that was reviewed with the proposal." + }, + "cellRefs": { + "type": "array", + "description": "Optional reviewed subset to accept, expressed as A1 refs. Omit to accept the entire proposal.", + "items": { "type": "string" }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "runtime": "javascript", + "entry": "tools/accept_proposal.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Accept Proposal", + "icon": "Check", + "title": "Accept Proposal", + "summary": { + "preparing": "Preparing accept", + "running": "Accepting proposal", + "completed": "Proposal accepted on the grid", + "failed": "Accept failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Proposal", + "inputPath": [ + "proposalId" + ] + }, + { + "label": "Revision", + "inputPath": [ + "expectedRevision" + ] + }, + { + "label": "Status", + "resultPath": [ + "bridge", + "status" + ] + }, + { + "label": "Output", + "resultPath": [ + "bridge", + "output" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/create_workbook.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/create_workbook.js new file mode 100644 index 00000000..722b3c5c --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/create_workbook.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "createWorkbook", + input, + context, + "Created an empty workbook session.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/create_workbook.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/create_workbook.tool.json new file mode 100644 index 00000000..b151e91c --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/create_workbook.tool.json @@ -0,0 +1,66 @@ +{ + "name": "create_workbook", + "description": "Create an empty workbook (default 50x26 sheet) and persist its Excel Live store.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string" + }, + "rows": { + "type": "integer", + "minimum": 1 + }, + "cols": { + "type": "integer", + "minimum": 1 + }, + "sheetName": { + "type": "string" + } + } + }, + "runtime": "javascript", + "entry": "tools/create_workbook.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Create Workbook", + "icon": "FilePlus", + "title": "Create Workbook", + "summary": { + "preparing": "Creating workbook", + "running": "Creating workbook", + "completed": "Workbook created", + "failed": "Create failed" + }, + "fields": [ + { + "label": "Title", + "inputPath": [ + "title" + ] + }, + { + "label": "Meta", + "resultPath": [ + "bridge", + "output", + "meta" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/edit_workbook_structure.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/edit_workbook_structure.js new file mode 100644 index 00000000..e6f64855 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/edit_workbook_structure.js @@ -0,0 +1,79 @@ +const { callExcelEngine } = require("./excel_bridge"); + +function buildStructureCall(input = {}) { + const common = { + workbookId: input.workbookId, + expectedRevision: input.expectedRevision, + intent: input.intent, + }; + if (input.sheetId) common.sheetId = input.sheetId; + + switch (input.operation) { + case "add_sheet": + return { + action: "addSheet", + payload: { + ...common, + ...(input.name ? { name: input.name } : {}), + ...(input.rows != null ? { rows: input.rows } : {}), + ...(input.columns != null ? { cols: input.columns } : {}), + ...(input.activate != null ? { activate: input.activate } : {}), + }, + summary: "Added a revisioned worksheet. This structural change is committed immediately and remains undoable.", + }; + case "rename_sheet": + if (!input.name) throw new Error("rename_sheet requires name"); + return { + action: "renameSheet", + payload: { ...common, name: input.name }, + summary: "Renamed a worksheet as a revisioned, undoable structural change.", + }; + case "insert_rows": + case "insert_columns": + case "delete_rows": + case "delete_columns": { + if (!Number.isInteger(input.index) || input.index < 1) { + throw new Error(`${input.operation} requires a 1-based index`); + } + const actions = { + insert_rows: "insertRows", + insert_columns: "insertColumns", + delete_rows: "deleteRows", + delete_columns: "deleteColumns", + }; + return { + action: actions[input.operation], + payload: { + ...common, + at: input.index - 1, + count: input.count == null ? 1 : input.count, + }, + summary: "Applied a revisioned, undoable worksheet structure change. Formula-bearing workbooks are rejected when references cannot be rewritten safely.", + }; + } + default: + throw new Error(`Unsupported workbook structure operation: ${input.operation || "missing operation"}`); + } +} + +async function run(input, context) { + const call = buildStructureCall(input); + switch (call.action) { + case "addSheet": + return callExcelEngine("addSheet", call.payload, context, call.summary); + case "renameSheet": + return callExcelEngine("renameSheet", call.payload, context, call.summary); + case "insertRows": + return callExcelEngine("insertRows", call.payload, context, call.summary); + case "insertColumns": + return callExcelEngine("insertColumns", call.payload, context, call.summary); + case "deleteRows": + return callExcelEngine("deleteRows", call.payload, context, call.summary); + case "deleteColumns": + return callExcelEngine("deleteColumns", call.payload, context, call.summary); + default: + throw new Error(`Unsupported Excel Engine structure action: ${call.action}`); + } +} + +module.exports = { run, buildStructureCall }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/edit_workbook_structure.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/edit_workbook_structure.tool.json new file mode 100644 index 00000000..3e0c7b38 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/edit_workbook_structure.tool.json @@ -0,0 +1,102 @@ +{ + "name": "edit_workbook_structure", + "description": "Perform one explicit worksheet structure change: add or rename a sheet, or insert/delete rows or columns. Unlike propose_patch, this commits immediately as a revisioned undoable operation and may be blocked when formulas make reference rewriting unsafe.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["workbookId", "expectedRevision", "intent", "operation"], + "properties": { + "workbookId": { "type": "string" }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "Exact current workbook revision. Refresh after each structure operation." + }, + "intent": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Why this immediate structural mutation is necessary." + }, + "operation": { + "type": "string", + "enum": [ + "add_sheet", + "rename_sheet", + "insert_rows", + "insert_columns", + "delete_rows", + "delete_columns" + ] + }, + "sheetId": { + "type": "string", + "description": "Target sheet for rename/insert/delete; defaults to the active sheet." + }, + "name": { + "type": "string", + "maxLength": 31, + "description": "New sheet name for add_sheet or rename_sheet. Required for rename_sheet." + }, + "rows": { + "type": "integer", + "minimum": 1, + "maximum": 1048576, + "description": "Initial row capacity for add_sheet." + }, + "columns": { + "type": "integer", + "minimum": 1, + "maximum": 16384, + "description": "Initial column capacity for add_sheet." + }, + "activate": { + "type": "boolean", + "description": "Whether a newly added sheet becomes active. Defaults to true." + }, + "index": { + "type": "integer", + "minimum": 1, + "description": "Human-facing 1-based row number or column number where insert/delete starts (A=1, B=2). Required for insert/delete." + }, + "count": { + "type": "integer", + "minimum": 1, + "description": "Number of rows or columns to insert/delete. Defaults to 1." + } + } + }, + "runtime": "javascript", + "entry": "tools/edit_workbook_structure.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 60000, + "maxOutputBytes": 200000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Edit Workbook Structure", + "icon": "TableProperties", + "title": "Edit Workbook Structure", + "summary": { + "preparing": "Preparing structure change", + "running": "Applying {{input.operation}}", + "completed": "Structure change committed", + "failed": "Structure change failed" + }, + "fields": [ + { "label": "Operation", "inputPath": ["operation"] }, + { "label": "Sheet", "inputPath": ["sheetId"] }, + { "label": "Revision", "inputPath": ["expectedRevision"] }, + { "label": "Intent", "inputPath": ["intent"] }, + { "label": "Status", "resultPath": ["bridge", "status"] }, + { "label": "Output", "resultPath": ["bridge", "output"], "format": "json" } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/excel_bridge.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/excel_bridge.js new file mode 100644 index 00000000..a155f2ef --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/excel_bridge.js @@ -0,0 +1,88 @@ +const BRIDGE_ID = "builtin-excel-runtime"; +const CAPABILITY_ID = "sparo.excelEngine"; + +function normalizeWorkspaceRelativePath(value) { + if (typeof value !== "string" || !value.trim() || value.includes("\0")) { + throw new Error("Excel Agent file paths must be non-empty workspace-relative paths"); + } + const normalized = value.trim().replaceAll("\\", "/"); + if ( + normalized.startsWith("/") || + /^[A-Za-z]:/.test(normalized) || + normalized.split("/").some((segment) => segment === "..") + ) { + throw new Error( + "Excel Agent file paths must stay inside the active workspace; absolute paths and parent traversal are not allowed" + ); + } + return normalized; +} + +function normalizeInput(input = {}, context = {}) { + const payload = { ...input }; + // The model must never choose or override the storage root. Bind every + // Excel operation to the workspace supplied by the trusted tool runtime. + delete payload.workspacePath; + delete payload.workspace_path; + if (!context.workspaceRoot || typeof context.workspaceRoot !== "string") { + throw new Error("Excel Live requires a host-bound workspace root"); + } + payload.workspacePath = context.workspaceRoot; + if (payload.path != null) { + payload.path = normalizeWorkspaceRelativePath(payload.path); + } + if (!payload.workbookId && payload.workbook_id) { + payload.workbookId = payload.workbook_id; + delete payload.workbook_id; + } + if (!payload.sheetId && payload.sheet_id) { + payload.sheetId = payload.sheet_id; + delete payload.sheet_id; + } + if (!payload.sheetName && payload.sheet_name) { + payload.sheetName = payload.sheet_name; + delete payload.sheet_name; + } + if (!payload.proposalId && payload.proposal_id) { + payload.proposalId = payload.proposal_id; + delete payload.proposal_id; + } + if (payload.expectedRevision == null && payload.expected_revision != null) { + payload.expectedRevision = payload.expected_revision; + delete payload.expected_revision; + } + if (!payload.cellRefs && Array.isArray(payload.cell_refs)) { + payload.cellRefs = payload.cell_refs; + delete payload.cell_refs; + } + if (!payload.maxCells && payload.max_cells != null) { + payload.maxCells = payload.max_cells; + delete payload.max_cells; + } + if (payload.range && !payload.a1) { + payload.a1 = payload.range; + delete payload.range; + } + return payload; +} + +function callExcelEngine(action, input, context, summary) { + return { + summary, + bridgeCall: { + bridgeId: BRIDGE_ID, + capabilityId: CAPABILITY_ID, + action, + // Live grid edits already persist under + // /.sparo_os/excel-live//workbook.json. + // Always pass workbookId from spreadsheet-focus when available. + input: normalizeInput(input, context), + }, + }; +} + +module.exports = { + callExcelEngine, + normalizeInput, + normalizeWorkspaceRelativePath, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_focus.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_focus.js new file mode 100644 index 00000000..3d114da4 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_focus.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "getFocus", + input, + context, + "Fetched the current ambient spreadsheet focus.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_focus.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_focus.tool.json new file mode 100644 index 00000000..0331666e --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_focus.tool.json @@ -0,0 +1,58 @@ +{ + "name": "get_focus", + "description": "Return the current ambient focus (sheet, A1, kind) for the open workbook session.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId" + ], + "properties": { + "workbookId": { + "type": "string" + } + } + }, + "runtime": "javascript", + "entry": "tools/get_focus.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 20000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Get Focus", + "icon": "Crosshair", + "title": "Get Focus", + "summary": { + "preparing": "Reading focus", + "running": "Fetching ambient focus", + "completed": "Focus loaded", + "failed": "Focus lookup failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Focus", + "resultPath": [ + "bridge", + "output", + "focus" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_history.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_history.js new file mode 100644 index 00000000..4ab045f2 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_history.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "getHistory", + input, + context, + "Read compact workbook revision and undo/redo history.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_history.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_history.tool.json new file mode 100644 index 00000000..604ce1e7 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_history.tool.json @@ -0,0 +1,37 @@ +{ + "name": "get_history", + "description": "Return compact Excel Live revision and undo/redo history without exposing workbook cell payloads.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["workbookId"], + "properties": { + "workbookId": { "type": "string" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 50 } + } + }, + "runtime": "javascript", + "entry": "tools/get_history.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 80000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "displayName": "Workbook History", + "icon": "History", + "title": "Workbook History", + "summary": { + "preparing": "Loading workbook history", + "running": "Reading workbook history", + "completed": "Workbook history loaded", + "failed": "History lookup failed" + } + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_proposal.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_proposal.js new file mode 100644 index 00000000..370b6bf3 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_proposal.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "getProposal", + { ...input, resultCellLimit: 200 }, + context, + "Fetched the active spreadsheet proposal.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_proposal.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_proposal.tool.json new file mode 100644 index 00000000..45d2a42f --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_proposal.tool.json @@ -0,0 +1,57 @@ +{ + "name": "get_proposal", + "description": "Return the active proposal diff for the workbook, if any.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId" + ], + "properties": { + "workbookId": { + "type": "string" + } + } + }, + "runtime": "javascript", + "entry": "tools/get_proposal.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 200000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Get Proposal", + "icon": "Eye", + "title": "Get Proposal", + "summary": { + "preparing": "Loading proposal", + "running": "Fetching active proposal", + "completed": "Proposal loaded", + "failed": "Proposal lookup failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Output", + "resultPath": [ + "bridge", + "output" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_workbook_meta.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_workbook_meta.js new file mode 100644 index 00000000..67b63c27 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_workbook_meta.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "getMeta", + input, + context, + "Fetched workbook metadata from the Excel Engine.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_workbook_meta.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_workbook_meta.tool.json new file mode 100644 index 00000000..cea15a0a --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_workbook_meta.tool.json @@ -0,0 +1,58 @@ +{ + "name": "get_workbook_meta", + "description": "Return workbook runtime truth: revision, mode, sheets, focus, proposal/history state, calculation status, source/export fidelity, and capability flags for formulas, styles, number formats, layout, and structural edits. Inspect this before non-trivial authoring.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId" + ], + "properties": { + "workbookId": { + "type": "string" + } + } + }, + "runtime": "javascript", + "entry": "tools/get_workbook_meta.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Workbook Meta", + "icon": "Info", + "title": "Workbook Meta", + "summary": { + "preparing": "Loading meta", + "running": "Fetching workbook meta", + "completed": "Meta loaded", + "failed": "Meta lookup failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Meta", + "resultPath": [ + "bridge", + "output", + "meta" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/list_sheets.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/list_sheets.js new file mode 100644 index 00000000..c36b6a58 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/list_sheets.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "listSheets", + input, + context, + "Listed workbook sheets.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/list_sheets.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/list_sheets.tool.json new file mode 100644 index 00000000..45b6efcc --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/list_sheets.tool.json @@ -0,0 +1,58 @@ +{ + "name": "list_sheets", + "description": "List sheets in the open workbook, including active sheet id and basic dimensions.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId" + ], + "properties": { + "workbookId": { + "type": "string" + } + } + }, + "runtime": "javascript", + "entry": "tools/list_sheets.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "List Sheets", + "icon": "Layers", + "title": "List Sheets", + "summary": { + "preparing": "Listing sheets", + "running": "Listing sheets", + "completed": "Sheets listed", + "failed": "List failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Sheets", + "resultPath": [ + "bridge", + "output", + "sheets" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/open_workbook.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/open_workbook.js new file mode 100644 index 00000000..f2bdf285 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/open_workbook.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "openWorkbook", + input, + context, + "Opened the workbook into an Excel Live session.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/open_workbook.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/open_workbook.tool.json new file mode 100644 index 00000000..2dc99259 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/open_workbook.tool.json @@ -0,0 +1,67 @@ +{ + "name": "open_workbook", + "description": "Open a CSV, XLSX, or Sparo workbook JSON file into an in-memory Excel Live session and persist the store under .sparo_os/excel-live.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "pattern": "^(?![A-Za-z]:)(?![/\\\\])(?!.*(?:^|[/\\\\])\\.\\.(?:[/\\\\]|$)).+\\.(?:xlsx|xlsm|csv)$", + "description": "Workbook file path relative to the active workspace. Absolute paths and parent traversal are not allowed." + }, + "title": { + "type": "string" + }, + "workbookId": { + "type": "string", + "description": "Optional workbook id to reuse." + } + } + }, + "runtime": "javascript", + "entry": "tools/open_workbook.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 60000, + "maxOutputBytes": 120000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Open Workbook", + "icon": "FolderOpen", + "title": "Open Workbook", + "summary": { + "preparing": "Opening workbook", + "running": "Opening {{input.path}}", + "completed": "Workbook opened", + "failed": "Open failed" + }, + "fields": [ + { + "label": "Path", + "inputPath": [ + "path" + ] + }, + { + "label": "Meta", + "resultPath": [ + "bridge", + "output", + "meta" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/propose_patch.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/propose_patch.js new file mode 100644 index 00000000..d3312e3f --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/propose_patch.js @@ -0,0 +1,261 @@ +const { callExcelEngine } = require("./excel_bridge"); + +const MAX_COMPILED_CELLS = 5000; +const RESULT_CELL_LIMIT = 200; + +function columnToIndex(label) { + let result = 0; + for (const char of label.toUpperCase()) { + result = result * 26 + char.charCodeAt(0) - 64; + } + return result - 1; +} + +function parseA1Range(value) { + if (typeof value !== "string" || !value.trim()) { + throw new Error("A1 range is required for this proposal operation"); + } + const unqualified = value.trim().split("!").pop().replaceAll("$", ""); + const match = unqualified.match(/^([A-Za-z]+)([1-9]\d*)(?::([A-Za-z]+)([1-9]\d*))?$/); + if (!match) { + throw new Error(`Unsupported A1 range: ${value}`); + } + const r1 = Number(match[2]) - 1; + const c1 = columnToIndex(match[1]); + const r2 = match[4] ? Number(match[4]) - 1 : r1; + const c2 = match[3] ? columnToIndex(match[3]) : c1; + if (r2 < r1 || c2 < c1) { + throw new Error(`A1 range must run from top-left to bottom-right: ${value}`); + } + return { r1, c1, r2, c2 }; +} + +function sheetQualifier(value) { + if (typeof value !== "string") return null; + const separator = value.lastIndexOf("!"); + return separator > 0 ? value.slice(0, separator) : null; +} + +function matrixCells(a1, values, formulaOnly = false) { + const { r1, c1 } = parseA1Range(a1); + const cells = []; + for (let rowOffset = 0; rowOffset < values.length; rowOffset += 1) { + const row = values[rowOffset]; + if (!Array.isArray(row)) { + throw new Error("Each values row must be an array"); + } + for (let colOffset = 0; colOffset < row.length; colOffset += 1) { + const raw = row[colOffset]; + const cell = { row: r1 + rowOffset, col: c1 + colOffset }; + if (formulaOnly) { + if (typeof raw !== "string" || !raw.trim()) { + throw new Error("set_formulas values must be non-empty formula strings"); + } + cell.formula = raw.trim().replace(/^=/, ""); + } else if (typeof raw === "string" && raw.trim().startsWith("=")) { + cell.formula = raw.trim().slice(1); + } else { + cell.value = raw; + } + cells.push(cell); + } + } + return cells; +} + +function styleCells(a1, styleRole, style) { + const range = parseA1Range(a1); + const count = (range.r2 - range.r1 + 1) * (range.c2 - range.c1 + 1); + if (count > MAX_COMPILED_CELLS) { + throw new Error( + `apply_style expands to ${count} cells; narrow the range below ${MAX_COMPILED_CELLS}`, + ); + } + const cells = []; + for (let row = range.r1; row <= range.r2; row += 1) { + for (let col = range.c1; col <= range.c2; col += 1) { + cells.push({ + row, + col, + ...(styleRole ? { styleRole } : {}), + ...(style !== undefined ? { style } : {}), + }); + } + } + return cells; +} + +function deepMerge(base, addition) { + if ( + base && + addition && + typeof base === "object" && + typeof addition === "object" && + !Array.isArray(base) && + !Array.isArray(addition) + ) { + const result = { ...base }; + for (const [key, value] of Object.entries(addition)) { + result[key] = key in result ? deepMerge(result[key], value) : value; + } + return result; + } + return addition; +} + +function mergeLayout(base = {}, addition = {}) { + const additionHasColumns = Object.prototype.hasOwnProperty.call(addition || {}, "columns"); + const additionHasRows = Object.prototype.hasOwnProperty.call(addition || {}, "rows"); + const merged = { + ...deepMerge(base || {}, addition || {}), + columns: additionHasColumns && addition.columns.length === 0 + ? [] + : [...(base?.columns || []), ...(addition?.columns || [])], + rows: additionHasRows && addition.rows.length === 0 + ? [] + : [...(base?.rows || []), ...(addition?.rows || [])], + }; + if (!merged.columns.length && !(additionHasColumns || Object.prototype.hasOwnProperty.call(base || {}, "columns"))) { + delete merged.columns; + } + if (!merged.rows.length && !(additionHasRows || Object.prototype.hasOwnProperty.call(base || {}, "rows"))) { + delete merged.rows; + } + return merged; +} + +function mergeCells(cells) { + const merged = new Map(); + for (const cell of cells) { + if (!Number.isInteger(cell?.row) || !Number.isInteger(cell?.col)) { + throw new Error("Compiled proposal cells require integer row and col coordinates"); + } + const key = `${cell.row}:${cell.col}`; + const prior = merged.get(key) || { row: cell.row, col: cell.col }; + const next = { + ...prior, + ...cell, + ...(Object.prototype.hasOwnProperty.call(prior, "style") || Object.prototype.hasOwnProperty.call(cell, "style") + ? { + style: Object.prototype.hasOwnProperty.call(cell, "style") + ? deepMerge(prior.style || {}, cell.style) + : prior.style, + } + : {}), + }; + if (Object.prototype.hasOwnProperty.call(cell, "style") && cell.style === null) { + delete next.styleRole; + } + merged.set(key, next); + } + if (merged.size > MAX_COMPILED_CELLS) { + throw new Error(`Proposal exceeds the maximum of ${MAX_COMPILED_CELLS} compiled cells`); + } + return [...merged.values()]; +} + +function normalizeExplicitStyleClears(cells) { + return cells.map((cell) => { + if (!cell || cell.style !== null) return cell; + const normalized = { ...cell }; + delete normalized.styleRole; + return normalized; + }); +} + +function compileProposalInput(input = {}) { + if (!Array.isArray(input.operations) || input.operations.length === 0) { + return { + ...input, + ...(Array.isArray(input.cells) ? { cells: normalizeExplicitStyleClears(input.cells) } : {}), + resultCellLimit: RESULT_CELL_LIMIT, + }; + } + + const payload = { ...input }; + delete payload.operations; + payload.resultCellLimit = RESULT_CELL_LIMIT; + const cells = [...(Array.isArray(input.cells) ? input.cells : [])]; + let operationSheet = null; + if (Array.isArray(input.values)) { + cells.push(...matrixCells(input.a1 || "A1", input.values)); + delete payload.values; + } + let layout = input.layout || {}; + + for (const operation of input.operations) { + const qualifier = sheetQualifier(operation?.a1); + if (qualifier && input.sheetId) { + throw new Error("Use sheetId or a sheet-qualified operation A1, not both"); + } + if (qualifier && operationSheet && qualifier !== operationSheet) { + throw new Error("One proposal cannot target operations on multiple sheets"); + } + if (qualifier) operationSheet = qualifier; + switch (operation?.kind) { + case "set_cells": + if (!Array.isArray(operation.cells) || operation.cells.length === 0) { + throw new Error("set_cells requires a non-empty cells array"); + } + cells.push(...operation.cells); + break; + case "set_values": + if (!Array.isArray(operation.values)) { + throw new Error("set_values requires values[][]"); + } + cells.push(...matrixCells(operation.a1, operation.values)); + break; + case "set_formulas": + if (!Array.isArray(operation.values)) { + throw new Error("set_formulas requires values[][]"); + } + cells.push(...matrixCells(operation.a1, operation.values, true)); + break; + case "apply_style": + if (!operation.styleRole && !Object.prototype.hasOwnProperty.call(operation, "style")) { + throw new Error("apply_style requires styleRole and/or style"); + } + cells.push(...styleCells(operation.a1, operation.styleRole, operation.style)); + break; + case "set_layout": + if (!operation.layout || typeof operation.layout !== "object") { + throw new Error("set_layout requires layout"); + } + layout = mergeLayout(layout, operation.layout); + break; + default: + throw new Error(`Unsupported proposal operation: ${operation?.kind || "missing kind"}`); + } + } + + if (operationSheet && !input.sheetId) { + const inputSheet = sheetQualifier(input.a1); + if (inputSheet && inputSheet !== operationSheet) { + throw new Error("Root patch and proposal operations must target the same sheet"); + } + if (!inputSheet) payload.a1 = `${operationSheet}!${input.a1 || "A1"}`; + } + if (cells.length) payload.cells = mergeCells(cells); + if (Object.keys(layout).length) payload.layout = layout; + return payload; +} + +async function run(input, context) { + return callExcelEngine( + "proposePatch", + compileProposalInput(input), + context, + "Proposed workbook content, formula, style, or layout changes for grid review. Not applied until Accept.", + ); +} + +module.exports = { + run, + compileProposalInput, + deepMerge, + matrixCells, + parseA1Range, + sheetQualifier, + normalizeExplicitStyleClears, + RESULT_CELL_LIMIT, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/propose_patch.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/propose_patch.tool.json new file mode 100644 index 00000000..4290b7cc --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/propose_patch.tool.json @@ -0,0 +1,328 @@ +{ + "name": "propose_patch", + "description": "Create one revision-bound proposal containing values, formulas, cell styles, semantic style roles, and sheet layout. Nothing is committed until the proposal is accepted on the grid.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId", + "expectedRevision", + "intent" + ], + "anyOf": [ + { "required": ["cells"] }, + { "required": ["values"] }, + { "required": ["layout"] }, + { "required": ["operations"] } + ], + "properties": { + "workbookId": { + "type": "string" + }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "Exact revision returned by the current focus or workbook metadata. Refresh after every mutation or revision conflict." + }, + "intent": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Concise user-facing reason, design intent, and expected outcome." + }, + "designIntent": { + "type": "object", + "additionalProperties": false, + "description": "Optional creative brief used to keep a multi-part spreadsheet design coherent.", + "properties": { + "purpose": { "type": "string", "maxLength": 200 }, + "audience": { "type": "string", "maxLength": 200 }, + "tone": { "type": "string", "maxLength": 120 } + } + }, + "sheetId": { + "type": "string" + }, + "a1": { + "type": "string", + "description": "Anchor for values[][] or a sheet-qualified range hint." + }, + "cells": { + "type": "array", + "description": "Sparse 0-based cell patches. A cell may change content, style, or both; style-only cells intentionally omit value and formula.", + "maxItems": 5000, + "items": { "$ref": "#/$defs/cell" } + }, + "values": { + "type": "array", + "description": "Legacy dense values matrix anchored at a1. Strings beginning with = are formulas.", + "items": { + "type": "array", + "items": {} + } + }, + "layout": { + "$ref": "#/$defs/layout" + }, + "operations": { + "type": "array", + "description": "Composable same-sheet authoring operations compiled into one atomic cells+layout proposal. Use root sheetId for clarity. Use this for coherent multi-range designs; use direct cells/values/layout for simple edits.", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "set_cells", + "set_values", + "set_formulas", + "apply_style", + "set_layout" + ] + }, + "a1": { + "type": "string", + "description": "Required for set_values, set_formulas, and apply_style." + }, + "cells": { + "type": "array", + "maxItems": 5000, + "items": { "$ref": "#/$defs/cell" }, + "description": "Required for set_cells." + }, + "values": { + "type": "array", + "items": { + "type": "array", + "items": {} + }, + "description": "Required for set_values or set_formulas. Formula strings may include or omit the leading = for set_formulas." + }, + "styleRole": { + "$ref": "#/$defs/styleRole" + }, + "style": { + "$ref": "#/$defs/style" + }, + "layout": { + "$ref": "#/$defs/layout" + } + } + } + } + }, + "$defs": { + "styleRole": { + "type": "string", + "enum": [ + "title", + "header", + "input", + "output", + "total", + "note", + "warning" + ], + "description": "Stable semantic styling. Prefer a role before adding custom overrides." + }, + "borderSide": { + "type": ["object", "null"], + "additionalProperties": false, + "description": "Border side override, or null to remove that side.", + "properties": { + "style": { + "type": "string", + "enum": ["thin", "medium", "thick", "dashed", "dotted", "double"] + }, + "color": { + "type": "string", + "description": "Six-digit RGB hex color, with optional leading #." + } + } + }, + "style": { + "type": ["object", "null"], + "additionalProperties": false, + "description": "Cell style override. Null clears the complete cell style and supersedes styleRole. Keep overrides sparse so semantic roles and source fidelity remain stable.", + "properties": { + "role": { "$ref": "#/$defs/styleRole" }, + "fill": { + "type": ["object", "null"], + "additionalProperties": false, + "description": "Fill override, or null to remove the fill.", + "properties": { + "color": { + "type": "string", + "description": "Six-digit RGB hex color, with optional leading #." + } + } + }, + "font": { + "type": ["object", "null"], + "additionalProperties": false, + "description": "Font override, or null to remove custom font properties.", + "properties": { + "bold": { "type": ["boolean", "null"] }, + "italic": { "type": ["boolean", "null"] }, + "color": { "type": ["string", "null"] }, + "size": { "type": ["number", "null"], "minimum": 6, "maximum": 72 } + } + }, + "border": { + "type": ["object", "null"], + "additionalProperties": false, + "description": "Border override, or null to remove all custom borders.", + "properties": { + "top": { "$ref": "#/$defs/borderSide" }, + "right": { "$ref": "#/$defs/borderSide" }, + "bottom": { "$ref": "#/$defs/borderSide" }, + "left": { "$ref": "#/$defs/borderSide" } + } + }, + "alignment": { + "type": ["object", "null"], + "additionalProperties": false, + "description": "Alignment override, or null to remove custom alignment.", + "properties": { + "horizontal": { + "type": ["string", "null"], + "enum": ["left", "center", "right", "general", null] + }, + "vertical": { + "type": ["string", "null"], + "enum": ["top", "center", "bottom", null] + }, + "wrapText": { "type": ["boolean", "null"] } + } + }, + "numberFormat": { + "type": ["string", "null"], + "maxLength": 120, + "description": "Excel-compatible number format such as #,##0.00, 0.0%, or yyyy-mm-dd; null removes it. Formatting does not change the underlying value." + } + } + }, + "cell": { + "type": "object", + "additionalProperties": false, + "required": ["row", "col"], + "properties": { + "row": { "type": "integer", "minimum": 0 }, + "col": { "type": "integer", "minimum": 0 }, + "value": {}, + "formula": { + "type": "string", + "description": "Formula with or without a leading =." + }, + "styleRole": { "$ref": "#/$defs/styleRole" }, + "style": { "$ref": "#/$defs/style" } + } + }, + "layout": { + "type": "object", + "additionalProperties": false, + "description": "Sheet layout metadata included in the same proposal. Column and row start/end indices are 0-based and inclusive; A1 remains 1-based Excel notation.", + "properties": { + "columns": { + "type": "array", + "description": "Column layout bands. An explicit empty array clears all custom column-width metadata.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["start"], + "properties": { + "start": { "type": "integer", "minimum": 0 }, + "end": { "type": "integer", "minimum": 0 }, + "width": { + "type": "number", + "minimum": 1, + "maximum": 255, + "description": "Width in Excel character units, never pixels." + }, + "autoFit": { "type": "boolean" } + } + } + }, + "rows": { + "type": "array", + "description": "Row layout bands. An explicit empty array clears all custom row-height metadata.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["start"], + "properties": { + "start": { "type": "integer", "minimum": 0 }, + "end": { "type": "integer", "minimum": 0 }, + "height": { + "type": "number", + "minimum": 1, + "maximum": 409, + "description": "Height in points, never pixels." + }, + "autoFit": { "type": "boolean" } + } + } + }, + "freezePanes": { + "type": "object", + "additionalProperties": false, + "description": "Frozen row/column counts. Use rows:0 and columns:0 to clear frozen panes.", + "properties": { + "rows": { "type": "integer", "minimum": 0 }, + "columns": { "type": "integer", "minimum": 0 } + } + }, + "autoFilter": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "a1": { "type": "string" } + }, + "description": "Filter header range, or null to clear the current auto filter." + } + } + } + } + }, + "runtime": "javascript", + "entry": "tools/propose_patch.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 60000, + "maxOutputBytes": 300000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Propose Workbook Change", + "icon": "Table", + "title": "Propose Workbook Change", + "summary": { + "preparing": "Preparing workbook proposal", + "running": "Designing changes for {{input.workbookId}}", + "completed": "Proposal ready - review on the grid", + "failed": "Proposal failed" + }, + "fields": [ + { "label": "Workbook", "inputPath": ["workbookId"] }, + { "label": "Sheet", "inputPath": ["sheetId"] }, + { "label": "Range", "inputPath": ["a1"] }, + { "label": "Revision", "inputPath": ["expectedRevision"] }, + { "label": "Intent", "inputPath": ["intent"] }, + { "label": "Action", "resultPath": ["bridge", "action"] }, + { "label": "Status", "resultPath": ["bridge", "status"] }, + { "label": "Base revision", "resultPath": ["bridge", "output", "proposal", "baseRevision"] }, + { "label": "Validation", "resultPath": ["bridge", "output", "proposal", "validation"], "format": "json" }, + { "label": "Layout", "resultPath": ["bridge", "output", "proposal", "layout"], "format": "json" }, + { "label": "Output", "resultPath": ["bridge", "output"], "format": "json" } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/read_range.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/read_range.js new file mode 100644 index 00000000..3d1cd422 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/read_range.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "readRange", + input, + context, + "Read the requested spreadsheet range.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/read_range.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/read_range.tool.json new file mode 100644 index 00000000..9e9ae31f --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/read_range.tool.json @@ -0,0 +1,80 @@ +{ + "name": "read_range", + "description": "Read grounded cell values, formulas, cell styles, calculation status, and sheet layout for a range. Defaults to ambient focus when a1 is omitted and enforces maxCells (default 2000). Use before reasoning about or redesigning content.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId" + ], + "properties": { + "workbookId": { + "type": "string" + }, + "sheetId": { + "type": "string" + }, + "a1": { + "type": "string" + }, + "maxCells": { + "type": "integer", + "minimum": 1, + "maximum": 20000 + } + } + }, + "runtime": "javascript", + "entry": "tools/read_range.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 200000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Read Range", + "icon": "Table", + "title": "Read Range", + "summary": { + "preparing": "Preparing read", + "running": "Reading {{input.a1}}", + "completed": "Range read", + "failed": "Read failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Sheet", + "inputPath": [ + "sheetId" + ] + }, + { + "label": "Range", + "inputPath": [ + "a1" + ] + }, + { + "label": "Output", + "resultPath": [ + "bridge", + "output" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/redo_workbook.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/redo_workbook.js new file mode 100644 index 00000000..488f75b6 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/redo_workbook.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "redo", + input, + context, + "Redo the latest undone workbook operation and return the new revision.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/redo_workbook.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/redo_workbook.tool.json new file mode 100644 index 00000000..5fbe7074 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/redo_workbook.tool.json @@ -0,0 +1,41 @@ +{ + "name": "redo_workbook", + "description": "Redo the latest undone Excel Live operation. Use only when the user explicitly asks to reapply it.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["workbookId", "expectedRevision"], + "properties": { + "workbookId": { "type": "string" }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "Current workbook revision from spreadsheet focus or get_workbook_meta." + } + } + }, + "runtime": "javascript", + "entry": "tools/redo_workbook.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "displayName": "Redo Workbook Change", + "icon": "Redo2", + "title": "Redo Workbook Change", + "summary": { + "preparing": "Checking workbook history", + "running": "Redoing the latest change", + "completed": "Workbook change redone", + "failed": "Redo failed" + } + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/reject_proposal.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/reject_proposal.js new file mode 100644 index 00000000..7fd554b6 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/reject_proposal.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "rejectProposal", + input, + context, + "Rejected the active spreadsheet proposal without changing committed cells.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/reject_proposal.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/reject_proposal.tool.json new file mode 100644 index 00000000..ba18b80a --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/reject_proposal.tool.json @@ -0,0 +1,73 @@ +{ + "name": "reject_proposal", + "description": "Discard the active proposal without changing committed cells. Prefer calling this only when the user explicitly asks you to Reject from chat.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId", + "proposalId", + "expectedRevision" + ], + "properties": { + "workbookId": { + "type": "string" + }, + "proposalId": { + "type": "string", + "description": "Exact active proposal id reviewed by the user." + }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "Exact workbook revision containing the proposal." + } + } + }, + "runtime": "javascript", + "entry": "tools/reject_proposal.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Reject Proposal", + "icon": "X", + "title": "Reject Proposal", + "summary": { + "preparing": "Preparing reject", + "running": "Rejecting proposal", + "completed": "Proposal rejected", + "failed": "Reject failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Proposal", + "inputPath": [ + "proposalId" + ] + }, + { + "label": "Status", + "resultPath": [ + "bridge", + "status" + ] + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/save_workbook.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/save_workbook.js new file mode 100644 index 00000000..1581b45f --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/save_workbook.js @@ -0,0 +1,29 @@ +const { callExcelEngine } = require("./excel_bridge"); + +function buildSaveInput(input = {}) { + if ( + input.acknowledgeFidelityLoss === true && + (typeof input.path !== "string" || !input.path.toLowerCase().endsWith(".xlsx")) + ) { + throw new Error( + "acknowledgeFidelityLoss=true is allowed only for an explicit Save As to a distinct .xlsx path", + ); + } + return { + ...input, + acknowledgeFidelityLoss: input.acknowledgeFidelityLoss === true, + exportCopy: true, + allowSourceOverwrite: false, + }; +} + +async function run(input, context) { + return callExcelEngine( + "saveWorkbook", + buildSaveInput(input), + context, + "Exported the reviewed workbook revision to a separate copy.", + ); +} + +module.exports = { run, buildSaveInput }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/save_workbook.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/save_workbook.tool.json new file mode 100644 index 00000000..e5f8cb78 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/save_workbook.tool.json @@ -0,0 +1,91 @@ +{ + "name": "save_workbook", + "description": "Export a source-safe workbook copy. The live revisioned store is already persisted; never overwrite the imported source file.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId", + "expectedRevision", + "path" + ], + "properties": { + "workbookId": { + "type": "string" + }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "Exact workbook revision reviewed before export. Refresh metadata if this conflicts." + }, + "path": { + "type": "string", + "pattern": "^(?![A-Za-z]:)(?![/\\\\])(?!.*(?:^|[/\\\\])\\.\\.(?:[/\\\\]|$)).+\\.(?:xlsx|xlsm)$", + "description": "Required .xlsx or .xlsm copy path relative to the active workspace. Absolute paths, parent traversal, source overwrite, and CSV are not allowed." + }, + "acknowledgeFidelityLoss": { + "type": "boolean", + "description": "Normally false. Set true only after the user has seen the workbook's concrete fidelity warning, explicitly accepted the losses, and chosen a distinct .xlsx Save As path. Never use true for .xlsm." + } + } + }, + "runtime": "javascript", + "entry": "tools/save_workbook.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 60000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Save Workbook", + "icon": "Save", + "title": "Export Workbook", + "summary": { + "preparing": "Preparing export", + "running": "Exporting workbook file", + "completed": "Workbook file exported", + "failed": "Export failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Path", + "inputPath": [ + "path" + ] + }, + { + "label": "Revision", + "inputPath": [ + "expectedRevision" + ] + }, + { + "label": "Loss acknowledged", + "inputPath": [ + "acknowledgeFidelityLoss" + ] + }, + { + "label": "Output", + "resultPath": [ + "bridge", + "output" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/summarize_range.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/summarize_range.js new file mode 100644 index 00000000..4514d21a --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/summarize_range.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "summarizeRange", + input, + context, + "Summarized the requested spreadsheet range.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/summarize_range.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/summarize_range.tool.json new file mode 100644 index 00000000..aab03910 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/summarize_range.tool.json @@ -0,0 +1,73 @@ +{ + "name": "summarize_range", + "description": "Profile a range with numeric/text/empty/formula counts, header evidence, and bounded sample rows before a large read or transformation. Defaults to ambient focus when a1 is omitted.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId" + ], + "properties": { + "workbookId": { + "type": "string" + }, + "sheetId": { + "type": "string" + }, + "a1": { + "type": "string" + }, + "maxCells": { + "type": "integer", + "minimum": 1 + } + } + }, + "runtime": "javascript", + "entry": "tools/summarize_range.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 120000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Summarize Range", + "icon": "BarChart", + "title": "Summarize Range", + "summary": { + "preparing": "Preparing summary", + "running": "Summarizing {{input.a1}}", + "completed": "Summary ready", + "failed": "Summarize failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Range", + "inputPath": [ + "a1" + ] + }, + { + "label": "Output", + "resultPath": [ + "bridge", + "output" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/switch_sheet.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/switch_sheet.js new file mode 100644 index 00000000..b745978a --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/switch_sheet.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "switchSheet", + input, + context, + "Switched the active workbook sheet.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/switch_sheet.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/switch_sheet.tool.json new file mode 100644 index 00000000..38a55a3b --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/switch_sheet.tool.json @@ -0,0 +1,79 @@ +{ + "name": "switch_sheet", + "description": "Switch the active sheet by id or name and update ambient focus to that sheet.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "workbookId" + ], + "anyOf": [ + { "required": ["sheetId"] }, + { "required": ["sheetName"] } + ], + "properties": { + "workbookId": { + "type": "string" + }, + "sheetId": { + "type": "string" + }, + "sheetName": { + "type": "string" + } + } + }, + "runtime": "javascript", + "entry": "tools/switch_sheet.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "primaryColor": "var(--ds-tool-family-agent-component-fg)", + "displayName": "Switch Sheet", + "icon": "Layers", + "title": "Switch Sheet", + "summary": { + "preparing": "Switching sheet", + "running": "Switching sheet", + "completed": "Sheet switched", + "failed": "Switch failed" + }, + "fields": [ + { + "label": "Workbook", + "inputPath": [ + "workbookId" + ] + }, + { + "label": "Sheet", + "inputPath": [ + "sheetId" + ] + }, + { + "label": "Name", + "inputPath": [ + "sheetName" + ] + }, + { + "label": "Output", + "resultPath": [ + "bridge", + "output" + ], + "format": "json" + } + ] + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/undo_workbook.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/undo_workbook.js new file mode 100644 index 00000000..49541efd --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/undo_workbook.js @@ -0,0 +1,12 @@ +const { callExcelEngine } = require("./excel_bridge"); + +async function run(input, context) { + return callExcelEngine( + "undo", + input, + context, + "Undo the latest committed workbook operation and return the new revision.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/undo_workbook.tool.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/undo_workbook.tool.json new file mode 100644 index 00000000..8a567fe4 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/undo_workbook.tool.json @@ -0,0 +1,41 @@ +{ + "name": "undo_workbook", + "description": "Undo the latest committed Excel Live operation. Use only when the user explicitly asks to reverse a committed change.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["workbookId", "expectedRevision"], + "properties": { + "workbookId": { "type": "string" }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "Current workbook revision from spreadsheet focus or get_workbook_meta." + } + } + }, + "runtime": "javascript", + "entry": "tools/undo_workbook.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 60000, + "ui": { + "card": { + "kind": "appDefined", + "family": "excel-live", + "template": "compact", + "displayMode": "compact", + "resultDisplayType": "detailed", + "displayName": "Undo Workbook Change", + "icon": "Undo2", + "title": "Undo Workbook Change", + "summary": { + "preparing": "Checking workbook history", + "running": "Undoing the latest change", + "completed": "Workbook change undone", + "failed": "Undo failed" + } + } + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/component.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/component.json new file mode 100644 index 00000000..7f4c385b --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/component.json @@ -0,0 +1,81 @@ +{ + "id": "builtin-excel-runtime", + "kind": "bridge", + "name": "Sparo Excel Engine", + "description": "Revisioned workbook creation with formulas, semantic styles, number formats, worksheet layout, reviewable edits, static formula lint, and source-safe XLSX export.", + "packageSource": "appPrivate", + "ownerApp": { + "appId": "builtin-excel-live", + "appVersion": "1.0.0" + }, + "capabilities": [ + { + "id": "sparo.excelEngine", + "title": "Sparo Excel Engine", + "description": "Read values, formulas, basic RGB styles, and layout; create reviewable content/style/layout proposals; statically lint but do not calculate formulas; undo; and export source-safe CSV/XLSX copies.", + "actions": [ + "openWorkbook", + "createWorkbook", + "getMeta", + "getFocus", + "setFocus", + "readRange", + "summarizeRange", + "proposePatch", + "getProposal", + "acceptProposal", + "rejectProposal", + "applyLocalEdit", + "applyLocalPatch", + "applyLayout", + "clearRange", + "undo", + "redo", + "getHistory", + "saveWorkbook", + "exportCsv", + "listSheets", + "listWorkbooks", + "switchSheet", + "insertRows", + "insertColumns", + "deleteRows", + "deleteColumns", + "renameSheet", + "addSheet", + "reloadWorkbook" + ] + } + ], + "permissions": [ + { + "kind": "fs", + "summary": "Reads spreadsheet files and writes workbook store plus CSV/XLSX exports under the workspace.", + "scopes": [ + "{workspace}", + "{app}" + ] + }, + { + "kind": "shell", + "summary": "Starts the Node.js bridge worker that hosts the Sparo Excel Engine.", + "scopes": [ + "node" + ] + } + ], + "usedByApps": [ + "builtin-excel-live" + ], + "visibility": "appDependency", + "dependencies": [], + "implementationRef": "app://builtin-excel-live@1.0.0/bridges/builtin-excel-runtime", + "usesCapabilities": [ + "filesystem.read", + "filesystem.write", + "terminal.run", + "process.spawn", + "workspace.inspect", + "component.run" + ] +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/bundle.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/bundle.json new file mode 100644 index 00000000..266791a0 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/bundle.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "id": "builtin-excel-runtime", + "version": 1 +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/manifest.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/manifest.json new file mode 100644 index 00000000..e07938c3 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/manifest.json @@ -0,0 +1,411 @@ +{ + "schemaVersion": 1, + "id": "builtin-excel-runtime", + "name": "Sparo Excel Engine", + "description": "Revisioned workbook creation with formulas, semantic cell styles, number formats, worksheet layout, proposal review, undo, static formula lint, and source-safe XLSX export for Excel Live.", + "kind": "daemon", + "runtime": { + "language": "javascript", + "entry": "worker.js" + }, + "surfaces": { + "launchableApp": false, + "agent": true, + "tool": true, + "productAppRuntimeBackend": true + }, + "permissions": { + "fs": [ + "{workspace}", + "{app}" + ], + "net": [], + "shell": [ + "node" + ], + "gui": [], + "secrets": [] + }, + "capabilities": [ + { + "id": "sparo.excelEngine", + "title": "Sparo Excel Engine", + "description": "Open and create revisioned workbooks; read values, formulas, basic RGB styles, and layout; propose conflict-safe content/style/layout edits; statically lint formulas without calculating them; undo; and export source-safe CSV/XLSX copies.", + "category": "excelEngine", + "actions": [ + "openWorkbook", + "createWorkbook", + "getMeta", + "getFocus", + "setFocus", + "readRange", + "summarizeRange", + "proposePatch", + "getProposal", + "acceptProposal", + "rejectProposal", + "applyLocalEdit", + "applyLocalPatch", + "applyLayout", + "clearRange", + "undo", + "redo", + "getHistory", + "saveWorkbook", + "exportCsv", + "listSheets", + "listWorkbooks", + "switchSheet", + "insertRows", + "insertColumns", + "deleteRows", + "deleteColumns", + "renameSheet", + "addSheet", + "reloadWorkbook" + ], + "streaming": false, + "cancelable": true, + "resumable": true, + "usableBy": [ + "agentComponent", + "productAppRuntime", + "management" + ], + "inputSchema": { + "type": "object" + }, + "outputSchema": { + "type": "object" + } + } + ], + "actions": [ + { + "name": "openWorkbook", + "description": "Open a CSV, XLSX, or Sparo workbook JSON file into an in-memory session and persist the store under .sparo_os/excel-live.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "createWorkbook", + "description": "Create an empty workbook (default 50x26 sheet) and persist its store.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "getMeta", + "description": "Return workbook metadata including sheet layout, focus, dirty/revision state, proposal state, calculation honesty, style/layout fidelity, and precise runtime capabilities.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "getFocus", + "description": "Return the current ambient focus (sheet, A1, kind).", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "setFocus", + "description": "Set the ambient focus and collaboration mode without changing workbook content revision.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "readRange", + "description": "Read a values matrix, formula subset, sparse styled-cell details, and worksheet layout for a range (defaults to current focus). Enforces maxCells (default 2000).", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "summarizeRange", + "description": "Summarize a range with numeric/empty/formula counts, header guess, and sample TSV (first 20 rows).", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "proposePatch", + "description": "Compute an intent-bearing content/formula/style/layout before/after proposal at an expected revision without mutating committed cells. New formulas receive static lint only, never calculation. resultCellLimit can bound returned detail without truncating the stored proposal.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "getProposal", + "description": "Return the active proposal diff, optionally bounding cell details with resultCellLimit while reporting totalCellCount and cellDetailsTruncated.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "acceptProposal", + "description": "Apply all or selected cells after revision and before-value/style conflict checks. Proposal layout is applied only on whole-proposal acceptance without cellRefs.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "rejectProposal", + "description": "Discard the active proposal without changing committed cells.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "applyLocalEdit", + "description": "Apply an undoable value, formula, or partial-merge style edit at the current workbook revision.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "applyLocalPatch", + "description": "Apply an undoable multi-cell value/formula/style patch, used by paste and direct user formatting.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "clearRange", + "description": "Clear committed cells as an undoable operation and invalidate stale proposals.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "applyLayout", + "description": "Apply undoable column widths (Excel character units), row heights (points), deterministic content-aware auto-fit sizes with an OOXML best-fit hint, freeze panes, and auto-filter metadata at an expected revision. Imported Excel packages require an acknowledged Save As rebuild for changed layout.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "undo", + "description": "Undo the latest committed workbook content operation.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "redo", + "description": "Redo the latest undone workbook content operation.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "getHistory", + "description": "Return revision and compact undo/redo operation history without cell payloads.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "saveWorkbook", + "description": "Persist the live store and export JSON or a styled/layout-aware XLSX copy. Imported Excel style/layout changes require an acknowledged lossy Save As rebuild and can never silently overwrite the source; CSV uses exportCsv.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "exportCsv", + "description": "Serialize the active (or specified) sheet to CSV, optionally writing a file.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "listSheets", + "description": "List sheets in the workbook.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "listWorkbooks", + "description": "List live workbook stores under .sparo_os/excel-live for the workspace.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "switchSheet", + "description": "Switch the active sheet by id or name.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "insertRows", + "description": "Insert one or more rows at a 0-based index.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "insertColumns", + "description": "Insert one or more columns at a 0-based index.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "deleteRows", + "description": "Delete one or more rows at a 0-based index.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "deleteColumns", + "description": "Delete one or more columns at a 0-based index.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "renameSheet", + "description": "Rename a sheet.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "addSheet", + "description": "Add a new empty sheet to the workbook.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + }, + { + "name": "reloadWorkbook", + "description": "Reload from the original file path when available, otherwise from the JSON store.", + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "streaming": false, + "cancelable": false, + "resumable": true + } + ], + "tools": [ + { + "name": "SparoExcelEngine", + "description": "Create professional workbooks with formulas, semantic styles, number formats, layout controls, reviewable proposals, and CSV/XLSX export. Formula validation is static only; this engine does not calculate formulas.", + "capabilityId": "sparo.excelEngine", + "action": "getMeta", + "actions": [ + "openWorkbook", + "createWorkbook", + "getMeta", + "getFocus", + "setFocus", + "readRange", + "summarizeRange", + "proposePatch", + "getProposal", + "acceptProposal", + "rejectProposal", + "applyLocalEdit", + "applyLocalPatch", + "applyLayout", + "clearRange", + "undo", + "redo", + "getHistory", + "saveWorkbook", + "exportCsv", + "listSheets", + "listWorkbooks", + "switchSheet", + "insertRows", + "insertColumns", + "deleteRows", + "deleteColumns", + "renameSheet", + "addSheet", + "reloadWorkbook" + ], + "readonly": false, + "needsPermissions": true, + "inputSchema": { + "type": "object" + }, + "ui": { + "card": { + "kind": "appDefined", + "displayName": "Sparo Excel Engine", + "icon": "Table", + "resultDisplayType": "detailed", + "displayMode": "compact", + "family": "bridge-component", + "title": "Sparo Excel Engine" + } + } + } + ] +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/package.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/package.json new file mode 100644 index 00000000..c0b98fb3 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "type": "commonjs", + "name": "builtin-excel-runtime", + "scripts": { + "test": "node --test test/*.test.js" + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/a1.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/a1.js new file mode 100644 index 00000000..f7e66fa1 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/a1.js @@ -0,0 +1,195 @@ +/** + * A1 reference helpers (0-based row/col indices). + */ + +const EXCEL_MAX_ROWS = 1_048_576; +const EXCEL_MAX_COLUMNS = 16_384; + +function assertCellCoordinates(row, col, context = "Cell") { + if ( + !Number.isSafeInteger(row) || + !Number.isSafeInteger(col) || + row < 0 || + col < 0 || + row >= EXCEL_MAX_ROWS || + col >= EXCEL_MAX_COLUMNS + ) { + throw new Error( + `[EXCEL_CELL_LIMIT] ${context} must be within rows 1-${EXCEL_MAX_ROWS} and columns A-XFD using safe integer coordinates.` + ); + } + return { row, col }; +} + +function assertSheetDimensions(rows, cols, context = "Worksheet") { + if ( + !Number.isSafeInteger(rows) || + !Number.isSafeInteger(cols) || + rows < 1 || + cols < 1 || + rows > EXCEL_MAX_ROWS || + cols > EXCEL_MAX_COLUMNS + ) { + throw new Error( + `[EXCEL_SHEET_LIMIT] ${context} must have 1-${EXCEL_MAX_ROWS} rows and 1-${EXCEL_MAX_COLUMNS} columns.` + ); + } + return { rows, cols }; +} + +function colToIndex(col) { + if (col == null || typeof col !== "string" || !col.length) { + throw new Error("Column letter is required"); + } + const upper = col.toUpperCase(); + if (!/^[A-Z]{1,3}$/.test(upper)) { + throw new Error(`Invalid column letters: ${col}`); + } + let index = 0; + for (let i = 0; i < upper.length; i += 1) { + index = index * 26 + (upper.charCodeAt(i) - 64); + } + const zeroBased = index - 1; + if (!Number.isSafeInteger(zeroBased) || zeroBased >= EXCEL_MAX_COLUMNS) { + throw new Error( + `[EXCEL_COLUMN_LIMIT] Column ${upper} exceeds Excel's maximum column XFD.` + ); + } + return zeroBased; +} + +function indexToCol(index) { + if (!Number.isSafeInteger(index) || index < 0 || index >= EXCEL_MAX_COLUMNS) { + throw new Error(`Invalid column index: ${index}`); + } + let n = index + 1; + let col = ""; + while (n > 0) { + const rem = (n - 1) % 26; + col = String.fromCharCode(65 + rem) + col; + n = Math.floor((n - 1) / 26); + } + return col; +} + +function parseCellToken(token) { + const match = String(token || "") + .trim() + .toUpperCase() + .match(/^([A-Z]+)(\d+)$/); + if (!match) { + throw new Error(`Invalid A1 cell reference: ${token}`); + } + const c = colToIndex(match[1]); + const rowNumber = Number(match[2]); + if (!Number.isSafeInteger(rowNumber) || rowNumber < 1 || rowNumber > EXCEL_MAX_ROWS) { + throw new Error( + `[EXCEL_ROW_LIMIT] Row ${match[2]} exceeds Excel's supported range 1-${EXCEL_MAX_ROWS}.` + ); + } + const r = rowNumber - 1; + assertCellCoordinates(r, c, `Cell ${match[1]}${match[2]}`); + return { c, r }; +} + +/** + * Parse an A1 range or cell. + * Accepts: "A1", "A1:B10", "Sheet1!A1:B2", "'My Sheet'!A1" + * @returns {{ sheet: string|null, r1: number, c1: number, r2: number, c2: number }} + */ +function parseA1(ref) { + if (ref == null || String(ref).trim() === "") { + throw new Error("A1 reference is required"); + } + let raw = String(ref).trim(); + let sheet = null; + + const bang = raw.lastIndexOf("!"); + if (bang >= 0) { + sheet = raw.slice(0, bang).trim(); + raw = raw.slice(bang + 1).trim(); + if ( + (sheet.startsWith("'") && sheet.endsWith("'")) || + (sheet.startsWith('"') && sheet.endsWith('"')) + ) { + sheet = sheet.slice(1, -1).replace(/''/g, "'"); + } + } + + const parts = raw.split(":"); + if (parts.length === 1) { + const cell = parseCellToken(parts[0]); + return { sheet, r1: cell.r, c1: cell.c, r2: cell.r, c2: cell.c }; + } + if (parts.length !== 2) { + throw new Error(`Invalid A1 range: ${ref}`); + } + const a = parseCellToken(parts[0]); + const b = parseCellToken(parts[1]); + return { + sheet, + r1: Math.min(a.r, b.r), + c1: Math.min(a.c, b.c), + r2: Math.max(a.r, b.r), + c2: Math.max(a.c, b.c), + }; +} + +function formatA1(r1, c1, r2 = r1, c2 = c1) { + assertCellCoordinates(r1, c1, "Range start"); + assertCellCoordinates(r2, c2, "Range end"); + const top = Math.min(r1, r2); + const left = Math.min(c1, c2); + const bottom = Math.max(r1, r2); + const right = Math.max(c1, c2); + const start = `${indexToCol(left)}${top + 1}`; + if (top === bottom && left === right) { + return start; + } + return `${start}:${indexToCol(right)}${bottom + 1}`; +} + +function cellCount(r1, c1, r2, c2) { + assertCellCoordinates(r1, c1, "Range start"); + assertCellCoordinates(r2, c2, "Range end"); + const rows = Math.abs(r2 - r1) + 1; + const cols = Math.abs(c2 - c1) + 1; + const count = rows * cols; + if (!Number.isSafeInteger(count)) { + throw new Error("[EXCEL_RANGE_LIMIT] Range cell count exceeds JavaScript safe integer limits."); + } + return count; +} + +function clampRange(range, maxRows, maxCols) { + if (!range || typeof range !== "object") { + throw new Error("Range is required"); + } + const rows = Number(maxRows); + const cols = Number(maxCols); + assertSheetDimensions(rows, cols, "Worksheet bounds"); + const r1 = Math.max(0, Math.min(Number(range.r1) || 0, rows - 1)); + const c1 = Math.max(0, Math.min(Number(range.c1) || 0, cols - 1)); + const r2 = Math.max(r1, Math.min(Number(range.r2) || r1, rows - 1)); + const c2 = Math.max(c1, Math.min(Number(range.c2) || c1, cols - 1)); + return { + sheet: range.sheet == null ? null : range.sheet, + r1, + c1, + r2, + c2, + }; +} + +module.exports = { + EXCEL_MAX_ROWS, + EXCEL_MAX_COLUMNS, + assertCellCoordinates, + assertSheetDimensions, + colToIndex, + indexToCol, + parseA1, + formatA1, + cellCount, + clampRange, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/csv-io.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/csv-io.js new file mode 100644 index 00000000..1095e55e --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/csv-io.js @@ -0,0 +1,168 @@ +/** + * Minimal CSV parse / serialize with quote handling. + */ + +const { + EXCEL_MAX_COLUMNS, + EXCEL_MAX_ROWS, +} = require("./a1"); + +const MAX_CSV_PARSED_CELLS = 500_000; + +function positiveLimit(value, fallback, name) { + if (value == null) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`[CSV_LIMIT_INVALID] ${name} must be a positive safe integer.`); + } + return parsed; +} + +function parseCsv(text, options = {}) { + const source = text == null ? "" : String(text); + const maxRows = Math.min( + EXCEL_MAX_ROWS, + positiveLimit(options.maxRows, EXCEL_MAX_ROWS, "maxRows") + ); + const maxColumns = Math.min( + EXCEL_MAX_COLUMNS, + positiveLimit(options.maxColumns, EXCEL_MAX_COLUMNS, "maxColumns") + ); + const maxCells = positiveLimit( + options.maxCells, + MAX_CSV_PARSED_CELLS, + "maxCells" + ); + const rows = []; + let row = []; + let field = ""; + let inQuotes = false; + let i = 0; + let parsedCellCount = 0; + + const pushField = () => { + if (row.length >= maxColumns) { + throw new Error( + `[CSV_COLUMN_LIMIT] CSV row exceeds the maximum of ${maxColumns} columns.` + ); + } + parsedCellCount += 1; + if (parsedCellCount > maxCells) { + throw new Error( + `[CSV_CELL_COUNT_LIMIT] CSV contains more than ${maxCells} parsed cells.` + ); + } + row.push(field); + field = ""; + }; + + const pushRow = () => { + if (rows.length >= maxRows) { + throw new Error( + `[CSV_ROW_LIMIT] CSV contains more than ${maxRows} rows.` + ); + } + rows.push(row); + row = []; + }; + + while (i < source.length) { + const ch = source[i]; + if (inQuotes) { + if (ch === '"') { + if (source[i + 1] === '"') { + field += '"'; + i += 2; + continue; + } + inQuotes = false; + i += 1; + continue; + } + field += ch; + i += 1; + continue; + } + + if (ch === '"') { + inQuotes = true; + i += 1; + continue; + } + if (ch === ",") { + pushField(); + i += 1; + continue; + } + if (ch === "\r") { + if (source[i + 1] === "\n") { + i += 1; + } + pushField(); + pushRow(); + i += 1; + continue; + } + if (ch === "\n") { + pushField(); + pushRow(); + i += 1; + continue; + } + field += ch; + i += 1; + } + + if (inQuotes) { + throw new Error("Unterminated quoted field in CSV"); + } + + if (field.length > 0 || row.length > 0 || source.endsWith(",") || source.endsWith("\n") || source.endsWith("\r")) { + // Avoid counting or materializing a phantom blank row after a final + // newline. A trailing comma still represents a real empty field. + const isTrailingEmpty = + row.length === 0 && + field === "" && + rows.length > 0 && + (source.endsWith("\n") || source.endsWith("\r")); + if (!isTrailingEmpty) { + pushField(); + pushRow(); + } + } + + return rows; +} + +function needsQuotes(value) { + return /[",\r\n]/.test(value); +} + +function serializeField(value) { + if (value == null) { + return ""; + } + const text = String(value); + if (needsQuotes(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +function serializeCsv(rows) { + if (!Array.isArray(rows)) { + throw new Error("serializeCsv expects an array of rows"); + } + return rows + .map((row) => { + const cells = Array.isArray(row) ? row : [row]; + return cells.map(serializeField).join(","); + }) + .join("\n"); +} + +module.exports = { + MAX_CSV_PARSED_CELLS, + parseCsv, + serializeCsv, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/engine.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/engine.js new file mode 100644 index 00000000..ace0ae5c --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/engine.js @@ -0,0 +1,2910 @@ +/** + * Sparo Excel Engine — workbook session API for Excel Live bridge. + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { + createEmpty, + loadJson, + saveJson, + ensureDir, + workbookDir, + getSheet, + findSheetByName, + findWorkbookIdByPath, + listWorkbookMetas, + cellKey, + createEmptySheet, + newId, + nowIso, + normalizeWorkbook, + normalizeMode, + normalizeSheetLayout, + validateExcelSheetName, + validateWorkbookId, +} = require("./workbook-store"); +const { + EXCEL_MAX_COLUMNS, + EXCEL_MAX_ROWS, + assertCellCoordinates, + assertSheetDimensions, + parseA1, + formatA1, + cellCount, + clampRange, +} = require("./a1"); +const { serializeCsv } = require("./csv-io"); +const { + readWorkbookFile, + readStableFile, + fingerprintsEqual, + writeWorkbookFile, + writeFileAtomic, + sheetToMatrix, +} = require("./xlsx-io"); + +const DEFAULT_MAX_CELLS = 2000; +const MAX_READ_RANGE_CELLS = 20_000; +const MAX_HISTORY = 500; +const MAX_UNDO_STACK = 100; +const MAX_PROPOSAL_CELLS = 5_000; +const MAX_LOCAL_PATCH_CELLS = 20_000; +const MAX_ACCEPT_SELECTION_CELLS = 5_000; +const MAX_WORKBOOK_CELLS = 500_000; +const MAX_LAYOUT_BANDS = 5_000; +const MAX_CUSTOM_LAYOUT_ROWS = 20_000; +const sessions = new Map(); + +function requireWorkspace(input) { + const workspacePath = input && input.workspacePath; + if (!workspacePath || typeof workspacePath !== "string") { + throw new Error( + "[INVALID_WORKSPACE_PATH] workspacePath is required and must be injected by the host as an absolute path." + ); + } + if (workspacePath.includes("\0") || !path.isAbsolute(workspacePath)) { + throw new Error( + "[INVALID_WORKSPACE_PATH] workspacePath must be a host-provided absolute path without NUL characters." + ); + } + return path.resolve(workspacePath); +} + +function requireWorkbookId(input) { + const workbookId = input && input.workbookId; + if (!workbookId || typeof workbookId !== "string") { + throw new Error("workbookId is required"); + } + return validateWorkbookId(workbookId); +} + +function trustedConsumerToken(input) { + return String(input?.__trustedConsumerKind || "") + .trim() + .toLowerCase() + .replace(/[^a-z]/g, ""); +} + +function assertConsumerPathAllowed(input, workspacePath, targetPath, action) { + if (trustedConsumerToken(input) !== "agentcomponent") return targetPath; + const workspaceIdentity = canonicalPathIdentity(workspacePath); + const targetIdentity = canonicalPathIdentity(targetPath); + const prefix = workspaceIdentity.endsWith(path.sep) + ? workspaceIdentity + : `${workspaceIdentity}${path.sep}`; + if (targetIdentity !== workspaceIdentity && !targetIdentity.startsWith(prefix)) { + throw new Error( + `[WORKSPACE_PATH_REQUIRED] ${action} must stay inside the host workspace. Product App file dialogs are the only supported way to authorize an external path.` + ); + } + return targetPath; +} + +function resolveConsumerPath(input, workspacePath, value, action) { + if (typeof value !== "string" || !value.trim() || value.includes("\0")) { + throw new Error(`${action} path must be a non-empty string without NUL characters`); + } + const resolved = path.isAbsolute(value) + ? path.resolve(value) + : path.resolve(workspacePath, value); + return assertConsumerPathAllowed(input, workspacePath, resolved, action); +} + +function prepareAtomicTargetWrite(input, targetPath, sourcePath = null) { + if (!fs.existsSync(targetPath)) { + return { expectTargetMissing: true }; + } + if (sourcePath && pathsReferToSameFile(targetPath, sourcePath)) { + return {}; + } + const productAppConfirmed = trustedConsumerToken(input) === "productappruntime" + && input.overwriteExisting === true; + if (!productAppConfirmed) { + throw new Error( + `[EXISTING_TARGET_OVERWRITE_BLOCKED] ${targetPath} already exists. Choose a new file name, or confirm replacement in the Product App save dialog.` + ); + } + const currentFingerprint = readStableFile(targetPath).fingerprint; + if ( + input.expectedTargetFingerprint + && !fingerprintsEqual(input.expectedTargetFingerprint, currentFingerprint) + ) { + throw new Error( + `[EXPORT_TARGET_CHANGED] ${targetPath} changed after overwrite confirmation. Reopen the save dialog and review the current file before replacing it.` + ); + } + return { expectedTargetFingerprint: currentFingerprint }; +} + +function touch(workbook) { + workbook.updatedAt = nowIso(); + return workbook; +} + +function persist(workspacePath, workbook) { + assertWorkbookWithinLimits(workbook); + reconcileCalculationStatus(workbook); + const saved = saveJson(workspacePath, touch(workbook)); + sessions.set(saved.workbookId, { workspacePath, workbook: saved }); + return saved; +} + +function cloneJson(value) { + if (value == null) return value; + return JSON.parse(JSON.stringify(value)); +} + +function cloneCell(cell) { + return cell ? cloneJson(cell) : null; +} + +const STYLE_ROLES = Object.freeze({ + title: { + fill: { color: "#17365D" }, + font: { bold: true, color: "#FFFFFF", size: 14 }, + alignment: { vertical: "center" }, + }, + header: { + fill: { color: "#1F4E78" }, + font: { bold: true, color: "#FFFFFF" }, + border: { bottom: { style: "thin", color: "#17365D" } }, + alignment: { vertical: "center", wrapText: true }, + }, + input: { + fill: { color: "#FFF2CC" }, + border: { bottom: { style: "thin", color: "#D6B656" } }, + }, + output: { + fill: { color: "#DDEBF7" }, + font: { color: "#17365D" }, + }, + total: { + font: { bold: true }, + border: { top: { style: "double", color: "#1F1F1F" } }, + }, + note: { + font: { italic: true, color: "#666666" }, + alignment: { wrapText: true }, + }, + warning: { + fill: { color: "#FCE4D6" }, + font: { bold: true, color: "#C00000" }, + }, +}); + +const BORDER_STYLES = new Set([ + "hair", "dotted", "dashDotDot", "dashDot", "dashed", "thin", "mediumDashDotDot", + "slantDashDot", "mediumDashDot", "mediumDashed", "medium", "thick", "double", +]); +const HORIZONTAL_ALIGNMENTS = new Set(["general", "left", "center", "right", "fill", "justify", "centerContinuous", "distributed"]); +const VERTICAL_ALIGNMENTS = new Set(["top", "center", "bottom", "justify", "distributed"]); + +function mergeObjects(base, override) { + const result = cloneJson(base) || {}; + for (const [key, value] of Object.entries(override || {})) { + if (value === undefined) continue; + if (value === null) { + delete result[key]; + } else if (value && typeof value === "object" && !Array.isArray(value)) { + result[key] = mergeObjects(result[key], value); + } else { + result[key] = value; + } + } + return result; +} + +function normalizeColor(value, context) { + if (typeof value !== "string") throw new Error(`${context} color must be a hex string`); + let color = value.trim().toUpperCase(); + if (/^#[0-9A-F]{3}$/.test(color)) { + color = `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`; + } + if (/^[0-9A-F]{6}$/.test(color)) color = `#${color}`; + if (!/^#[0-9A-F]{6}$/.test(color)) { + throw new Error(`${context} color must use #RRGGBB`); + } + return color; +} + +function normalizeBorderSide(value, context) { + if (typeof value === "string") value = { style: value }; + if (!value || typeof value !== "object") throw new Error(`${context} must be an object or border style`); + const style = value.style == null ? "thin" : String(value.style); + if (!BORDER_STYLES.has(style)) throw new Error(`${context}.style is not a supported Excel border style`); + const result = { style }; + if (value.color != null) result.color = normalizeColor(value.color, context); + return result; +} + +function normalizeStyle(style, styleRole, baseStyle = null) { + if (style === undefined && styleRole === undefined) return cloneJson(baseStyle) || null; + if (style === null && (styleRole == null || styleRole === "")) return null; + if (style != null && typeof style !== "object") throw new Error("cell style must be an object or null"); + const requestedRole = styleRole || style?.role || null; + if (requestedRole && !Object.prototype.hasOwnProperty.call(STYLE_ROLES, requestedRole)) { + throw new Error(`styleRole must be one of: ${Object.keys(STYLE_ROLES).join(", ")}`); + } + let merged = cloneJson(baseStyle) || {}; + if (requestedRole && merged.role && merged.role !== requestedRole) { + merged = merged.numberFormat ? { numberFormat: merged.numberFormat } : {}; + } + if (requestedRole) merged = mergeObjects(merged, STYLE_ROLES[requestedRole]); + merged = mergeObjects(merged, style || {}); + if (requestedRole) merged.role = requestedRole; + else if (merged.role && !Object.prototype.hasOwnProperty.call(STYLE_ROLES, merged.role)) delete merged.role; + + const result = {}; + if (merged.role) result.role = merged.role; + if (merged.fill != null) { + if (typeof merged.fill !== "object" || merged.fill.color == null) throw new Error("style.fill requires color"); + result.fill = { color: normalizeColor(merged.fill.color, "style.fill") }; + } + if (merged.font != null) { + if (typeof merged.font !== "object") throw new Error("style.font must be an object"); + const font = {}; + if (merged.font.bold != null) { + if (typeof merged.font.bold !== "boolean") throw new Error("style.font.bold must be boolean"); + if (merged.font.bold) font.bold = true; + } + if (merged.font.italic != null) { + if (typeof merged.font.italic !== "boolean") throw new Error("style.font.italic must be boolean"); + if (merged.font.italic) font.italic = true; + } + if (merged.font.color != null) font.color = normalizeColor(merged.font.color, "style.font"); + if (merged.font.size != null) { + const size = Number(merged.font.size); + if (!Number.isFinite(size) || size < 6 || size > 72) throw new Error("style.font.size must be between 6 and 72 points"); + font.size = size; + } + if (Object.keys(font).length) result.font = font; + } + if (merged.border != null) { + if (typeof merged.border !== "object") throw new Error("style.border must be an object"); + const border = {}; + for (const side of ["top", "right", "bottom", "left"]) { + if (merged.border[side] != null) border[side] = normalizeBorderSide(merged.border[side], `style.border.${side}`); + } + if (Object.keys(border).length) result.border = border; + } + if (merged.alignment != null) { + if (typeof merged.alignment !== "object") throw new Error("style.alignment must be an object"); + const alignment = {}; + if (merged.alignment.horizontal != null) { + const horizontal = String(merged.alignment.horizontal); + if (!HORIZONTAL_ALIGNMENTS.has(horizontal)) throw new Error("style.alignment.horizontal is not supported"); + alignment.horizontal = horizontal; + } + if (merged.alignment.vertical != null) { + const vertical = String(merged.alignment.vertical); + if (!VERTICAL_ALIGNMENTS.has(vertical)) throw new Error("style.alignment.vertical is not supported"); + alignment.vertical = vertical; + } + if (merged.alignment.wrapText != null) { + if (typeof merged.alignment.wrapText !== "boolean") throw new Error("style.alignment.wrapText must be boolean"); + if (merged.alignment.wrapText) alignment.wrapText = true; + } + if (Object.keys(alignment).length) result.alignment = alignment; + } + if (merged.numberFormat != null) { + const numberFormat = String(merged.numberFormat).trim(); + if (!numberFormat || numberFormat.length > 120) throw new Error("style.numberFormat must be 1-120 characters"); + result.numberFormat = numberFormat; + } + return Object.keys(result).length ? result : null; +} + +function stableEqual(left, right) { + return JSON.stringify(left == null ? null : left) === JSON.stringify(right == null ? null : right); +} + +function cellsEqual(left, right) { + const a = left || null; + const b = right || null; + if (a === b) return true; + if (!a || !b) return false; + return ( + a.v === b.v && + a.f === b.f && + a.t === b.t && + Boolean(a.formulaEvidence) === Boolean(b.formulaEvidence) && + (a.formulaType || null) === (b.formulaType || null) && + (a.formulaRef || null) === (b.formulaRef || null) && + (a.formulaGroupType || null) === (b.formulaGroupType || null) && + (a.formulaGroupRef || null) === (b.formulaGroupRef || null) && + stableEqual(a.style, b.style) + ); +} + +function cellsContentEqual(left, right) { + const clean = (cell) => { + if (!cell) return null; + const { style: _style, _sourceStyleIndex: _sourceStyleIndex, ...content } = cell; + return Object.keys(content).length ? content : null; + }; + return stableEqual(clean(left), clean(right)); +} + +function cellHasFormulaEvidence(cell) { + return Boolean( + cell && + (cell.formulaEvidence === true || + (typeof cell.f === "string" && cell.f.length > 0)) + ); +} + +function assertWorkbookWithinLimits(workbook) { + if (!workbook || !Array.isArray(workbook.sheets) || workbook.sheets.length === 0) { + throw new Error("Workbook must contain at least one worksheet"); + } + let workbookCellCount = 0; + for (const sheet of workbook.sheets) { + assertSheetDimensions(Number(sheet.rows), Number(sheet.cols), `Worksheet ${sheet.name || sheet.id}`); + for (const key of Object.keys(sheet.cells || {})) { + workbookCellCount += 1; + if (workbookCellCount > MAX_WORKBOOK_CELLS) { + throw new Error( + `[WORKBOOK_CELL_LIMIT] Workbook contains more than ${MAX_WORKBOOK_CELLS} populated cell records.` + ); + } + const parts = key.split(","); + if (parts.length !== 2) { + throw new Error(`[EXCEL_CELL_LIMIT] Invalid workbook cell key: ${key}`); + } + const row = Number(parts[0]); + const col = Number(parts[1]); + assertCellCoordinates(row, col, `Workbook cell ${key}`); + } + } + return workbook; +} + +function requirePositiveSafeInteger(value, fallback, name) { + const resolved = value == null ? fallback : Number(value); + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error(`${name} must be a positive safe integer`); + } + return resolved; +} + +function populatedCellCount(workbook) { + return (workbook.sheets || []).reduce( + (total, sheet) => total + Object.keys(sheet.cells || {}).length, + 0 + ); +} + +function assertCellMutationCapacity(workbook, changes) { + let nextCount = populatedCellCount(workbook); + for (const change of changes || []) { + if (!change.before && change.after) nextCount += 1; + if (change.before && !change.after) nextCount -= 1; + } + if (!Number.isSafeInteger(nextCount) || nextCount > MAX_WORKBOOK_CELLS) { + throw new Error( + `[WORKBOOK_CELL_LIMIT] Operation would exceed ${MAX_WORKBOOK_CELLS} populated workbook cells.` + ); + } +} + +function assertExpectedRevision(workbook, input, action) { + const supplied = input.expectedRevision ?? input.baseRevision; + if (supplied == null) { + throw new Error( + `[EXPECTED_REVISION_REQUIRED] ${action} requires expectedRevision so concurrent workbook changes cannot be overwritten.` + ); + } + const expected = Number(supplied); + if (!Number.isSafeInteger(expected) || expected < 0) { + throw new Error(`${action} expectedRevision must be a non-negative integer`); + } + if (expected !== workbook.revision) { + throw new Error( + `[REVISION_CONFLICT] ${action} expected revision ${expected}, but the workbook is at revision ${workbook.revision}. Refresh and retry.` + ); + } +} + +function canonicalPathIdentity(value) { + const resolved = path.resolve(String(value || "")); + const missing = []; + let cursor = resolved; + while (!fs.existsSync(cursor)) { + const parent = path.dirname(cursor); + if (parent === cursor) break; + missing.unshift(path.basename(cursor)); + cursor = parent; + } + let canonicalBase = cursor; + try { + canonicalBase = fs.realpathSync.native(cursor); + } catch (_error) { + canonicalBase = path.resolve(cursor); + } + const canonical = path.resolve(canonicalBase, ...missing); + return process.platform === "win32" ? canonical.toLowerCase() : canonical; +} + +function pathsReferToSameFile(left, right) { + if (!left || !right) return false; + const leftPath = path.resolve(String(left)); + const rightPath = path.resolve(String(right)); + if (canonicalPathIdentity(leftPath) === canonicalPathIdentity(rightPath)) return true; + if (!fs.existsSync(leftPath) || !fs.existsSync(rightPath)) return false; + try { + const leftStat = fs.statSync(leftPath, { bigint: true }); + const rightStat = fs.statSync(rightPath, { bigint: true }); + return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino; + } catch (_error) { + return false; + } +} + +function assertWritable(workbook, action) { + if (workbook.mode === "inspect") { + throw new Error( + `[INSPECT_MODE_READ_ONLY] ${action} is not allowed while the workbook is in Inspect mode. Switch to Edit or Author mode first.` + ); + } +} + +function formulaCount(workbook) { + let count = 0; + for (const sheet of workbook.sheets || []) { + for (const cell of Object.values(sheet.cells || {})) { + if (cellHasFormulaEvidence(cell)) count += 1; + } + } + return count; +} + +function reconcileCalculationStatus(workbook) { + const count = formulaCount(workbook); + if (count === 0) { + workbook.calculationStatus = { + engine: "none", + status: "not-required", + formulaCount: 0, + lastCalculatedRevision: null, + warning: null, + }; + return workbook.calculationStatus; + } + const previous = workbook.calculationStatus && typeof workbook.calculationStatus === "object" + ? workbook.calculationStatus + : {}; + const previousStatus = String(previous.status || "").toLowerCase(); + workbook.calculationStatus = { + ...previous, + engine: "none", + status: workbook.dirty || previousStatus === "stale" ? "stale" : "cached", + formulaCount: count, + lastCalculatedRevision: null, + warning: "Formula results are cached values; this engine does not recalculate formulas.", + }; + return workbook.calculationStatus; +} + +function refreshCalculationStatus(workbook, contentChanged = false) { + const count = formulaCount(workbook); + const wasStale = String(workbook.calculationStatus?.status || "").toLowerCase() === "stale"; + workbook.calculationStatus = { + engine: "none", + status: count === 0 ? "not-required" : contentChanged || wasStale ? "stale" : "cached", + formulaCount: count, + lastCalculatedRevision: null, + warning: count > 0 + ? "Formula results are cached values; this engine does not recalculate formulas." + : null, + }; +} + +function operationAudit(operation, action, targetOperationId = null) { + return { + id: operation.id, + type: action || operation.type, + actor: operation.actor, + intent: operation.intent || null, + summary: operation.summary || null, + sheetId: operation.sheetId || null, + a1: operation.a1 || null, + cellCount: operation.cellCount || 0, + baseRevision: operation.baseRevision, + revision: operation.revision, + targetOperationId, + createdAt: operation.createdAt, + }; +} + +function appendHistory(workbook, entry) { + workbook.history.push(entry); + if (workbook.history.length > MAX_HISTORY) { + workbook.history.splice(0, workbook.history.length - MAX_HISTORY); + } +} + +function recordOperation(workbook, descriptor) { + const baseRevision = workbook.revision; + const invalidatesSourceRoundTrip = + descriptor.roundTripSafe === false && + Boolean(workbook.sourcePath) && + ["xlsx", "xlsm"].includes(String(workbook.sourceFormat || "").toLowerCase()); + const fidelityBefore = invalidatesSourceRoundTrip + ? cloneJson(workbook.fidelity) + : null; + workbook.revision += 1; + const operation = { + id: newId("op"), + type: descriptor.type, + actor: descriptor.actor || "user", + intent: descriptor.intent || null, + summary: descriptor.summary || null, + sheetId: descriptor.sheetId || null, + a1: descriptor.a1 || null, + cellCount: descriptor.cellCount || 0, + baseRevision, + revision: workbook.revision, + createdAt: nowIso(), + roundTripSafe: descriptor.roundTripSafe !== false, + contentChanged: descriptor.contentChanged !== false, + change: descriptor.change, + }; + workbook.undoStack.push(operation); + if (workbook.undoStack.length > MAX_UNDO_STACK) { + workbook.undoStack.splice(0, workbook.undoStack.length - MAX_UNDO_STACK); + } + workbook.redoStack = []; + appendHistory(workbook, operationAudit(operation)); + workbook.dirty = true; + if (invalidatesSourceRoundTrip && workbook.fidelity) { + workbook.fidelity.canRoundTrip = false; + if (descriptor.structureChanged !== false) workbook.fidelity.structureChanged = true; + workbook.fidelity.warning = descriptor.fidelityWarning || + "Workbook structure changed and cannot be patched into the source package losslessly. Export requires explicit fidelity-loss acknowledgement."; + operation.fidelityBefore = fidelityBefore; + operation.fidelityAfter = cloneJson(workbook.fidelity); + } + refreshCalculationStatus(workbook, operation.contentChanged); + return operation; +} + +function setSheetCell(sheet, row, col, cell) { + assertCellCoordinates(row, col, "Worksheet cell"); + const key = cellKey(row, col); + if (!cell) { + delete sheet.cells[key]; + return; + } + if (row >= sheet.rows) sheet.rows = row + 1; + if (col >= sheet.cols) sheet.cols = col + 1; + sheet.cells[key] = cloneCell(cell); +} + +function captureWorkbookContent(workbook) { + return cloneJson({ + title: workbook.title, + sheets: workbook.sheets, + activeSheetId: workbook.activeSheetId, + focus: workbook.focus, + path: workbook.path, + sourcePath: workbook.sourcePath, + sourceFormat: workbook.sourceFormat, + sourceFingerprint: workbook.sourceFingerprint, + fidelity: workbook.fidelity, + }); +} + +function restoreWorkbookContent(workbook, state) { + workbook.title = state.title; + workbook.sheets = cloneJson(state.sheets); + workbook.activeSheetId = state.activeSheetId; + workbook.focus = cloneJson(state.focus); + if (Object.prototype.hasOwnProperty.call(state, "path")) workbook.path = state.path; + if (Object.prototype.hasOwnProperty.call(state, "sourcePath")) workbook.sourcePath = state.sourcePath; + if (Object.prototype.hasOwnProperty.call(state, "sourceFormat")) workbook.sourceFormat = state.sourceFormat; + if (Object.prototype.hasOwnProperty.call(state, "sourceFingerprint")) { + workbook.sourceFingerprint = cloneJson(state.sourceFingerprint); + } + if (state.fidelity) workbook.fidelity = cloneJson(state.fidelity); +} + +function applyOperationChange(workbook, operation, direction) { + const change = operation.change || {}; + const useAfter = direction === "after"; + if (change.kind === "cells") { + const sheet = getSheet(workbook, change.sheetId); + for (const cell of change.cells || []) { + setSheetCell(sheet, cell.r, cell.c, useAfter ? cell.after : cell.before); + } + const size = useAfter ? change.afterSize : change.beforeSize; + if (size) { + sheet.rows = size.rows; + sheet.cols = size.cols; + } + return; + } + if (change.kind === "sheet") { + const snapshot = cloneJson(useAfter ? change.after : change.before); + const index = workbook.sheets.findIndex((sheet) => sheet.id === change.sheetId); + if (index < 0) throw new Error(`Cannot restore missing sheet: ${change.sheetId}`); + workbook.sheets[index] = snapshot; + return; + } + if (change.kind === "sheet-name") { + getSheet(workbook, change.sheetId).name = useAfter ? change.after : change.before; + return; + } + if (change.kind === "layout") { + getSheet(workbook, change.sheetId).layout = cloneJson(useAfter ? change.after : change.before); + return; + } + if (change.kind === "compound") { + for (const part of change.changes || []) { + applyOperationChange(workbook, { change: part }, direction); + } + return; + } + if (change.kind === "workbook") { + restoreWorkbookContent(workbook, useAfter ? change.after : change.before); + return; + } + throw new Error(`Unsupported reversible operation kind: ${change.kind || "unknown"}`); +} + +function undo(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "undo"); + assertExpectedRevision(workbook, input, "undo"); + const target = workbook.undoStack[workbook.undoStack.length - 1]; + if (!target) { + return { summary: "Nothing to undo", operation: null, meta: metaOf(workbook) }; + } + applyOperationChange(workbook, target, "before"); + if (target.fidelityBefore) workbook.fidelity = cloneJson(target.fidelityBefore); + workbook.undoStack.pop(); + workbook.proposal = null; + const baseRevision = workbook.revision; + workbook.revision += 1; + workbook.redoStack.push(target); + const audit = { + id: newId("op"), + type: "undo", + actor: input.actor || "user", + intent: input.intent || `Undo ${target.type}`, + summary: `Undid ${target.type}`, + sheetId: target.sheetId || null, + a1: target.a1 || null, + cellCount: target.cellCount || 0, + baseRevision, + revision: workbook.revision, + createdAt: nowIso(), + }; + appendHistory(workbook, operationAudit(audit, "undo", target.id)); + workbook.dirty = true; + refreshCalculationStatus(workbook, target.contentChanged !== false); + const saved = persist(workspacePath, workbook); + return { summary: audit.summary, operation: operationAudit(audit, "undo", target.id), meta: metaOf(saved) }; +} + +function redo(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "redo"); + assertExpectedRevision(workbook, input, "redo"); + const target = workbook.redoStack[workbook.redoStack.length - 1]; + if (!target) { + return { summary: "Nothing to redo", operation: null, meta: metaOf(workbook) }; + } + applyOperationChange(workbook, target, "after"); + if (target.fidelityAfter) workbook.fidelity = cloneJson(target.fidelityAfter); + workbook.redoStack.pop(); + workbook.proposal = null; + const baseRevision = workbook.revision; + workbook.revision += 1; + workbook.undoStack.push(target); + const audit = { + id: newId("op"), + type: "redo", + actor: input.actor || "user", + intent: input.intent || `Redo ${target.type}`, + summary: `Redid ${target.type}`, + sheetId: target.sheetId || null, + a1: target.a1 || null, + cellCount: target.cellCount || 0, + baseRevision, + revision: workbook.revision, + createdAt: nowIso(), + }; + appendHistory(workbook, operationAudit(audit, "redo", target.id)); + workbook.dirty = true; + refreshCalculationStatus(workbook, target.contentChanged !== false); + const saved = persist(workspacePath, workbook); + return { summary: audit.summary, operation: operationAudit(audit, "redo", target.id), meta: metaOf(saved) }; +} + +function getHistory(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + const requested = input.limit == null ? 50 : Number(input.limit); + const limit = Math.max(1, Math.min(200, Number.isFinite(requested) ? Math.floor(requested) : 50)); + const history = workbook.history.slice(-limit).reverse(); + return { + summary: `${history.length} operation(s), revision ${workbook.revision}`, + revision: workbook.revision, + canUndo: workbook.undoStack.length > 0, + canRedo: workbook.redoStack.length > 0, + history, + entries: history, + }; +} + +function getSession(workbookId) { + const session = sessions.get(workbookId); + if (!session) { + throw new Error( + `Workbook session not found: ${workbookId}. Open or create a workbook first.` + ); + } + return session; +} + +function loadOrGet(workspacePath, workbookId) { + if (sessions.has(workbookId)) { + const session = sessions.get(workbookId); + if (session.workspacePath === workspacePath) { + return assertWorkbookWithinLimits(session.workbook); + } + } + const workbook = assertWorkbookWithinLimits(loadJson(workspacePath, workbookId)); + reconcileCalculationStatus(workbook); + sessions.set(workbookId, { workspacePath, workbook }); + return workbook; +} + +function resolveSheet(workbook, sheetId, a1Ref) { + let qualifiedSheet = null; + if (a1Ref) { + const parsed = parseA1(a1Ref); + if (parsed.sheet) { + qualifiedSheet = findSheetByName(workbook, parsed.sheet) + || workbook.sheets.find((sheet) => sheet.id === parsed.sheet) + || null; + if (!qualifiedSheet) throw new Error(`Sheet not found: ${parsed.sheet}`); + } + } + if (sheetId) { + const explicitSheet = getSheet(workbook, sheetId); + if (qualifiedSheet && qualifiedSheet.id !== explicitSheet.id) { + throw new Error( + `[SHEET_QUALIFIER_CONFLICT] sheetId targets ${explicitSheet.name}, but a1 targets ${qualifiedSheet.name}. Use one consistent sheet target.` + ); + } + return explicitSheet; + } + if (qualifiedSheet) return qualifiedSheet; + return getSheet(workbook, workbook.activeSheetId); +} + +function resolveRange(workbook, input = {}) { + const sheet = resolveSheet(workbook, input.sheetId, input.a1); + let range; + if (input.a1) { + range = parseA1(input.a1); + } else if (workbook.focus && workbook.focus.sheetId === sheet.id && workbook.focus.a1) { + range = parseA1(workbook.focus.a1); + } else { + range = { sheet: null, r1: 0, c1: 0, r2: 0, c2: 0 }; + } + range = clampRange(range, sheet.rows, sheet.cols); + return { sheet, range, a1: formatA1(range.r1, range.c1, range.r2, range.c2) }; +} + +function cellDisplay(cell) { + if (!cell) return null; + const result = cellHasFormulaEvidence(cell) + ? { + v: cell.v != null ? cell.v : null, + f: typeof cell.f === "string" && cell.f ? cell.f : null, + t: cell.t || "f", + formulaEvidence: true, + formulaType: cell.formulaType || cell.formulaGroupType || null, + formulaRef: cell.formulaRef || cell.formulaGroupRef || null, + } + : { + v: cell.v != null ? cell.v : null, + t: cell.t || (typeof cell.v === "number" ? "n" : "s"), + }; + if (cell.style) result.style = cloneJson(cell.style); + return result; +} + +function valueCell(value) { + if (value === null || value === "") return {}; + if (typeof value === "number") return { v: value, t: "n" }; + if (typeof value === "boolean") return { v: value, t: "b" }; + const text = String(value); + const num = Number(text); + if (text.trim() !== "" && !Number.isNaN(num) && /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(text.trim())) { + return { v: num, t: "n" }; + } + return { v: text, t: "s" }; +} + +function cellFromPatch(before, patch) { + const previous = cloneCell(before) || {}; + let next = cloneCell(before) || {}; + const hasFormula = Object.prototype.hasOwnProperty.call(patch, "formula") && patch.formula != null; + const hasValue = Object.prototype.hasOwnProperty.call(patch, "value") && patch.value !== undefined; + if (hasFormula) { + let f = String(patch.formula).trim(); + if (f.startsWith("=")) f = f.slice(1); + next = { f, t: "f", formulaEvidence: true }; + if (hasValue) next.v = patch.value; + } else if (hasValue) { + next = valueCell(patch.value); + } + const style = normalizeStyle(patch.style, patch.styleRole, previous.style || null); + if (style) next.style = style; + else delete next.style; + if (previous._sourceStyleIndex != null) next._sourceStyleIndex = previous._sourceStyleIndex; + const publicKeys = Object.keys(next).filter((key) => key !== "_sourceStyleIndex"); + return publicKeys.length ? next : null; +} + +function setCell(sheet, row, col, patch) { + assertCellCoordinates(row, col, "Worksheet cell"); + if (row >= sheet.rows) sheet.rows = row + 1; + if (col >= sheet.cols) sheet.cols = col + 1; + const key = cellKey(row, col); + const next = cellFromPatch(sheet.cells[key] || null, patch); + setSheetCell(sheet, row, col, next); + return next; +} + +function metaOf(workbook) { + reconcileCalculationStatus(workbook); + const count = formulaCount(workbook); + return { + workbookId: workbook.workbookId, + title: workbook.title, + path: workbook.path, + sourcePath: workbook.sourcePath || null, + sourceFormat: workbook.sourceFormat || null, + sourceFingerprint: cloneJson(workbook.sourceFingerprint), + lastExportPath: workbook.lastExportPath || null, + lastExportedRevision: Number.isSafeInteger(workbook.lastExportedRevision) + ? workbook.lastExportedRevision + : null, + dirty: Boolean(workbook.dirty), + revision: workbook.revision, + mode: workbook.mode, + activeSheetId: workbook.activeSheetId, + sheetCount: workbook.sheets.length, + sheets: workbook.sheets.map((s) => ({ + id: s.id, + name: s.name, + rows: s.rows, + cols: s.cols, + cellCount: Object.keys(s.cells || {}).length, + layout: cloneJson(s.layout), + })), + focus: workbook.focus, + hasProposal: Boolean(workbook.proposal), + proposalId: workbook.proposal ? workbook.proposal.id : null, + proposalBaseRevision: workbook.proposal ? workbook.proposal.baseRevision : null, + capabilities: { + revisionedEdits: true, + undoRedo: true, + canUndo: workbook.undoStack.length > 0, + canRedo: workbook.redoStack.length > 0, + history: true, + partialProposalAccept: true, + localPatch: true, + inspectModeEnforced: true, + atomicStoreWrites: true, + sourceOverwriteProtected: true, + formulaRecalculation: false, + formulaStaticLint: true, + cellStyles: true, + semanticStyleRoles: Object.keys(STYLE_ROLES), + numberFormats: true, + layoutMetadata: true, + styleInterpretation: "basic-rgb-font-fill-border-alignment-number-format", + unsupportedStyleInterpretation: ["theme colors", "indexed colors", "conditional formatting", "named styles"], + styleSourcePatch: !workbook.sourcePath, + layoutSourcePatch: !workbook.sourcePath, + sourcePreservingStylePatch: !workbook.sourcePath, + sourcePreservingLayoutPatch: !workbook.sourcePath, + losslessRoundTrip: Boolean(workbook.fidelity?.canRoundTrip), + structuralEditsAllowed: count === 0 && workbook.mode !== "inspect", + }, + fidelity: cloneJson(workbook.fidelity), + calculationStatus: cloneJson(workbook.calculationStatus), + createdAt: workbook.createdAt, + updatedAt: workbook.updatedAt, + }; +} + +function viewportSummary(workbook, sheet, maxRows = 20, maxCols = 10) { + const r2 = Math.min(sheet.rows - 1, maxRows - 1); + const c2 = Math.min(sheet.cols - 1, maxCols - 1); + const values = []; + for (let r = 0; r <= r2; r += 1) { + const row = []; + for (let c = 0; c <= c2; c += 1) { + const cell = sheet.cells[cellKey(r, c)]; + row.push(cell && cell.v != null ? cell.v : cell && cell.f ? `=${cell.f}` : ""); + } + values.push(row); + } + return { + sheetId: sheet.id, + sheetName: sheet.name, + a1: formatA1(0, 0, r2, c2), + values, + }; +} + +function proposalSummary(proposal) { + if (!proposal) return null; + return { + id: proposal.id, + sheetId: proposal.sheetId, + a1: proposal.a1, + cellCount: (proposal.cells || []).length, + baseRevision: proposal.baseRevision, + intent: proposal.intent || null, + validation: cloneJson(proposal.validation), + layout: cloneJson(proposal.layout), + createdAt: proposal.createdAt, + }; +} + +function proposalCellResult(proposal, resultCellLimit) { + const source = proposal?.cells || []; + let limit = source.length; + if (resultCellLimit != null) { + limit = Number(resultCellLimit); + if (!Number.isSafeInteger(limit) || limit < 0 || limit > MAX_PROPOSAL_CELLS) { + throw new Error(`resultCellLimit must be a safe integer between 0 and ${MAX_PROPOSAL_CELLS}`); + } + } + const selected = source.slice(0, limit).map((cell) => ({ + row: cell.r, + col: cell.c, + a1: formatA1(cell.r, cell.c), + before: cellDisplay(cell.before), + after: cellDisplay(cell.after), + })); + return { + cells: selected, + totalCellCount: source.length, + cellDetailsTruncated: selected.length < source.length, + }; +} + +function proposalRange(cells) { + let r1 = Infinity; + let c1 = Infinity; + let r2 = -Infinity; + let c2 = -Infinity; + for (const cell of cells || []) { + r1 = Math.min(r1, cell.r); + c1 = Math.min(c1, cell.c); + r2 = Math.max(r2, cell.r); + c2 = Math.max(c2, cell.c); + } + return Number.isFinite(r1) ? formatA1(r1, c1, r2, c2) : null; +} + +function validateLayoutBands(items, sizeKey, maximum, label) { + if (!Array.isArray(items)) throw new Error(`layout.${label} must be an array`); + if (items.length > MAX_LAYOUT_BANDS) throw new Error(`layout.${label} cannot exceed ${MAX_LAYOUT_BANDS} bands`); + let covered = 0; + for (const item of items) { + if (!item || typeof item !== "object") throw new Error(`layout.${label} entries must be objects`); + const start = Number(item.start); + const end = item.end == null ? start : Number(item.end); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= maximum) { + throw new Error(`layout.${label} start/end must be 0-based inclusive indexes within the current or proposed sheet dimensions`); + } + if (item[sizeKey] == null && item.autoFit !== true) { + throw new Error(`layout.${label} entries require ${sizeKey} or autoFit=true`); + } + if (item[sizeKey] != null) { + const size = Number(item[sizeKey]); + const upper = sizeKey === "width" ? 255 : 409; + if (!Number.isFinite(size) || size <= 0 || size > upper) { + throw new Error(`layout.${label}.${sizeKey} must be greater than 0 and at most ${upper}`); + } + } + covered += end - start + 1; + if (label === "rows" && covered > MAX_CUSTOM_LAYOUT_ROWS) { + throw new Error(`layout.rows cannot materialize more than ${MAX_CUSTOM_LAYOUT_ROWS} custom-height rows`); + } + } +} + +function mergeLayoutBands(current, changes, sizeKey) { + let result = cloneJson(current) || []; + for (const change of changes) { + const start = Number(change.start); + const end = change.end == null ? start : Number(change.end); + const preserved = []; + for (const existing of result) { + if (existing.end < start || existing.start > end) { + preserved.push(existing); + continue; + } + if (existing.start < start) preserved.push({ ...existing, end: start - 1 }); + if (existing.end > end) preserved.push({ ...existing, start: end + 1 }); + } + const next = { start, end }; + if (change[sizeKey] != null) next[sizeKey] = Number(change[sizeKey]); + if (change.autoFit === true) next.autoFit = true; + result = [...preserved, next]; + } + return result.sort((a, b) => a.start - b.start || a.end - b.end); +} + +function displayWidth(value) { + let width = 0; + for (const character of String(value == null ? "" : value)) { + width += character.codePointAt(0) > 255 ? 2 : 1; + } + return width; +} + +function autoFitColumnWidth(sheet, start, end, pendingCells) { + const pending = new Map((pendingCells || []).map((cell) => [`${cell.r},${cell.c}`, cell.after])); + let longest = 0; + const keys = new Set([...Object.keys(sheet.cells || {}), ...pending.keys()]); + for (const key of keys) { + const [row, col] = key.split(",").map(Number); + if (col < start || col > end) continue; + const cell = pending.has(key) ? pending.get(key) : sheet.cells[key]; + const display = cell?.v != null ? cell.v : cell?.f ? `=${cell.f}` : ""; + for (const line of String(display).split(/\r?\n/)) longest = Math.max(longest, displayWidth(line)); + } + return Math.max(10, Math.min(60, longest ? longest + 2 : 18)); +} + +function autoFitRowHeight(sheet, start, end, pendingCells) { + const pending = new Map((pendingCells || []).map((cell) => [`${cell.r},${cell.c}`, cell.after])); + let lines = 1; + const keys = new Set([...Object.keys(sheet.cells || {}), ...pending.keys()]); + for (const key of keys) { + const [row] = key.split(",").map(Number); + if (row < start || row > end) continue; + const cell = pending.has(key) ? pending.get(key) : sheet.cells[key]; + const display = cell?.v != null ? cell.v : cell?.f ? `=${cell.f}` : ""; + lines = Math.max(lines, String(display).split(/\r?\n/).length); + } + return Math.max(24, Math.min(120, lines * 15)); +} + +function layoutFromPatch(sheet, patch, pendingCells = []) { + if (patch == null) return null; + if (typeof patch !== "object" || Array.isArray(patch)) throw new Error("layout must be an object"); + const before = normalizeSheetLayout(sheet.layout); + const next = cloneJson(before); + const projectedRows = Math.max(sheet.rows, ...pendingCells.map((cell) => cell.r + 1)); + const projectedColumns = Math.max(sheet.cols, ...pendingCells.map((cell) => cell.c + 1)); + if (Object.prototype.hasOwnProperty.call(patch, "columns")) { + validateLayoutBands(patch.columns, "width", projectedColumns, "columns"); + const columns = patch.columns.map((item) => ({ + ...item, + width: item.width == null && item.autoFit === true + ? autoFitColumnWidth(sheet, Number(item.start), Number(item.end == null ? item.start : item.end), pendingCells) + : item.width, + })); + next.columns = columns.length === 0 ? [] : mergeLayoutBands(next.columns, columns, "width"); + } + if (Object.prototype.hasOwnProperty.call(patch, "rows")) { + validateLayoutBands(patch.rows, "height", projectedRows, "rows"); + const rows = patch.rows.map((item) => ({ + ...item, + height: item.height == null && item.autoFit === true + ? autoFitRowHeight(sheet, Number(item.start), Number(item.end == null ? item.start : item.end), pendingCells) + : item.height, + })); + next.rows = rows.length === 0 ? [] : mergeLayoutBands(next.rows, rows, "height"); + } + if (Object.prototype.hasOwnProperty.call(patch, "freezePanes")) { + if (!patch.freezePanes || typeof patch.freezePanes !== "object") { + throw new Error("layout.freezePanes must be an object"); + } + const rows = patch.freezePanes.rows == null ? next.freezePanes.rows : Number(patch.freezePanes.rows); + const columns = patch.freezePanes.columns == null ? next.freezePanes.columns : Number(patch.freezePanes.columns); + if (!Number.isSafeInteger(rows) || rows < 0 || rows >= projectedRows) { + throw new Error(`layout.freezePanes.rows must be a non-negative safe integer smaller than the projected sheet row count (${projectedRows})`); + } + if (!Number.isSafeInteger(columns) || columns < 0 || columns >= projectedColumns) { + throw new Error(`layout.freezePanes.columns must be a non-negative safe integer smaller than the projected sheet column count (${projectedColumns})`); + } + next.freezePanes = { rows, columns }; + } + if (Object.prototype.hasOwnProperty.call(patch, "autoFilter")) { + if (patch.autoFilter == null) { + next.autoFilter = null; + } else if (typeof patch.autoFilter === "object" && typeof patch.autoFilter.a1 === "string") { + const parsed = parseA1(patch.autoFilter.a1); + if (parsed.sheet) { + const token = String(parsed.sheet).toLowerCase(); + if (token !== String(sheet.id).toLowerCase() && token !== String(sheet.name).toLowerCase()) { + throw new Error(`layout.autoFilter.a1 targets another sheet: ${parsed.sheet}`); + } + } + next.autoFilter = { a1: formatA1(parsed.r1, parsed.c1, parsed.r2, parsed.c2) }; + } else { + throw new Error("layout.autoFilter must be null or {a1}"); + } + } + return { before, after: normalizeSheetLayout(next) }; +} + +function cellChangesStyle(cells) { + return (cells || []).some((cell) => !stableEqual(cell.before?.style, cell.after?.style)); +} + +function cellChangesContent(cells) { + return (cells || []).some((cell) => !cellsContentEqual(cell.before, cell.after)); +} + +function lintFormula(formula, row, col) { + const cell = formatA1(row, col); + const text = String(formula == null ? "" : formula).replace(/^=/, "").trim(); + const errors = []; + const warnings = []; + if (!text) { + errors.push({ code: "FORMULA_EMPTY", cell, message: `${cell} has an empty formula.` }); + return { errors, warnings }; + } + let depth = 0; + let inString = false; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (character === '"') { + if (inString && text[index + 1] === '"') { + index += 1; + } else { + inString = !inString; + } + continue; + } + if (inString) continue; + if (character === "(") depth += 1; + if (character === ")") depth -= 1; + if (depth < 0) break; + } + if (inString) { + errors.push({ code: "FORMULA_QUOTE_UNBALANCED", cell, message: `${cell} has an unbalanced string quote.` }); + } + if (depth !== 0) { + errors.push({ code: "FORMULA_PAREN_UNBALANCED", cell, message: `${cell} has unbalanced parentheses.` }); + } + const normalizedSelf = cell.toUpperCase(); + const references = text.match(/(? reference.replaceAll("$", "").toUpperCase() === normalizedSelf)) { + errors.push({ code: "FORMULA_SELF_REFERENCE", cell, message: `${cell} directly references itself and creates an obvious circular reference.` }); + } + if (/\[[^\]]+\]|(?:https?|ftp):\/\/|\\\\|\b(?:WEBSERVICE|FILTERXML|RTD)\s*\(/i.test(text)) { + warnings.push({ code: "FORMULA_EXTERNAL_LINK", cell, message: `${cell} contains an external-link or external-data pattern. Review trust and portability before accepting.` }); + } + return { errors, warnings }; +} + +function lintPatchFormulas(cells) { + const errors = []; + const warnings = []; + let checkedFormulaCount = 0; + for (const cell of cells || []) { + if (!cellHasFormulaEvidence(cell.after)) continue; + if (cellHasFormulaEvidence(cell.before) && cell.before.f === cell.after.f) continue; + checkedFormulaCount += 1; + const result = lintFormula(cell.after.f, cell.r, cell.c); + errors.push(...result.errors); + warnings.push(...result.warnings); + } + return { + status: errors.length > 0 ? "invalid" : warnings.length > 0 ? "warning" : "valid", + checkedFormulaCount, + recalculated: false, + calculationEngine: "none", + note: checkedFormulaCount > 0 + ? "Static syntax and risk checks only; formulas were not calculated and cached results were not verified." + : "No new or changed formulas in this patch; no calculation was performed.", + errors, + warnings, + }; +} + +function validatePatchCells(sheet, cells) { + const seen = new Set(); + const errors = []; + const warnings = []; + let overwriteCount = 0; + let formulaCountAfter = 0; + let noOpCount = 0; + for (const cell of cells || []) { + const key = cellKey(cell.r, cell.c); + if (seen.has(key)) { + errors.push(`Duplicate cell ${formatA1(cell.r, cell.c)}`); + } + seen.add(key); + if (cell.before && !cellsEqual(cell.before, cell.after)) overwriteCount += 1; + if (cellHasFormulaEvidence(cell.after)) formulaCountAfter += 1; + if (cellsEqual(cell.before, cell.after)) noOpCount += 1; + } + if (overwriteCount > 0) warnings.push(`${overwriteCount} non-empty cell(s) will be overwritten.`); + if (formulaCountAfter > 0) { + warnings.push(`${formulaCountAfter} formula cell(s) cannot be recalculated by this engine.`); + } + const formulaLint = lintPatchFormulas(cells); + errors.push(...formulaLint.errors.map((issue) => `${issue.code} ${issue.message}`)); + warnings.push(...formulaLint.warnings.map((issue) => `${issue.code} ${issue.message}`)); + if (noOpCount > 0) warnings.push(`${noOpCount} cell(s) already match the proposal.`); + return { + status: errors.length > 0 ? "invalid" : warnings.length > 0 ? "warning" : "valid", + checkedAt: nowIso(), + cellCount: (cells || []).length, + overwriteCount, + formulaCount: formulaCountAfter, + noOpCount, + errors, + warnings, + formulaLint, + }; +} + +function rebaseProposal(workbook, excluded = new Set(), layoutAccepted = false) { + const proposal = workbook.proposal; + if (!proposal) return; + const sheet = getSheet(workbook, proposal.sheetId); + const remaining = (proposal.cells || []).filter( + (cell) => !excluded.has(`${proposal.sheetId}:${cell.r},${cell.c}`) + ); + if (layoutAccepted) proposal.layout = null; + if (proposal.layout && !stableEqual(sheet.layout, proposal.layout.before)) { + workbook.proposal = null; + return; + } + if (remaining.length === 0 && !proposal.layout) { + workbook.proposal = null; + return; + } + const conflicts = remaining.filter( + (cell) => !cellsEqual(sheet.cells[cellKey(cell.r, cell.c)] || null, cell.before) + ); + if (conflicts.length > 0) { + workbook.proposal = null; + return; + } + proposal.cells = remaining; + proposal.a1 = proposalRange(remaining); + proposal.baseRevision = workbook.revision; + proposal.validation = validatePatchCells(sheet, remaining); + proposal.validation.layoutChanged = Boolean(proposal.layout); + proposal.validation.layoutPartialAccept = proposal.layout + ? "Layout is applied only when the proposal is accepted without cellRefs." + : null; +} + +function clearProposalOverlap(workbook, sheetId, row, col) { + if (!workbook.proposal) return; + if (workbook.proposal.sheetId !== sheetId) return; + const remaining = (workbook.proposal.cells || []).filter( + (c) => !(c.r === row && c.c === col) + ); + if (remaining.length === 0) { + workbook.proposal = null; + } else if (remaining.length !== workbook.proposal.cells.length) { + workbook.proposal = { ...workbook.proposal, cells: remaining }; + } +} + +function clearProposalIfOverlapsRange(workbook, sheetId, range) { + if (!workbook.proposal) return; + if (workbook.proposal.sheetId !== sheetId) return; + const hits = (workbook.proposal.cells || []).some( + (c) => c.r >= range.r1 && c.r <= range.r2 && c.c >= range.c1 && c.c <= range.c2 + ); + if (hits) { + workbook.proposal = null; + } +} + +function openWorkbook(input = {}) { + const workspacePath = requireWorkspace(input); + if (!input.path || typeof input.path !== "string") { + throw new Error("path is required to open a workbook"); + } + const filePath = resolveConsumerPath(input, workspacePath, input.path, "openWorkbook"); + + // Reuse the existing store id when this file was opened before so the + // surface and agent keep pointing at one workbook per file. + const requestedId = input.workbookId == null ? null : validateWorkbookId(input.workbookId); + const foundId = findWorkbookIdByPath(workspacePath, filePath) + || listWorkbookMetas(workspacePath).find((meta) => + pathsReferToSameFile(meta.sourcePath || meta.path, filePath) + )?.workbookId + || null; + if (requestedId && foundId && requestedId !== foundId) { + throw new Error( + `[WORKBOOK_PATH_ALREADY_OPEN] ${filePath} already belongs to live workbook ${foundId}, not ${requestedId}. Resume the existing workbook or explicitly reload it.` + ); + } + const existingId = requestedId || foundId; + + if (existingId) { + let existing = null; + try { + existing = loadOrGet(workspacePath, existingId); + } catch (error) { + if (!/^Workbook store not found:/.test(String(error.message || ""))) throw error; + if (foundId || !requestedId) throw error; + } + if (existing) { + const associatedPath = existing.sourcePath || existing.path; + if ( + !associatedPath || + !pathsReferToSameFile(associatedPath, filePath) + ) { + throw new Error( + `[WORKBOOK_ID_PATH_MISMATCH] Workbook ${existingId} is already associated with ${associatedPath || "an internal workbook"}, not ${filePath}.` + ); + } + const sheet = getSheet(existing, existing.activeSheetId); + return { + summary: `Resumed live workbook "${existing.title}" at revision ${existing.revision}; external source was not re-read. Use reloadWorkbook with expectedRevision to reload explicitly.`, + resumed: true, + meta: metaOf(existing), + viewport: viewportSummary(existing, sheet), + }; + } + } + + const workbook = readWorkbookFile(filePath, { + title: input.title, + workbookId: requestedId || undefined, + }); + ensureDir(workbookDir(workspacePath, workbook.workbookId)); + const saved = persist(workspacePath, workbook); + const sheet = getSheet(saved, saved.activeSheetId); + return { + summary: `Opened workbook "${saved.title}" (${saved.sheets.length} sheet(s)) from ${filePath}`, + resumed: false, + meta: metaOf(saved), + viewport: viewportSummary(saved, sheet), + }; +} + +function createWorkbook(input = {}) { + const workspacePath = requireWorkspace(input); + const rows = requirePositiveSafeInteger(input.rows, 50, "rows"); + const cols = requirePositiveSafeInteger(input.cols, 26, "cols"); + assertSheetDimensions(rows, cols, "New worksheet"); + const workbook = createEmpty({ + title: input.title || "Untitled Workbook", + rows, + cols, + sheetName: input.sheetName || "Sheet1", + }); + ensureDir(workbookDir(workspacePath, workbook.workbookId)); + const saved = persist(workspacePath, workbook); + const sheet = getSheet(saved, saved.activeSheetId); + return { + summary: `Created empty workbook "${saved.title}" (${sheet.rows}x${sheet.cols})`, + meta: metaOf(saved), + viewport: viewportSummary(saved, sheet), + }; +} + +function getMeta(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + return { + summary: `Workbook "${workbook.title}" — ${workbook.sheets.length} sheet(s), dirty=${workbook.dirty}`, + meta: metaOf(workbook), + }; +} + +function listSheets(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + return { + summary: `${workbook.sheets.length} sheet(s) in "${workbook.title}"`, + activeSheetId: workbook.activeSheetId, + sheets: workbook.sheets.map((s) => ({ + id: s.id, + name: s.name, + rows: s.rows, + cols: s.cols, + cellCount: Object.keys(s.cells || {}).length, + layout: cloneJson(s.layout), + })), + }; +} + +function switchSheet(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + let sheet = null; + if (input.sheetId) { + sheet = getSheet(workbook, input.sheetId); + } else if (input.sheetName) { + sheet = findSheetByName(workbook, input.sheetName); + if (!sheet) throw new Error(`Sheet not found: ${input.sheetName}`); + } else { + throw new Error("sheetId or sheetName is required"); + } + workbook.activeSheetId = sheet.id; + workbook.focus = { + sheetId: sheet.id, + a1: workbook.focus && workbook.focus.sheetId === sheet.id ? workbook.focus.a1 : "A1", + kind: workbook.focus && workbook.focus.sheetId === sheet.id ? workbook.focus.kind : "cell", + }; + const saved = persist(workspacePath, workbook); + return { + summary: `Switched to sheet "${sheet.name}"`, + meta: metaOf(saved), + viewport: viewportSummary(saved, sheet), + }; +} + +function getFocus(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + const sheet = getSheet(workbook, workbook.focus.sheetId); + return { + summary: `Focus ${sheet.name}!${workbook.focus.a1} (${workbook.focus.kind})`, + focus: { + ...workbook.focus, + sheetName: sheet.name, + }, + mode: workbook.mode, + revision: workbook.revision, + }; +} + +function setFocus(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + if (!input.a1) { + throw new Error("a1 is required for setFocus"); + } + const sheet = resolveSheet(workbook, input.sheetId, input.a1); + const range = clampRange(parseA1(input.a1), sheet.rows, sheet.cols); + const a1 = formatA1(range.r1, range.c1, range.r2, range.c2); + let kind = input.kind; + if (!kind) { + kind = range.r1 === range.r2 && range.c1 === range.c2 ? "cell" : "range"; + } + if (input.mode != null) { + if (!["inspect", "edit", "author"].includes(input.mode)) { + throw new Error("mode must be one of: inspect, edit, author"); + } + workbook.mode = normalizeMode(input.mode); + } + workbook.focus = { sheetId: sheet.id, a1, kind }; + workbook.activeSheetId = sheet.id; + const saved = persist(workspacePath, workbook); + return { + summary: `Focus set to ${sheet.name}!${a1}`, + focus: { ...saved.focus, sheetName: sheet.name }, + meta: metaOf(saved), + }; +} + +function setMode(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + if (!input.mode || !["inspect", "edit", "author"].includes(input.mode)) { + throw new Error("mode must be one of: inspect, edit, author"); + } + workbook.mode = normalizeMode(input.mode); + const saved = persist(workspacePath, workbook); + return { + summary: `Workbook mode set to ${saved.mode}`, + mode: saved.mode, + meta: metaOf(saved), + }; +} + +function readRange(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + const maxCells = requirePositiveSafeInteger(input.maxCells, DEFAULT_MAX_CELLS, "maxCells"); + if (maxCells > MAX_READ_RANGE_CELLS) { + throw new Error(`[READ_CELL_LIMIT] maxCells cannot exceed ${MAX_READ_RANGE_CELLS}.`); + } + const { sheet, range, a1 } = resolveRange(workbook, input); + const count = cellCount(range.r1, range.c1, range.r2, range.c2); + if (count > maxCells) { + throw new Error( + `Range ${a1} has ${count} cells which exceeds maxCells=${maxCells}. Narrow the selection or raise maxCells.` + ); + } + + const values = []; + const formulas = []; + const cells = []; + for (let r = range.r1; r <= range.r2; r += 1) { + const rowVals = []; + for (let c = range.c1; c <= range.c2; c += 1) { + const cell = sheet.cells[cellKey(r, c)]; + if (!cell) { + rowVals.push(null); + } else { + rowVals.push(cell.v != null ? cell.v : null); + cells.push({ + row: r, + col: c, + a1: formatA1(r, c), + value: cell.v != null ? cell.v : null, + formula: typeof cell.f === "string" && cell.f ? cell.f : null, + style: cloneJson(cell.style) || null, + }); + if (cellHasFormulaEvidence(cell)) { + formulas.push({ + row: r, + col: c, + formula: typeof cell.f === "string" && cell.f ? cell.f : null, + formulaEvidence: true, + formulaType: cell.formulaType || cell.formulaGroupType || null, + formulaRef: cell.formulaRef || cell.formulaGroupRef || null, + value: cell.v != null ? cell.v : null, + }); + } + } + } + values.push(rowVals); + } + + return { + summary: `Read ${sheet.name}!${a1} (${count} cells, ${formulas.length} formulas)`, + workbookId, + revision: workbook.revision, + calculationStatus: cloneJson(workbook.calculationStatus), + sheetId: sheet.id, + sheetName: sheet.name, + a1, + rowCount: range.r2 - range.r1 + 1, + columnCount: range.c2 - range.c1 + 1, + values, + formulas, + cells, + layout: cloneJson(sheet.layout), + }; +} + +function summarizeRange(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + const maxCells = requirePositiveSafeInteger(input.maxCells, DEFAULT_MAX_CELLS, "maxCells"); + if (maxCells > MAX_READ_RANGE_CELLS) { + throw new Error(`[READ_CELL_LIMIT] maxCells cannot exceed ${MAX_READ_RANGE_CELLS}.`); + } + const { sheet, range, a1 } = resolveRange(workbook, input); + const count = cellCount(range.r1, range.c1, range.r2, range.c2); + if (count > maxCells) { + throw new Error( + `Range ${a1} has ${count} cells which exceeds maxCells=${maxCells}. Narrow the selection or raise maxCells.` + ); + } + + let numericCount = 0; + let emptyCount = 0; + let formulaCount = 0; + let textCount = 0; + const sampleRows = []; + const headerRow = []; + + for (let r = range.r1; r <= range.r2; r += 1) { + const sample = []; + for (let c = range.c1; c <= range.c2; c += 1) { + const cell = sheet.cells[cellKey(r, c)]; + if (!cell || (cell.v == null && !cellHasFormulaEvidence(cell))) { + emptyCount += 1; + sample.push(""); + if (r === range.r1) headerRow.push(""); + continue; + } + const hasFormula = cellHasFormulaEvidence(cell); + if (hasFormula) formulaCount += 1; + if (typeof cell.v === "number") { + numericCount += 1; + } else if (cell.v != null && cell.v !== "") { + textCount += 1; + } else if (!hasFormula) { + emptyCount += 1; + } + const display = cell.v != null + ? cell.v + : cell.f + ? `=${cell.f}` + : hasFormula + ? "[formula result unavailable]" + : ""; + sample.push(display); + if (r === range.r1) headerRow.push(String(display)); + } + if (sampleRows.length < 20) { + sampleRows.push(sample); + } + } + + const headerGuess = headerRow.some((h) => h !== "" && Number.isNaN(Number(h))); + const sampleTsv = sampleRows.map((row) => row.map((v) => String(v)).join("\t")).join("\n"); + + return { + summary: `Summary of ${sheet.name}!${a1}: numeric=${numericCount}, empty=${emptyCount}, formulas=${formulaCount}`, + workbookId, + revision: workbook.revision, + calculationStatus: cloneJson(workbook.calculationStatus), + sheetId: sheet.id, + sheetName: sheet.name, + a1, + stats: { + cellCount: count, + numericCount, + emptyCount, + formulaCount, + textCount, + headerGuess, + }, + sampleTsv, + sampleRowCount: sampleRows.length, + }; +} + +function buildPatchCells(sheet, input, maxCells = MAX_LOCAL_PATCH_CELLS) { + assertSheetDimensions(Number(sheet.rows), Number(sheet.cols), `Worksheet ${sheet.name || sheet.id}`); + const cells = []; + if (Array.isArray(input.cells) && input.cells.length > 0) { + if (input.cells.length > maxCells) { + throw new Error(`[PATCH_CELL_LIMIT] Patch exceeds the maximum of ${maxCells} cells.`); + } + for (const item of input.cells) { + if (!item || typeof item !== "object") continue; + const row = Number(item.row); + const col = Number(item.col); + assertCellCoordinates(row, col, `Patch cell row=${item.row}, col=${item.col}`); + const beforeCell = sheet.cells[cellKey(row, col)] || null; + const hasMutation = Object.prototype.hasOwnProperty.call(item, "formula") + || Object.prototype.hasOwnProperty.call(item, "value") + || Object.prototype.hasOwnProperty.call(item, "style") + || Object.prototype.hasOwnProperty.call(item, "styleRole"); + if (!hasMutation) { + continue; + } + const after = cellFromPatch(beforeCell, item); + cells.push({ + r: row, + c: col, + before: cloneCell(beforeCell), + after: cloneCell(after), + }); + } + return cells; + } + + if (Array.isArray(input.values)) { + const { range } = (() => { + if (input.a1) { + return { range: clampRange(parseA1(input.a1), sheet.rows, sheet.cols) }; + } + return { range: { r1: 0, c1: 0, r2: 0, c2: 0 } }; + })(); + for (let i = 0; i < input.values.length; i += 1) { + const rowVals = input.values[i]; + if (!Array.isArray(rowVals)) continue; + for (let j = 0; j < rowVals.length; j += 1) { + if (cells.length >= maxCells) { + throw new Error(`[PATCH_CELL_LIMIT] Patch exceeds the maximum of ${maxCells} cells.`); + } + const row = range.r1 + i; + const col = range.c1 + j; + assertCellCoordinates(row, col, `Patch matrix cell row=${row}, col=${col}`); + const value = rowVals[j]; + const beforeCell = sheet.cells[cellKey(row, col)] || null; + let after = null; + if (value !== null && value !== undefined && value !== "") { + after = typeof value === "string" && value.trim().startsWith("=") + ? cellFromPatch(beforeCell, { formula: value }) + : cellFromPatch(beforeCell, { value }); + } + cells.push({ + r: row, + c: col, + before: cloneCell(beforeCell), + after, + }); + } + } + return cells; + } + + throw new Error("proposePatch requires cells[] or values[][]"); +} + +function proposePatch(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "proposePatch"); + assertExpectedRevision(workbook, input, "proposePatch"); + if (workbook.proposal) { + throw new Error( + `[ACTIVE_PROPOSAL_EXISTS] Proposal ${workbook.proposal.id} is still active. Accept or reject it before creating another proposal.` + ); + } + const sheet = resolveSheet(workbook, input.sheetId, input.a1); + const hasCellPatch = (Array.isArray(input.cells) && input.cells.length > 0) || Array.isArray(input.values); + const patchCells = hasCellPatch ? buildPatchCells(sheet, input, MAX_PROPOSAL_CELLS) : []; + const rawLayout = layoutFromPatch(sheet, input.layout, patchCells); + const layout = rawLayout && !stableEqual(rawLayout.before, rawLayout.after) ? rawLayout : null; + if (patchCells.length === 0 && !layout) throw new Error("proposePatch produced an empty patch"); + + const validation = validatePatchCells(sheet, patchCells); + validation.layoutChanged = Boolean(layout); + validation.layoutPartialAccept = layout + ? "Layout is applied only when the proposal is accepted without cellRefs." + : null; + if (validation.errors.length > 0) { + throw new Error(`[INVALID_PROPOSAL] ${validation.errors.join(" ")}`); + } + if (patchCells.length > 0 && validation.noOpCount === patchCells.length && !layout) { + throw new Error("proposePatch produced no content changes"); + } + const a1 = proposalRange(patchCells); + const proposal = { + id: newId("prop"), + sheetId: sheet.id, + a1, + cells: patchCells, + baseRevision: workbook.revision, + intent: typeof input.intent === "string" && input.intent.trim() + ? input.intent.trim() + : null, + validation, + layout, + createdAt: nowIso(), + }; + workbook.proposal = proposal; + // Do NOT mutate committed cells + const saved = persist(workspacePath, workbook); + const cellResult = proposalCellResult(proposal, input.resultCellLimit); + return { + summary: `Proposed patch ${proposal.id} on ${sheet.name}${a1 ? `!${a1}` : ""} (${patchCells.length} cells${layout ? ", layout" : ""}). Not applied until acceptProposal.`, + proposal: proposalSummary(proposal), + ...cellResult, + meta: metaOf(saved), + }; +} + +function getProposal(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + if (!workbook.proposal) { + return { + summary: "No active proposal", + proposal: null, + totalCellCount: 0, + cellDetailsTruncated: false, + }; + } + const sheet = getSheet(workbook, workbook.proposal.sheetId); + const cellResult = proposalCellResult(workbook.proposal, input.resultCellLimit); + return { + summary: `Active proposal ${workbook.proposal.id} on ${sheet.name}${workbook.proposal.a1 ? `!${workbook.proposal.a1}` : ""}`, + proposal: { + ...proposalSummary(workbook.proposal), + sheetName: sheet.name, + ...cellResult, + }, + totalCellCount: cellResult.totalCellCount, + cellDetailsTruncated: cellResult.cellDetailsTruncated, + }; +} + +function proposalSelection(workbook, proposal, cellRefs) { + if (cellRefs == null) { + const all = proposal.cells || []; + if (all.length > MAX_ACCEPT_SELECTION_CELLS) { + throw new Error( + `[ACCEPT_CELL_LIMIT] Proposal contains ${all.length} cells; maximum accepted in one operation is ${MAX_ACCEPT_SELECTION_CELLS}.` + ); + } + return all; + } + if (!Array.isArray(cellRefs) || cellRefs.length === 0) { + throw new Error("acceptProposal cellRefs must be a non-empty array when provided"); + } + if (cellRefs.length > MAX_ACCEPT_SELECTION_CELLS) { + throw new Error( + `[ACCEPT_CELL_LIMIT] cellRefs contains more than ${MAX_ACCEPT_SELECTION_CELLS} entries.` + ); + } + const wanted = new Set(); + for (const ref of cellRefs) { + if (typeof ref === "string") { + const parsed = parseA1(ref); + if (parsed.sheet) { + const sheet = getSheet(workbook, proposal.sheetId); + if (parsed.sheet !== proposal.sheetId && parsed.sheet.toLowerCase() !== sheet.name.toLowerCase()) { + throw new Error( + `acceptProposal cellRef ${ref} targets ${parsed.sheet}, but proposal ${proposal.id} targets ${sheet.name}` + ); + } + } + const expandedCount = cellCount(parsed.r1, parsed.c1, parsed.r2, parsed.c2); + if (expandedCount > MAX_ACCEPT_SELECTION_CELLS) { + throw new Error( + `[ACCEPT_CELL_LIMIT] ${ref} expands to ${expandedCount} cells; maximum is ${MAX_ACCEPT_SELECTION_CELLS}.` + ); + } + for (let r = parsed.r1; r <= parsed.r2; r += 1) { + for (let c = parsed.c1; c <= parsed.c2; c += 1) { + wanted.add(`${r},${c}`); + if (wanted.size > MAX_ACCEPT_SELECTION_CELLS) { + throw new Error( + `[ACCEPT_CELL_LIMIT] Selected cells exceed the maximum of ${MAX_ACCEPT_SELECTION_CELLS}.` + ); + } + } + } + continue; + } + if (ref && typeof ref === "object") { + const row = Number(ref.row); + const col = Number(ref.col); + assertCellCoordinates(row, col, `acceptProposal cellRef row=${ref.row}, col=${ref.col}`); + wanted.add(`${row},${col}`); + continue; + } + throw new Error("acceptProposal cellRefs entries must be A1 strings or {row,col}"); + } + const selected = (proposal.cells || []).filter((cell) => wanted.has(`${cell.r},${cell.c}`)); + if (selected.length !== wanted.size) { + const proposalKeys = new Set((proposal.cells || []).map((cell) => `${cell.r},${cell.c}`)); + const missing = [...wanted].filter((key) => !proposalKeys.has(key)); + throw new Error(`[INVALID_CELL_REFS] Cells are not in proposal ${proposal.id}: ${missing.join(", ")}`); + } + return selected; +} + +function acceptProposal(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "acceptProposal"); + assertExpectedRevision(workbook, input, "acceptProposal"); + if (!workbook.proposal) { + throw new Error("No active proposal to accept"); + } + if (!input.proposalId || typeof input.proposalId !== "string") { + throw new Error("[PROPOSAL_ID_REQUIRED] acceptProposal requires the exact reviewed proposalId."); + } + if (input.proposalId !== workbook.proposal.id) { + throw new Error( + `Proposal id mismatch: active=${workbook.proposal.id}, requested=${input.proposalId}` + ); + } + const proposal = workbook.proposal; + if (proposal.baseRevision !== workbook.revision) { + throw new Error( + `[PROPOSAL_REVISION_CONFLICT] Proposal ${proposal.id} was created at revision ${proposal.baseRevision}, but the workbook is at revision ${workbook.revision}. Refresh the proposal before accepting it.` + ); + } + const sheet = getSheet(workbook, proposal.sheetId); + const selected = proposalSelection(workbook, proposal, input.cellRefs); + const acceptLayout = Boolean(proposal.layout && input.cellRefs == null); + const conflicts = []; + for (const cell of selected) { + const current = sheet.cells[cellKey(cell.r, cell.c)] || null; + if (!cellsEqual(current, cell.before)) { + conflicts.push(formatA1(cell.r, cell.c)); + } + } + if (conflicts.length > 0) { + throw new Error( + `[PROPOSAL_BEFORE_CONFLICT] ${conflicts.length} cell(s) changed after the proposal was created: ${conflicts.join(", ")}. Refresh before accepting.` + ); + } + if (acceptLayout && !stableEqual(sheet.layout, proposal.layout.before)) { + throw new Error( + `[PROPOSAL_LAYOUT_CONFLICT] Sheet layout changed after proposal ${proposal.id} was created. Refresh before accepting.` + ); + } + assertCellMutationCapacity(workbook, selected); + const beforeSize = { rows: sheet.rows, cols: sheet.cols }; + for (const cell of selected) { + setSheetCell(sheet, cell.r, cell.c, cell.after); + } + if (acceptLayout) sheet.layout = cloneJson(proposal.layout.after); + workbook.activeSheetId = sheet.id; + const selectedKeys = new Set( + selected.map((cell) => `${proposal.sheetId}:${cell.r},${cell.c}`) + ); + const changes = []; + if (selected.length > 0) { + changes.push({ + kind: "cells", + sheetId: sheet.id, + beforeSize, + afterSize: { rows: sheet.rows, cols: sheet.cols }, + cells: cloneJson(selected), + }); + } + if (acceptLayout) { + changes.push({ kind: "layout", sheetId: sheet.id, ...cloneJson(proposal.layout) }); + } + const hasStyleChanges = cellChangesStyle(selected); + const operation = recordOperation(workbook, { + type: "accept-proposal", + actor: input.actor || "user", + intent: proposal.intent || input.intent || null, + summary: `Accepted ${selected.length} cell(s)${acceptLayout ? " and layout" : ""} from proposal ${proposal.id}`, + sheetId: sheet.id, + a1: proposalRange(selected), + cellCount: selected.length, + roundTripSafe: !hasStyleChanges && !acceptLayout, + contentChanged: cellChangesContent(selected), + structureChanged: false, + fidelityWarning: hasStyleChanges && acceptLayout + ? "New or changed cell styles and worksheet layout cannot be added to an imported Excel package by the source-preserving patcher. Save As with fidelity-loss acknowledgement to rebuild a styled .xlsx copy." + : hasStyleChanges + ? "New or changed cell styles cannot be added to an imported Excel package by the source-preserving patcher. Save As with fidelity-loss acknowledgement to rebuild a styled .xlsx copy." + : acceptLayout + ? "New or changed worksheet layout cannot be patched into an imported Excel package losslessly. Save As with fidelity-loss acknowledgement to rebuild an .xlsx copy." + : null, + change: changes.length === 1 ? changes[0] : { kind: "compound", changes }, + }); + rebaseProposal(workbook, selectedKeys, acceptLayout); + const saved = persist(workspacePath, workbook); + return { + summary: `Accepted proposal ${proposal.id} on ${sheet.name}${proposalRange(selected) ? `!${proposalRange(selected)}` : ""} (${selected.length} cells${acceptLayout ? ", layout" : ""})`, + applied: { + proposalId: proposal.id, + operationId: operation.id, + sheetId: sheet.id, + sheetName: sheet.name, + a1: proposalRange(selected), + cellCount: selected.length, + layoutApplied: acceptLayout, + revision: saved.revision, + }, + remainingProposal: proposalSummary(saved.proposal), + meta: metaOf(saved), + }; +} + +function rejectProposal(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertExpectedRevision(workbook, input, "rejectProposal"); + if (!workbook.proposal) { + return { + summary: "No active proposal to reject", + proposal: null, + meta: metaOf(workbook), + }; + } + if (!input.proposalId || typeof input.proposalId !== "string") { + throw new Error("[PROPOSAL_ID_REQUIRED] rejectProposal requires the exact reviewed proposalId."); + } + if (input.proposalId !== workbook.proposal.id) { + throw new Error( + `Proposal id mismatch: active=${workbook.proposal.id}, requested=${input.proposalId}` + ); + } + const rejectedId = workbook.proposal.id; + const a1 = workbook.proposal.a1; + workbook.proposal = null; + const saved = persist(workspacePath, workbook); + return { + summary: `Rejected proposal ${rejectedId} (${a1})`, + rejectedProposalId: rejectedId, + meta: metaOf(saved), + }; +} + +function applyLocalEdit(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "applyLocalEdit"); + assertExpectedRevision(workbook, input, "applyLocalEdit"); + const sheet = getSheet(workbook, input.sheetId || workbook.activeSheetId); + const row = Number(input.row); + const col = Number(input.col); + assertCellCoordinates(row, col, "Local edit cell"); + const beforeSize = { rows: sheet.rows, cols: sheet.cols }; + const before = cloneCell(sheet.cells[cellKey(row, col)] || null); + const previewAfter = cellFromPatch(before, input); + const validation = validatePatchCells(sheet, [{ r: row, c: col, before, after: previewAfter }]); + if (validation.errors.length > 0) { + throw new Error(`[INVALID_LOCAL_EDIT] ${validation.errors.join(" ")}`); + } + assertCellMutationCapacity(workbook, [{ + before, + after: previewAfter, + }]); + const after = setCell(sheet, row, col, { + value: input.value, + formula: input.formula, + style: input.style, + styleRole: input.styleRole, + }); + workbook.focus = { + sheetId: sheet.id, + a1: formatA1(row, col), + kind: "cell", + }; + let operation = null; + if (!cellsEqual(before, after)) { + operation = recordOperation(workbook, { + type: "local-edit", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Edited ${sheet.name}!${formatA1(row, col)}`, + sheetId: sheet.id, + a1: formatA1(row, col), + cellCount: 1, + roundTripSafe: !cellChangesStyle([{ before, after }]), + contentChanged: !cellsContentEqual(before, after), + structureChanged: false, + fidelityWarning: "New or changed cell styles cannot be patched into an imported Excel package losslessly. Save As with fidelity-loss acknowledgement to rebuild a styled .xlsx copy.", + change: { + kind: "cells", + sheetId: sheet.id, + beforeSize, + afterSize: { rows: sheet.rows, cols: sheet.cols }, + cells: [{ r: row, c: col, before, after: cloneCell(after) }], + }, + }); + } + rebaseProposal(workbook, new Set([`${sheet.id}:${row},${col}`])); + const saved = persist(workspacePath, workbook); + return { + summary: `Local edit at ${sheet.name}!${formatA1(row, col)}`, + edit: { + sheetId: sheet.id, + sheetName: sheet.name, + row, + col, + a1: formatA1(row, col), + before: cellDisplay(before), + after: cellDisplay(after), + operationId: operation ? operation.id : null, + }, + validation, + meta: metaOf(saved), + }; +} + +function applyLocalPatch(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "applyLocalPatch"); + assertExpectedRevision(workbook, input, "applyLocalPatch"); + const sheet = resolveSheet(workbook, input.sheetId, input.a1); + const patchCells = buildPatchCells(sheet, input, MAX_LOCAL_PATCH_CELLS); + const validation = validatePatchCells(sheet, patchCells); + if (validation.errors.length > 0) { + throw new Error(`[INVALID_LOCAL_PATCH] ${validation.errors.join(" ")}`); + } + const changed = patchCells.filter((cell) => !cellsEqual(cell.before, cell.after)); + if (changed.length === 0) { + return { + summary: "Local patch made no content changes", + applied: { sheetId: sheet.id, sheetName: sheet.name, a1: null, cellCount: 0 }, + meta: metaOf(workbook), + }; + } + assertCellMutationCapacity(workbook, changed); + const beforeSize = { rows: sheet.rows, cols: sheet.cols }; + for (const cell of changed) { + setSheetCell(sheet, cell.r, cell.c, cell.after); + } + const a1 = proposalRange(changed); + workbook.activeSheetId = sheet.id; + workbook.focus = { + sheetId: sheet.id, + a1, + kind: changed.length === 1 ? "cell" : "range", + }; + const operation = recordOperation(workbook, { + type: "local-patch", + actor: input.actor || "user", + intent: input.intent || "Paste cells", + summary: `Applied local patch to ${sheet.name}!${a1}`, + sheetId: sheet.id, + a1, + cellCount: changed.length, + roundTripSafe: !cellChangesStyle(changed), + contentChanged: cellChangesContent(changed), + structureChanged: false, + fidelityWarning: "New or changed cell styles cannot be patched into an imported Excel package losslessly. Save As with fidelity-loss acknowledgement to rebuild a styled .xlsx copy.", + change: { + kind: "cells", + sheetId: sheet.id, + beforeSize, + afterSize: { rows: sheet.rows, cols: sheet.cols }, + cells: cloneJson(changed), + }, + }); + rebaseProposal( + workbook, + new Set(changed.map((cell) => `${sheet.id}:${cell.r},${cell.c}`)) + ); + const saved = persist(workspacePath, workbook); + return { + summary: `Applied ${changed.length} local cell edit(s) to ${sheet.name}!${a1}`, + applied: { + operationId: operation.id, + sheetId: sheet.id, + sheetName: sheet.name, + a1, + cellCount: changed.length, + revision: saved.revision, + }, + validation, + meta: metaOf(saved), + }; +} + +function applyLayout(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "applyLayout"); + assertExpectedRevision(workbook, input, "applyLayout"); + const sheet = getSheet(workbook, input.sheetId || workbook.activeSheetId); + const change = layoutFromPatch(sheet, input.layout); + if (!change || stableEqual(change.before, change.after)) { + return { summary: "Layout already matches", layout: cloneJson(sheet.layout), meta: metaOf(workbook) }; + } + sheet.layout = cloneJson(change.after); + const operation = recordOperation(workbook, { + type: "apply-layout", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Updated layout on ${sheet.name}`, + sheetId: sheet.id, + cellCount: 0, + roundTripSafe: false, + contentChanged: false, + structureChanged: false, + fidelityWarning: "New or changed worksheet layout cannot be patched into an imported Excel package losslessly. Save As with fidelity-loss acknowledgement to rebuild an .xlsx copy.", + change: { kind: "layout", sheetId: sheet.id, ...cloneJson(change) }, + }); + rebaseProposal(workbook); + const saved = persist(workspacePath, workbook); + return { + summary: `Updated layout on ${sheet.name}`, + operationId: operation.id, + sheetId: sheet.id, + layout: cloneJson(sheet.layout), + meta: metaOf(saved), + }; +} + +function clearRange(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "clearRange"); + assertExpectedRevision(workbook, input, "clearRange"); + if (!input.a1) { + throw new Error("a1 is required for clearRange"); + } + const { sheet, range, a1 } = resolveRange(workbook, input); + let cleared = 0; + const changes = []; + const beforeSize = { rows: sheet.rows, cols: sheet.cols }; + for (let r = range.r1; r <= range.r2; r += 1) { + for (let c = range.c1; c <= range.c2; c += 1) { + const key = cellKey(r, c); + if (sheet.cells[key]) { + changes.push({ r, c, before: cloneCell(sheet.cells[key]), after: null }); + delete sheet.cells[key]; + cleared += 1; + } + } + } + let operation = null; + if (changes.length > 0) { + operation = recordOperation(workbook, { + type: "clear-range", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Cleared ${sheet.name}!${a1}`, + sheetId: sheet.id, + a1, + cellCount: changes.length, + change: { + kind: "cells", + sheetId: sheet.id, + beforeSize, + afterSize: { rows: sheet.rows, cols: sheet.cols }, + cells: changes, + }, + }); + } + const excluded = new Set(); + if (workbook.proposal && workbook.proposal.sheetId === sheet.id) { + for (const cell of workbook.proposal.cells || []) { + if (cell.r >= range.r1 && cell.r <= range.r2 && cell.c >= range.c1 && cell.c <= range.c2) { + excluded.add(`${sheet.id}:${cell.r},${cell.c}`); + } + } + } + rebaseProposal(workbook, excluded); + const saved = persist(workspacePath, workbook); + return { + summary: `Cleared ${cleared} cell(s) in ${sheet.name}!${a1}`, + sheetId: sheet.id, + a1, + clearedCount: cleared, + operationId: operation ? operation.id : null, + meta: metaOf(saved), + }; +} + +function listWorkbooks(input = {}) { + const workspacePath = requireWorkspace(input); + const workbooks = listWorkbookMetas(workspacePath); + return { + summary: `${workbooks.length} workbook store(s) in workspace`, + workbooks, + }; +} + +function saveWorkbook(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertExpectedRevision(workbook, input, "saveWorkbook"); + const targetPath = input.path + ? resolveConsumerPath(input, workspacePath, input.path, "saveWorkbook") + : workbook.path + ? assertConsumerPathAllowed( + input, + workspacePath, + path.resolve(workbook.path), + "saveWorkbook" + ) + : null; + + if (targetPath && path.extname(targetPath).toLowerCase() === ".csv") { + throw new Error( + "[CSV_EXPORT_ACTION_REQUIRED] saveWorkbook cannot export CSV. Use exportCsv so formula freshness checks and formula-injection sanitization are enforced." + ); + } + + const sourcePath = workbook.sourcePath || null; + const exportCopy = input.exportCopy === true; + const excelSource = ["xlsx", "xlsm"].includes( + String(workbook.sourceFormat || "").toLowerCase() + ); + const overwritesSource = Boolean( + targetPath && + sourcePath && + pathsReferToSameFile(targetPath, sourcePath) + ); + if (overwritesSource && input.allowSourceOverwrite !== true) { + throw new Error( + `[SOURCE_OVERWRITE_BLOCKED] ${sourcePath} is the imported source. Use Save As with a different path, or explicitly set allowSourceOverwrite=true after reviewing fidelity.` + ); + } + if (overwritesSource && workbook.fidelity?.canRoundTrip === false) { + throw new Error( + "[LOSSY_SOURCE_OVERWRITE_BLOCKED] The imported source can never be overwritten with a lossy rebuild. Choose a new .xlsx path and acknowledge fidelity loss there." + ); + } + if ( + targetPath && + excelSource && + workbook.fidelity && + workbook.fidelity.canRoundTrip === false && + input.acknowledgeFidelityLoss !== true + ) { + throw new Error( + `[FIDELITY_ACK_REQUIRED] This workbook cannot be exported losslessly: ${workbook.fidelity.warning || "unsupported workbook features may be lost"} Set acknowledgeFidelityLoss=true only after choosing an appropriate Save As target.` + ); + } + + const savedStore = persist(workspacePath, workbook); + let exportResult = null; + if (targetPath) { + const atomicWriteOptions = prepareAtomicTargetWrite(input, targetPath, sourcePath); + exportResult = writeWorkbookFile(savedStore, targetPath, { + allowLossyRebuild: + input.acknowledgeFidelityLoss === true && !overwritesSource, + atomicWriteOptions, + }); + if (overwritesSource && exportResult.outputFingerprint) { + savedStore.sourceFingerprint = cloneJson(exportResult.outputFingerprint); + } + if (!exportCopy) savedStore.path = targetPath; + savedStore.lastExportPath = targetPath; + savedStore.lastExportedRevision = savedStore.revision; + savedStore.lastExportFingerprint = readStableFile(targetPath).fingerprint; + savedStore.dirty = false; + persist(workspacePath, savedStore); + } else { + savedStore.dirty = false; + persist(workspacePath, savedStore); + } + + return { + summary: targetPath + ? exportCopy + ? `Exported workbook copy to ${targetPath}; source association remains ${sourcePath || "internal store"}` + : `Saved workbook store and exported to ${targetPath}` + : `Saved workbook store for ${workbookId} (no external path)`, + meta: metaOf(sessions.get(workbookId).workbook), + storePath: path.join(workspacePath, ".sparo_os", "excel-live", workbookId, "workbook.json"), + export: exportResult, + exportCopy, + workingPath: sessions.get(workbookId).workbook.path || null, + sourceProtected: Boolean(sourcePath), + }; +} + +function exportCsv(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertExpectedRevision(workbook, input, "exportCsv"); + const sheet = getSheet(workbook, input.sheetId || workbook.activeSheetId); + const matrix = sheetToMatrix(sheet); + let exportedFormulaCount = 0; + let sanitizedCellCount = 0; + const uncachedFormulaRefs = []; + for (let r = 0; r < matrix.length; r += 1) { + for (let c = 0; c < (matrix[r] || []).length; c += 1) { + const cell = sheet.cells[cellKey(r, c)]; + if (!cell) continue; + if (cellHasFormulaEvidence(cell)) { + exportedFormulaCount += 1; + if (!Object.prototype.hasOwnProperty.call(cell, "v") || cell.v == null) { + uncachedFormulaRefs.push(formatA1(r, c)); + continue; + } + const cachedValue = matrix[r][c]; + if (typeof cachedValue === "string" && /^[=+\-@\t\r\n]/.test(cachedValue)) { + matrix[r][c] = `'${cachedValue}`; + sanitizedCellCount += 1; + } + continue; + } + const isText = cell.t === "s" || cell.t === "str" || typeof cell.v === "string"; + const value = matrix[r][c]; + if (isText && typeof value === "string" && /^[=+\-@\t\r\n]/.test(value)) { + matrix[r][c] = `'${value}`; + sanitizedCellCount += 1; + } + } + } + if (uncachedFormulaRefs.length > 0) { + const sample = uncachedFormulaRefs.slice(0, 20).join(", "); + throw new Error( + `[UNCACHED_FORMULA_CSV_BLOCKED] Sheet ${sheet.name} has ${uncachedFormulaRefs.length} formula cell(s) without cached results (${sample}). CSV export will not emit executable formula text as if it were a calculated value. Recalculate in Excel first.` + ); + } + if (exportedFormulaCount > 0 && input.acknowledgeStaleFormulaValues !== true) { + throw new Error( + `[STALE_FORMULA_VALUES_ACK_REQUIRED] Sheet ${sheet.name} contains ${exportedFormulaCount} formula cell(s). The live engine does not recalculate formulas, so cached CSV values may be stale. Review calculationStatus=${workbook.calculationStatus?.status || "unknown"} and set acknowledgeStaleFormulaValues=true to export intentionally.` + ); + } + const csv = serializeCsv(matrix); + let outPath = input.path || null; + if (outPath) { + outPath = resolveConsumerPath(input, workspacePath, outPath, "exportCsv"); + const actualOutPath = path.extname(outPath).toLowerCase() === ".csv" + ? outPath + : `${outPath}.csv`; + if ( + workbook.sourcePath && + pathsReferToSameFile(actualOutPath, workbook.sourcePath) + ) { + throw new Error( + "[SOURCE_OVERWRITE_BLOCKED] exportCsv cannot replace the imported source. Choose a new path." + ); + } + const atomicWriteOptions = prepareAtomicTargetWrite(input, actualOutPath, workbook.sourcePath); + writeFileAtomic(actualOutPath, csv, "utf8", atomicWriteOptions); + outPath = actualOutPath; + } + const warnings = []; + if (exportedFormulaCount > 0) { + warnings.push( + `${exportedFormulaCount} formula cell(s) were exported using cached values that may be stale.` + ); + } + if (sanitizedCellCount > 0) { + warnings.push( + `${sanitizedCellCount} text cell(s) with formula-injection prefixes were escaped with a leading apostrophe.` + ); + } + return { + summary: outPath + ? `Exported sheet "${sheet.name}" to ${outPath}` + : `Serialized sheet "${sheet.name}" to CSV (${matrix.length} rows)`, + sheetId: sheet.id, + sheetName: sheet.name, + path: outPath, + csv: outPath ? undefined : csv, + rowCount: matrix.length, + columnCount: matrix[0] ? matrix[0].length : 0, + formulaCount: exportedFormulaCount, + sanitizedCellCount, + calculationStatus: cloneJson(workbook.calculationStatus), + warning: warnings.length > 0 ? warnings.join(" ") : null, + }; +} + +function shiftCells(sheet, predicate, mapper) { + const next = {}; + for (const [key, cell] of Object.entries(sheet.cells || {})) { + const [rs, cs] = key.split(","); + const r = Number(rs); + const c = Number(cs); + if (!predicate(r, c)) { + next[key] = cell; + continue; + } + const mapped = mapper(r, c, cell); + if (!mapped) continue; + assertCellCoordinates(mapped.r, mapped.c, "Shifted worksheet cell"); + next[cellKey(mapped.r, mapped.c)] = mapped.cell; + } + sheet.cells = next; +} + +function assertStructureEditSafe(workbook, action) { + const count = formulaCount(workbook); + if (count > 0) { + throw new Error( + `[FORMULA_REFERENCE_RISK] ${action} is blocked because the workbook contains ${count} formula cell(s), and this engine cannot safely rewrite formula references.` + ); + } +} + +function assertLayoutStructureEditSafe(sheet, axis, action) { + const layout = normalizeSheetLayout(sheet.layout); + const hasAxisBands = axis === "rows" ? layout.rows.length > 0 : layout.columns.length > 0; + const hasAxisFreeze = axis === "rows" + ? layout.freezePanes.rows > 0 + : layout.freezePanes.columns > 0; + if (hasAxisBands || hasAxisFreeze || layout.autoFilter) { + throw new Error( + `[LAYOUT_REFERENCE_RISK] ${action} is blocked because ${sheet.name} has ${axis} layout, freeze panes, or an auto filter that this engine cannot safely rewrite. Apply structural edits before layout, or clear the affected layout first.` + ); + } +} + +function insertRows(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "insertRows"); + assertExpectedRevision(workbook, input, "insertRows"); + assertStructureEditSafe(workbook, "insertRows"); + const sheet = getSheet(workbook, input.sheetId || workbook.activeSheetId); + assertLayoutStructureEditSafe(sheet, "rows", "insertRows"); + const at = Number(input.at == null ? input.row : input.at); + const count = requirePositiveSafeInteger(input.count, 1, "count"); + if (!Number.isSafeInteger(at) || at < 0 || at > sheet.rows) { + throw new Error("at/row must be a safe integer within the worksheet row boundary"); + } + if (sheet.rows + count > EXCEL_MAX_ROWS) { + throw new Error(`[EXCEL_ROW_LIMIT] Inserting ${count} row(s) would exceed ${EXCEL_MAX_ROWS}.`); + } + const before = cloneJson(sheet); + shiftCells( + sheet, + (r) => r >= at, + (r, c, cell) => ({ r: r + count, c, cell }) + ); + sheet.rows += count; + workbook.proposal = null; + const operation = recordOperation(workbook, { + type: "insert-rows", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Inserted ${count} row(s) at ${at} on ${sheet.name}`, + sheetId: sheet.id, + cellCount: 0, + roundTripSafe: false, + change: { kind: "sheet", sheetId: sheet.id, before, after: cloneJson(sheet) }, + }); + const saved = persist(workspacePath, workbook); + return { + summary: `Inserted ${count} row(s) at ${at} on "${sheet.name}"`, + sheetId: sheet.id, + at, + count, + operationId: operation.id, + meta: metaOf(saved), + }; +} + +function insertColumns(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "insertColumns"); + assertExpectedRevision(workbook, input, "insertColumns"); + assertStructureEditSafe(workbook, "insertColumns"); + const sheet = getSheet(workbook, input.sheetId || workbook.activeSheetId); + assertLayoutStructureEditSafe(sheet, "columns", "insertColumns"); + const at = Number(input.at == null ? input.col : input.at); + const count = requirePositiveSafeInteger(input.count, 1, "count"); + if (!Number.isSafeInteger(at) || at < 0 || at > sheet.cols) { + throw new Error("at/col must be a safe integer within the worksheet column boundary"); + } + if (sheet.cols + count > EXCEL_MAX_COLUMNS) { + throw new Error(`[EXCEL_COLUMN_LIMIT] Inserting ${count} column(s) would exceed ${EXCEL_MAX_COLUMNS}.`); + } + const before = cloneJson(sheet); + shiftCells( + sheet, + (_r, c) => c >= at, + (r, c, cell) => ({ r, c: c + count, cell }) + ); + sheet.cols += count; + workbook.proposal = null; + const operation = recordOperation(workbook, { + type: "insert-columns", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Inserted ${count} column(s) at ${at} on ${sheet.name}`, + sheetId: sheet.id, + cellCount: 0, + roundTripSafe: false, + change: { kind: "sheet", sheetId: sheet.id, before, after: cloneJson(sheet) }, + }); + const saved = persist(workspacePath, workbook); + return { + summary: `Inserted ${count} column(s) at ${at} on "${sheet.name}"`, + sheetId: sheet.id, + at, + count, + operationId: operation.id, + meta: metaOf(saved), + }; +} + +function deleteRows(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "deleteRows"); + assertExpectedRevision(workbook, input, "deleteRows"); + assertStructureEditSafe(workbook, "deleteRows"); + const sheet = getSheet(workbook, input.sheetId || workbook.activeSheetId); + assertLayoutStructureEditSafe(sheet, "rows", "deleteRows"); + const at = Number(input.at == null ? input.row : input.at); + const count = requirePositiveSafeInteger(input.count, 1, "count"); + if (!Number.isSafeInteger(at) || at < 0 || at >= sheet.rows) { + throw new Error("at/row must be a safe integer within the worksheet row boundary"); + } + if (count > sheet.rows - at) { + throw new Error("deleteRows count exceeds the remaining worksheet rows"); + } + const before = cloneJson(sheet); + shiftCells( + sheet, + (r) => r >= at, + (r, c, cell) => { + if (r < at + count) return null; + return { r: r - count, c, cell }; + } + ); + sheet.rows = Math.max(1, sheet.rows - count); + workbook.proposal = null; + const operation = recordOperation(workbook, { + type: "delete-rows", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Deleted ${count} row(s) at ${at} on ${sheet.name}`, + sheetId: sheet.id, + cellCount: 0, + roundTripSafe: false, + change: { kind: "sheet", sheetId: sheet.id, before, after: cloneJson(sheet) }, + }); + const saved = persist(workspacePath, workbook); + return { + summary: `Deleted ${count} row(s) at ${at} on "${sheet.name}"`, + sheetId: sheet.id, + at, + count, + operationId: operation.id, + meta: metaOf(saved), + }; +} + +function deleteColumns(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "deleteColumns"); + assertExpectedRevision(workbook, input, "deleteColumns"); + assertStructureEditSafe(workbook, "deleteColumns"); + const sheet = getSheet(workbook, input.sheetId || workbook.activeSheetId); + assertLayoutStructureEditSafe(sheet, "columns", "deleteColumns"); + const at = Number(input.at == null ? input.col : input.at); + const count = requirePositiveSafeInteger(input.count, 1, "count"); + if (!Number.isSafeInteger(at) || at < 0 || at >= sheet.cols) { + throw new Error("at/col must be a safe integer within the worksheet column boundary"); + } + if (count > sheet.cols - at) { + throw new Error("deleteColumns count exceeds the remaining worksheet columns"); + } + const before = cloneJson(sheet); + shiftCells( + sheet, + (_r, c) => c >= at, + (r, c, cell) => { + if (c < at + count) return null; + return { r, c: c - count, cell }; + } + ); + sheet.cols = Math.max(1, sheet.cols - count); + workbook.proposal = null; + const operation = recordOperation(workbook, { + type: "delete-columns", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Deleted ${count} column(s) at ${at} on ${sheet.name}`, + sheetId: sheet.id, + cellCount: 0, + roundTripSafe: false, + change: { kind: "sheet", sheetId: sheet.id, before, after: cloneJson(sheet) }, + }); + const saved = persist(workspacePath, workbook); + return { + summary: `Deleted ${count} column(s) at ${at} on "${sheet.name}"`, + sheetId: sheet.id, + at, + count, + operationId: operation.id, + meta: metaOf(saved), + }; +} + +function renameSheet(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "renameSheet"); + assertExpectedRevision(workbook, input, "renameSheet"); + assertStructureEditSafe(workbook, "renameSheet"); + const sheet = getSheet(workbook, input.sheetId || workbook.activeSheetId); + const name = input.name || input.sheetName; + if (!name || typeof name !== "string" || !name.trim()) { + throw new Error("name is required"); + } + const trimmed = validateExcelSheetName(name); + const clash = workbook.sheets.find( + (s) => s.id !== sheet.id && s.name.toLowerCase() === trimmed.toLowerCase() + ); + if (clash) { + throw new Error(`Sheet name already exists: ${trimmed}`); + } + const previous = sheet.name; + sheet.name = trimmed; + const operation = recordOperation(workbook, { + type: "rename-sheet", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Renamed sheet ${previous} to ${trimmed}`, + sheetId: sheet.id, + roundTripSafe: false, + change: { kind: "sheet-name", sheetId: sheet.id, before: previous, after: trimmed }, + }); + const saved = persist(workspacePath, workbook); + return { + summary: `Renamed sheet "${previous}" to "${trimmed}"`, + sheetId: sheet.id, + previousName: previous, + name: trimmed, + operationId: operation.id, + meta: metaOf(saved), + }; +} + +function addSheet(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const workbook = loadOrGet(workspacePath, workbookId); + assertWritable(workbook, "addSheet"); + assertExpectedRevision(workbook, input, "addSheet"); + const before = captureWorkbookContent(workbook); + const requestedName = input.name ?? input.sheetName ?? ""; + if (typeof requestedName !== "string") throw new Error("[INVALID_SHEET_NAME] Worksheet name must be a string."); + let name = requestedName.trim(); + if (!name) { + let i = workbook.sheets.length + 1; + name = `Sheet${i}`; + while (findSheetByName(workbook, name)) { + i += 1; + name = `Sheet${i}`; + } + } else if (findSheetByName(workbook, name)) { + throw new Error(`Sheet name already exists: ${name}`); + } + const rows = requirePositiveSafeInteger(input.rows, 50, "rows"); + const cols = requirePositiveSafeInteger(input.cols, 26, "cols"); + assertSheetDimensions(rows, cols, "New worksheet"); + const sheet = createEmptySheet(name, rows, cols); + workbook.sheets.push(sheet); + if (input.activate !== false) { + workbook.activeSheetId = sheet.id; + workbook.focus = { sheetId: sheet.id, a1: "A1", kind: "cell" }; + } + const operation = recordOperation(workbook, { + type: "add-sheet", + actor: input.actor || "user", + intent: input.intent || null, + summary: `Added sheet ${sheet.name}`, + sheetId: sheet.id, + roundTripSafe: false, + change: { kind: "workbook", before, after: captureWorkbookContent(workbook) }, + }); + const saved = persist(workspacePath, workbook); + return { + summary: `Added sheet "${sheet.name}"`, + sheet: { id: sheet.id, name: sheet.name, rows: sheet.rows, cols: sheet.cols }, + operationId: operation.id, + meta: metaOf(saved), + }; +} + +function reloadWorkbook(input = {}) { + const workspacePath = requireWorkspace(input); + const workbookId = requireWorkbookId(input); + const existing = loadOrGet(workspacePath, workbookId); + + if (existing.path) { + assertWritable(existing, "reloadWorkbook"); + assertExpectedRevision(existing, input, "reloadWorkbook"); + const before = captureWorkbookContent(existing); + const reloaded = readWorkbookFile(existing.path, { + workbookId: existing.workbookId, + title: existing.title, + }); + existing.title = reloaded.title; + existing.sheets = cloneJson(reloaded.sheets); + existing.activeSheetId = reloaded.activeSheetId; + existing.focus = cloneJson(reloaded.focus); + existing.path = reloaded.path; + existing.sourcePath = reloaded.sourcePath; + existing.sourceFormat = reloaded.sourceFormat; + existing.sourceFingerprint = cloneJson(reloaded.sourceFingerprint); + existing.fidelity = cloneJson(reloaded.fidelity); + existing.proposal = null; + const operation = recordOperation(existing, { + type: "reload-workbook", + actor: input.actor || "user", + intent: input.intent || "Reload external workbook", + summary: `Reloaded workbook from ${existing.path}`, + roundTripSafe: true, + change: { kind: "workbook", before, after: captureWorkbookContent(existing) }, + }); + existing.dirty = false; + refreshCalculationStatus(existing, false); + const saved = persist(workspacePath, normalizeWorkbook(existing)); + const sheet = getSheet(saved, saved.activeSheetId); + return { + summary: `Reloaded workbook from ${existing.path}`, + operationId: operation.id, + meta: metaOf(saved), + viewport: viewportSummary(saved, sheet), + }; + } + + const fromStore = loadJson(workspacePath, workbookId); + sessions.set(workbookId, { workspacePath, workbook: fromStore }); + const sheet = getSheet(fromStore, fromStore.activeSheetId); + return { + summary: `Reloaded workbook store for ${workbookId}`, + meta: metaOf(fromStore), + viewport: viewportSummary(fromStore, sheet), + }; +} + +const actions = { + openWorkbook, + createWorkbook, + getMeta, + getFocus, + setFocus, + setMode, + readRange, + summarizeRange, + proposePatch, + getProposal, + acceptProposal, + rejectProposal, + applyLocalEdit, + applyLocalPatch, + applyLayout, + clearRange, + undo, + redo, + getHistory, + saveWorkbook, + exportCsv, + listSheets, + listWorkbooks, + switchSheet, + insertRows, + insertColumns, + deleteRows, + deleteColumns, + renameSheet, + addSheet, + reloadWorkbook, +}; + +function dispatch(action, input) { + const fn = actions[action]; + if (!fn) { + throw new Error(`Unsupported Sparo Excel Engine action: ${action}`); + } + return fn(input || {}); +} + +module.exports = { + dispatch, + actions, + sessions, + DEFAULT_MAX_CELLS, + limits: Object.freeze({ + maxReadRangeCells: MAX_READ_RANGE_CELLS, + maxProposalCells: MAX_PROPOSAL_CELLS, + maxLocalPatchCells: MAX_LOCAL_PATCH_CELLS, + maxAcceptSelectionCells: MAX_ACCEPT_SELECTION_CELLS, + }), +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/protocol.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/protocol.js new file mode 100644 index 00000000..976f9bc3 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/protocol.js @@ -0,0 +1,38 @@ +const fs = require("node:fs"); + +function emit(event) { + const line = `${JSON.stringify(event)}\n`; + try { + fs.writeSync(1, line); + } catch { + process.stdout.write(line); + } +} + +function emitStatus(message, status = "running") { + emit({ type: "run.status", status, message }); +} + +function readRequest() { + return new Promise((resolve, reject) => { + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + input += chunk; + }); + process.stdin.on("end", () => { + try { + resolve(JSON.parse(input.trim() || "{}")); + } catch (error) { + reject(error); + } + }); + process.stdin.on("error", reject); + }); +} + +module.exports = { + emit, + emitStatus, + readRequest, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/workbook-store.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/workbook-store.js new file mode 100644 index 00000000..cbc31e7d --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/workbook-store.js @@ -0,0 +1,580 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); + +const STORE_ROOT_SEGMENTS = [".sparo_os", "excel-live"]; +const WORKBOOK_SCHEMA_VERSION = 2; +const SUPPORTED_MODES = new Set(["inspect", "edit", "author"]); +const WORKBOOK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; + +function validateExcelSheetName(value) { + if (typeof value !== "string" || !value.trim()) { + throw new Error("[INVALID_SHEET_NAME] Worksheet name must be a non-empty string."); + } + const name = value.trim(); + if (Array.from(name).length > 31) { + throw new Error("[INVALID_SHEET_NAME] Worksheet name cannot exceed 31 characters."); + } + if (/[\\/?*\[\]:]/.test(name) || /[\u0000-\u001F]/.test(name)) { + throw new Error("[INVALID_SHEET_NAME] Worksheet name cannot contain \\ / ? * [ ] : or control characters."); + } + if (name.startsWith("'") || name.endsWith("'")) { + throw new Error("[INVALID_SHEET_NAME] Worksheet name cannot begin or end with an apostrophe."); + } + return name; +} + +function nowIso() { + return new Date().toISOString(); +} + +function newId(prefix) { + return `${prefix}_${crypto.randomBytes(8).toString("hex")}`; +} + +function ensureDir(dirPath) { + if (!dirPath) { + throw new Error("Directory path is required"); + } + fs.mkdirSync(dirPath, { recursive: true }); + return dirPath; +} + +function storeRoot(workspacePath) { + if (!workspacePath || typeof workspacePath !== "string" || !path.isAbsolute(workspacePath)) { + throw new Error( + "[INVALID_WORKSPACE_PATH] workspacePath must be a host-provided absolute path." + ); + } + return path.join(path.resolve(workspacePath), ...STORE_ROOT_SEGMENTS); +} + +function workbookDir(workspacePath, workbookId) { + const safeId = validateWorkbookId(workbookId); + const root = storeRoot(workspacePath); + const dir = path.resolve(root, safeId); + if (!dir.startsWith(`${path.resolve(root)}${path.sep}`)) { + throw new Error("[INVALID_WORKBOOK_ID] workbookId resolved outside the workbook store root."); + } + return dir; +} + +function workbookJsonPath(workspacePath, workbookId) { + return path.join(workbookDir(workspacePath, workbookId), "workbook.json"); +} + +function createEmptySheet(name, rows = 50, cols = 26) { + return { + id: newId("sheet"), + name: validateExcelSheetName(name || "Sheet1"), + rows: Math.max(1, Number(rows) || 50), + cols: Math.max(1, Number(cols) || 26), + cells: {}, + layout: defaultSheetLayout(), + }; +} + +function defaultSheetLayout() { + return { + units: { columnWidth: "excelCharacters", rowHeight: "points" }, + columns: [], + rows: [], + freezePanes: { rows: 0, columns: 0 }, + autoFilter: null, + }; +} + +function normalizeSheetLayout(value) { + const input = value && typeof value === "object" ? value : {}; + const normalizeBands = (items, sizeKey) => { + if (!Array.isArray(items)) return []; + const bands = []; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const start = Number(item.start); + const end = item.end == null ? start : Number(item.end); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { + continue; + } + const band = { start, end }; + if (item[sizeKey] != null && Number.isFinite(Number(item[sizeKey])) && Number(item[sizeKey]) > 0) { + band[sizeKey] = Number(item[sizeKey]); + } + if (item.autoFit === true) band.autoFit = true; + if (band[sizeKey] != null || band.autoFit === true) bands.push(band); + } + return bands.sort((a, b) => a.start - b.start || a.end - b.end); + }; + const freeze = input.freezePanes && typeof input.freezePanes === "object" + ? input.freezePanes + : {}; + const freezeRows = Number(freeze.rows || 0); + const freezeColumns = Number(freeze.columns || 0); + const autoFilter = input.autoFilter && typeof input.autoFilter === "object" + && typeof input.autoFilter.a1 === "string" && input.autoFilter.a1.trim() + ? { a1: input.autoFilter.a1.trim() } + : null; + return { + units: { columnWidth: "excelCharacters", rowHeight: "points" }, + columns: normalizeBands(input.columns, "width"), + rows: normalizeBands(input.rows, "height"), + freezePanes: { + rows: Number.isSafeInteger(freezeRows) && freezeRows >= 0 ? freezeRows : 0, + columns: Number.isSafeInteger(freezeColumns) && freezeColumns >= 0 ? freezeColumns : 0, + }, + autoFilter, + }; +} + +function validateWorkbookId(workbookId) { + if ( + typeof workbookId !== "string" || + !WORKBOOK_ID_PATTERN.test(workbookId) || + WINDOWS_RESERVED_NAMES.test(workbookId) + ) { + throw new Error( + "[INVALID_WORKBOOK_ID] workbookId must be 1-128 safe ASCII characters (letters, digits, underscore, or hyphen), must start with a letter or digit, and must not be a reserved device name." + ); + } + return workbookId; +} + +function normalizeMode(value) { + return SUPPORTED_MODES.has(value) ? value : "edit"; +} + +function formatFromPath(filePath) { + const ext = path.extname(String(filePath || "")).toLowerCase(); + return ext ? ext.slice(1) : null; +} + +function defaultFidelity(sourceFormat) { + const format = String(sourceFormat || "").toLowerCase() || null; + if (!format) { + return { + level: "native", + canRoundTrip: true, + canOverwriteSource: false, + preserved: [ + "cell values", + "basic formulas", + "authored cell styles and number formats", + "column widths and row heights", + "freeze panes and auto filters", + "sheet names", + ], + unsupported: [], + warning: null, + }; + } + if (format === "json") { + return { + level: "native", + canRoundTrip: true, + canOverwriteSource: false, + preserved: ["Sparo workbook state"], + unsupported: [], + warning: "Imported sources are protected from overwrite by default.", + }; + } + if (format === "csv") { + return { + level: "limited", + canRoundTrip: false, + canOverwriteSource: false, + preserved: ["active sheet cell values"], + unsupported: ["multiple sheets", "formulas", "styles", "charts", "workbook metadata"], + warning: "CSV import does not preserve workbook features. Use Save As to avoid replacing the source.", + }; + } + if (format === "xlsx" || format === "xlsm") { + return { + level: "source-preserving", + canRoundTrip: true, + canOverwriteSource: false, + structureChanged: false, + preserved: [ + "unknown OOXML package parts", + "existing cell styles and number formats", + "existing column widths, row heights, freeze panes, and auto filters", + "sheet names and structure", + "cell values and formulas", + format === "xlsm" ? "macros and VBA package parts" : "workbook package metadata", + ], + unsupported: [ + "new or changed styles during source-preserving patch", + "new or changed layout during source-preserving patch", + "structural edits", + "formula calculation in the live engine", + ], + warning: "Value/formula-only edits can be patched into a copy of the source package. New styles or layout require an acknowledged Save As rebuild; source overwrite remains protected.", + }; + } + return { + level: "limited", + canRoundTrip: false, + canOverwriteSource: false, + preserved: ["sheet names", "cell values", "basic formulas", "cached formula values"], + unsupported: [ + "styles and number formats", + "merged cells", + "data validation", + "tables and named ranges", + "charts and drawings", + "comments and pivot tables", + "macros and VBA", + ], + warning: "This engine cannot losslessly round-trip Excel files. Use Save As to protect the source.", + }; +} + +function countFormulas(sheets) { + let count = 0; + for (const sheet of sheets || []) { + for (const cell of Object.values(sheet.cells || {})) { + if (cell && (cell.formulaEvidence === true || cell.f)) count += 1; + } + } + return count; +} + +function defaultCalculationStatus(sheets) { + const formulaCount = countFormulas(sheets); + return { + engine: "none", + status: formulaCount > 0 ? "cached" : "not-required", + formulaCount, + lastCalculatedRevision: null, + warning: formulaCount > 0 + ? "Formula results are cached values; this engine does not recalculate formulas." + : null, + }; +} + +function createEmpty(options = {}) { + const title = options.title || "Untitled Workbook"; + const rows = options.rows == null ? 50 : options.rows; + const cols = options.cols == null ? 26 : options.cols; + const sheet = createEmptySheet(options.sheetName || "Sheet1", rows, cols); + const createdAt = nowIso(); + const workbookId = options.workbookId || newId("wb"); + validateWorkbookId(workbookId); + return { + schemaVersion: WORKBOOK_SCHEMA_VERSION, + workbookId, + path: options.path || null, + sourcePath: options.sourcePath || null, + sourceFormat: options.sourceFormat || null, + sourceFingerprint: options.sourceFingerprint || null, + lastExportPath: null, + lastExportedRevision: null, + lastExportFingerprint: null, + title, + sheets: [sheet], + activeSheetId: sheet.id, + dirty: false, + revision: 0, + mode: normalizeMode(options.mode), + focus: { + sheetId: sheet.id, + a1: "A1", + kind: "cell", + }, + proposal: null, + fidelity: defaultFidelity(options.sourceFormat), + calculationStatus: defaultCalculationStatus([sheet]), + history: [], + undoStack: [], + redoStack: [], + createdAt, + updatedAt: createdAt, + }; +} + +function normalizeWorkbook(raw) { + if (!raw || typeof raw !== "object") { + throw new Error("Invalid workbook JSON"); + } + if (!raw.workbookId) { + throw new Error("Workbook is missing workbookId"); + } + validateWorkbookId(raw.workbookId); + if (!Array.isArray(raw.sheets) || raw.sheets.length === 0) { + throw new Error("Workbook must contain at least one sheet"); + } + const normalizedSheetNames = new Set(); + const sheets = raw.sheets.map((sheet, index) => { + if (!sheet || typeof sheet !== "object") { + throw new Error(`Invalid sheet at index ${index}`); + } + const name = validateExcelSheetName(sheet.name || `Sheet${index + 1}`); + const nameKey = name.toLowerCase(); + if (normalizedSheetNames.has(nameKey)) { + throw new Error(`[DUPLICATE_SHEET_NAME] Worksheet name already exists: ${name}`); + } + normalizedSheetNames.add(nameKey); + return { + id: sheet.id || newId("sheet"), + name, + rows: Math.max(1, Number(sheet.rows) || 50), + cols: Math.max(1, Number(sheet.cols) || 26), + cells: sheet.cells && typeof sheet.cells === "object" ? sheet.cells : {}, + layout: normalizeSheetLayout(sheet.layout), + complexFormulaRanges: Array.isArray(sheet.complexFormulaRanges) + ? sheet.complexFormulaRanges + : [], + }; + }); + const activeSheetId = + sheets.find((s) => s.id === raw.activeSheetId)?.id || sheets[0].id; + const focus = raw.focus && typeof raw.focus === "object" + ? { + sheetId: sheets.find((s) => s.id === raw.focus.sheetId)?.id || activeSheetId, + a1: raw.focus.a1 || "A1", + kind: raw.focus.kind || "cell", + } + : { sheetId: activeSheetId, a1: "A1", kind: "cell" }; + + const revision = Number.isSafeInteger(raw.revision) && raw.revision >= 0 + ? raw.revision + : 0; + const sourcePath = raw.sourcePath || raw.source?.path || raw.path || null; + const sourceFormat = raw.sourceFormat || raw.source?.format || formatFromPath(sourcePath); + const defaultCalculation = defaultCalculationStatus(sheets); + const calculationStatus = raw.calculationStatus && typeof raw.calculationStatus === "object" + ? { + ...defaultCalculation, + ...raw.calculationStatus, + formulaCount: defaultCalculation.formulaCount, + } + : defaultCalculation; + const proposal = raw.proposal && typeof raw.proposal === "object" + ? { + ...raw.proposal, + baseRevision: Number.isSafeInteger(raw.proposal.baseRevision) + ? raw.proposal.baseRevision + : revision, + intent: raw.proposal.intent || null, + validation: raw.proposal.validation || null, + layout: raw.proposal.layout && typeof raw.proposal.layout === "object" + ? { + before: normalizeSheetLayout(raw.proposal.layout.before), + after: normalizeSheetLayout(raw.proposal.layout.after), + } + : null, + } + : null; + + return { + schemaVersion: WORKBOOK_SCHEMA_VERSION, + workbookId: raw.workbookId, + path: raw.path || null, + sourcePath, + sourceFormat, + sourceFingerprint: + raw.sourceFingerprint && typeof raw.sourceFingerprint === "object" + ? raw.sourceFingerprint + : null, + lastExportPath: raw.lastExportPath || null, + lastExportedRevision: Number.isSafeInteger(raw.lastExportedRevision) + ? raw.lastExportedRevision + : null, + lastExportFingerprint: + raw.lastExportFingerprint && typeof raw.lastExportFingerprint === "object" + ? raw.lastExportFingerprint + : null, + title: raw.title || "Untitled Workbook", + sheets, + activeSheetId, + dirty: Boolean(raw.dirty), + revision, + mode: normalizeMode(raw.mode), + focus, + proposal, + fidelity: raw.fidelity && typeof raw.fidelity === "object" + ? { ...defaultFidelity(sourceFormat), ...raw.fidelity } + : defaultFidelity(sourceFormat), + calculationStatus, + history: Array.isArray(raw.history) ? raw.history.slice(-500) : [], + undoStack: Array.isArray(raw.undoStack) ? raw.undoStack.slice(-100) : [], + redoStack: Array.isArray(raw.redoStack) ? raw.redoStack.slice(-100) : [], + createdAt: raw.createdAt || nowIso(), + updatedAt: raw.updatedAt || nowIso(), + }; +} + +function loadJson(workspacePath, workbookId) { + const filePath = workbookJsonPath(workspacePath, workbookId); + if (!fs.existsSync(filePath)) { + throw new Error(`Workbook store not found: ${filePath}`); + } + const text = fs.readFileSync(filePath, "utf8"); + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`Failed to parse workbook store JSON: ${error.message}`); + } + return normalizeWorkbook(parsed); +} + +function listWorkbookMetas(workspacePath) { + const root = storeRoot(workspacePath); + if (!fs.existsSync(root)) return []; + const metas = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const filePath = path.join(root, entry.name, "workbook.json"); + if (!fs.existsSync(filePath)) continue; + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (!parsed || !parsed.workbookId) continue; + validateWorkbookId(parsed.workbookId); + metas.push({ + workbookId: parsed.workbookId, + title: parsed.title || "Untitled Workbook", + path: parsed.path || null, + dirty: Boolean(parsed.dirty), + revision: Number.isSafeInteger(parsed.revision) ? parsed.revision : 0, + mode: normalizeMode(parsed.mode), + sourcePath: parsed.sourcePath || parsed.path || null, + sourceFormat: parsed.sourceFormat || formatFromPath(parsed.sourcePath || parsed.path), + sourceFingerprint: + parsed.sourceFingerprint && typeof parsed.sourceFingerprint === "object" + ? parsed.sourceFingerprint + : null, + lastExportPath: parsed.lastExportPath || null, + lastExportedRevision: Number.isSafeInteger(parsed.lastExportedRevision) + ? parsed.lastExportedRevision + : null, + lastExportFingerprint: + parsed.lastExportFingerprint && typeof parsed.lastExportFingerprint === "object" + ? parsed.lastExportFingerprint + : null, + sheetCount: Array.isArray(parsed.sheets) ? parsed.sheets.length : 0, + createdAt: parsed.createdAt || null, + updatedAt: parsed.updatedAt || null, + }); + } catch (_error) { + // Skip unreadable stores; they should not break listing. + } + } + metas.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))); + return metas; +} + +function normalizePathIdentity(value, platform = process.platform) { + const resolved = path.resolve(String(value || "")).replaceAll("\\", "/"); + return platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function findWorkbookIdByPath(workspacePath, filePath) { + if (!filePath) return null; + const wanted = normalizePathIdentity(filePath); + for (const meta of listWorkbookMetas(workspacePath)) { + if ( + (meta.sourcePath && normalizePathIdentity(meta.sourcePath) === wanted) || + (meta.path && normalizePathIdentity(meta.path) === wanted) + ) { + return meta.workbookId; + } + } + return null; +} + +function saveJson(workspacePath, workbook) { + if (!workbook || !workbook.workbookId) { + throw new Error("Cannot save workbook without workbookId"); + } + const dir = workbookDir(workspacePath, workbook.workbookId); + ensureDir(dir); + const next = { + ...workbook, + updatedAt: nowIso(), + }; + const filePath = workbookJsonPath(workspacePath, workbook.workbookId); + const tempPath = path.join( + dir, + `.workbook.json.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp` + ); + let handle = null; + try { + handle = fs.openSync(tempPath, "wx", 0o600); + fs.writeFileSync(handle, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + fs.fsyncSync(handle); + fs.closeSync(handle); + handle = null; + fs.renameSync(tempPath, filePath); + } catch (error) { + if (handle != null) { + try { + fs.closeSync(handle); + } catch (_closeError) { + // Preserve the original save error. + } + } + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch (_cleanupError) { + // Best-effort cleanup; never mask the original save error. + } + throw new Error(`Failed to atomically save workbook store: ${error.message}`); + } + return next; +} + +function cellKey(row, col) { + return `${row},${col}`; +} + +function getSheet(workbook, sheetId) { + if (!workbook) { + throw new Error("Workbook is required"); + } + const id = sheetId || workbook.activeSheetId; + const sheet = workbook.sheets.find((s) => s.id === id); + if (!sheet) { + throw new Error(`Sheet not found: ${id}`); + } + return sheet; +} + +function findSheetByName(workbook, name) { + if (!name) { + return null; + } + const lower = String(name).toLowerCase(); + return workbook.sheets.find((s) => String(s.name).toLowerCase() === lower) || null; +} + +module.exports = { + STORE_ROOT_SEGMENTS, + WORKBOOK_SCHEMA_VERSION, + WORKBOOK_ID_PATTERN, + ensureDir, + storeRoot, + workbookDir, + workbookJsonPath, + createEmptySheet, + defaultSheetLayout, + normalizeSheetLayout, + createEmpty, + validateWorkbookId, + validateExcelSheetName, + defaultFidelity, + defaultCalculationStatus, + normalizeMode, + formatFromPath, + normalizeWorkbook, + loadJson, + saveJson, + listWorkbookMetas, + findWorkbookIdByPath, + normalizePathIdentity, + cellKey, + getSheet, + findSheetByName, + newId, + nowIso, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/xlsx-io.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/xlsx-io.js new file mode 100644 index 00000000..0b7ef12a --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/xlsx-io.js @@ -0,0 +1,1997 @@ +/** + * Minimal XLSX / CSV / JSON workbook I/O using only Node builtins. + * ZIP: local file header reader + inflateRaw for deflated entries. + * WRITE: store-method ZIP with a minimal OOXML package openable in Excel. + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const zlib = require("node:zlib"); +const crypto = require("node:crypto"); +const { + MAX_CSV_PARSED_CELLS, + parseCsv, + serializeCsv, +} = require("./csv-io"); +const { + WORKBOOK_SCHEMA_VERSION, + createEmpty, + createEmptySheet, + defaultSheetLayout, + cellKey, + newId, + nowIso, + defaultFidelity, + defaultCalculationStatus, + formatFromPath, + normalizeWorkbook, + validateExcelSheetName, +} = require("./workbook-store"); +const { + EXCEL_MAX_COLUMNS, + EXCEL_MAX_ROWS, + assertCellCoordinates, + assertSheetDimensions, + cellCount, + indexToCol, +} = require("./a1"); + +const SIG_LOCAL = 0x04034b50; +const SIG_CENTRAL = 0x02014b50; +const SIG_END = 0x06054b50; +const METHOD_STORE = 0; +const METHOD_DEFLATE = 8; +const MAX_ZIP_ENTRIES = 4096; +const MAX_ZIP_FILE_BYTES = 256 * 1024 * 1024; +const MAX_ZIP_ENTRY_BYTES = 128 * 1024 * 1024; +const MAX_ZIP_TOTAL_BYTES = 512 * 1024 * 1024; +const MAX_ZIP_COMPRESSION_RATIO = 200; +const MAX_CSV_FILE_BYTES = 64 * 1024 * 1024; +const MAX_JSON_FILE_BYTES = 64 * 1024 * 1024; +const MAX_XLSX_PARSED_CELLS = 500_000; +const MAX_DENSE_EXPORT_CELLS = 1_000_000; +const MAX_COMPLEX_FORMULA_COVERAGE_CELLS = 500_000; +const MAX_AUTHORED_CELL_STYLES = 10_000; + +function fileFingerprint(buffer, stat) { + return { + algorithm: "sha256", + hash: crypto.createHash("sha256").update(buffer).digest("hex"), + size: buffer.length, + mtimeMs: Number(stat.mtimeMs), + }; +} + +function readStableFile(filePath, options = {}) { + const maxBytes = options.maxBytes ?? MAX_ZIP_FILE_BYTES; + const sizeLimitCode = options.sizeLimitCode || "ZIP_FILE_SIZE_LIMIT"; + const fileKind = options.fileKind || "Workbook file"; + const before = fs.statSync(filePath); + if (!before.isFile()) throw new Error(`Not a regular file: ${filePath}`); + if (before.size > maxBytes) { + throw new Error( + `[${sizeLimitCode}] ${fileKind} is ${before.size} bytes; maximum is ${maxBytes}.` + ); + } + const buffer = fs.readFileSync(filePath); + const after = fs.statSync(filePath); + if ( + before.size !== after.size || + Number(before.mtimeMs) !== Number(after.mtimeMs) || + buffer.length !== after.size + ) { + throw new Error( + `[SOURCE_CHANGED_DURING_READ] ${filePath} changed while it was being read. Retry after external writes finish.` + ); + } + return { buffer, fingerprint: fileFingerprint(buffer, after) }; +} + +function fingerprintsEqual(left, right) { + return Boolean( + left && + right && + left.algorithm === "sha256" && + right.algorithm === "sha256" && + left.hash === right.hash && + left.size === right.size && + Number(left.mtimeMs) === Number(right.mtimeMs) + ); +} + +function realpathNative(filePath) { + return fs.realpathSync.native ? fs.realpathSync.native(filePath) : fs.realpathSync(filePath); +} + +function identityPathKey(filePath) { + const normalized = path.normalize(path.resolve(filePath)); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function sameFileIdentity(leftPath, rightPath) { + const left = fs.statSync(leftPath, { bigint: true }); + const right = fs.statSync(rightPath, { bigint: true }); + return left.ino !== 0n && left.dev === right.dev && left.ino === right.ino; +} + +/** + * Resolve the target parent before writing and reject every known alias of the + * imported source (direct path, symlink/junction path, or existing hard link). + * The returned path keeps the requested basename but anchors the write in the + * already-resolved parent directory, so a parent alias is not followed again. + */ +function assertDistinctSourceTarget(sourcePath, targetPath) { + try { + const sourceRealPath = realpathNative(sourcePath); + const targetAbsolutePath = path.resolve(targetPath); + const targetParentRealPath = realpathNative(path.dirname(targetAbsolutePath)); + const resolvedTargetPath = path.join(targetParentRealPath, path.basename(targetAbsolutePath)); + let targetRealPath = null; + let targetExists = false; + try { + targetRealPath = realpathNative(targetAbsolutePath); + targetExists = true; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + + const aliasesSource = + identityPathKey(sourceRealPath) === identityPathKey(resolvedTargetPath) || + (targetRealPath != null && + identityPathKey(sourceRealPath) === identityPathKey(targetRealPath)) || + (targetExists && sameFileIdentity(sourceRealPath, targetAbsolutePath)); + if (aliasesSource) { + throw new Error( + `[SOURCE_TARGET_ALIAS_BLOCKED] Export target ${targetPath} resolves to or identifies the imported source package. Choose a different output file.` + ); + } + return resolvedTargetPath; + } catch (error) { + if (/^\[SOURCE_TARGET_ALIAS_BLOCKED\]/.test(String(error.message || ""))) throw error; + throw new Error( + `[SOURCE_TARGET_IDENTITY_CHECK_FAILED] Could not verify that ${targetPath} is distinct from ${sourcePath}: ${error.message}` + ); + } +} + +function crc32(buf) { + let crc = 0xffffffff; + const table = crc32.table || (crc32.table = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i += 1) { + let c = i; + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + t[i] = c >>> 0; + } + return t; + })()); + const data = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + for (let i = 0; i < data.length; i += 1) { + crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function decodeXmlEntities(text) { + return String(text || "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16))) + .replace(/&/g, "&"); +} + +function escapeXml(text) { + return String(text == null ? "" : text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Read ZIP local file entries (sufficient for typical xlsx packages). + * @returns {Map} + */ +function unzipBuffer(buffer) { + const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + const files = new Map(); + if (buf.length > MAX_ZIP_FILE_BYTES) { + throw new Error( + `[ZIP_FILE_SIZE_LIMIT] Workbook ZIP is ${buf.length} bytes; maximum is ${MAX_ZIP_FILE_BYTES}.` + ); + } + if (buf.length < 22) throw new Error("Corrupt ZIP: file is too small"); + let endOffset = -1; + const minOffset = Math.max(0, buf.length - 0xffff - 22); + for (let offset = buf.length - 22; offset >= minOffset; offset -= 1) { + if (buf.readUInt32LE(offset) === SIG_END) { + endOffset = offset; + break; + } + } + if (endOffset < 0) { + throw new Error("Corrupt ZIP: end-of-central-directory record not found"); + } + const entryCount = buf.readUInt16LE(endOffset + 10); + const centralOffset = buf.readUInt32LE(endOffset + 16); + if (entryCount === 0xffff || centralOffset === 0xffffffff) { + throw new Error("ZIP64 workbooks are not supported"); + } + if (entryCount > MAX_ZIP_ENTRIES) { + throw new Error( + `[ZIP_ENTRY_LIMIT] Workbook ZIP has ${entryCount} entries; maximum is ${MAX_ZIP_ENTRIES}.` + ); + } + const entries = []; + let totalUncompressedSize = 0; + let offset = centralOffset; + for (let index = 0; index < entryCount; index += 1) { + if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== SIG_CENTRAL) { + throw new Error("Corrupt ZIP: invalid central directory entry"); + } + const flags = buf.readUInt16LE(offset + 8); + const method = buf.readUInt16LE(offset + 10); + const expectedCrc = buf.readUInt32LE(offset + 16); + const compressedSize = buf.readUInt32LE(offset + 20); + const uncompressedSize = buf.readUInt32LE(offset + 24); + const nameLen = buf.readUInt16LE(offset + 28); + const extraLen = buf.readUInt16LE(offset + 30); + const commentLen = buf.readUInt16LE(offset + 32); + const localOffset = buf.readUInt32LE(offset + 42); + if (flags & 0x1) throw new Error("Encrypted ZIP entries are not supported"); + const nameStart = offset + 46; + const nameEnd = nameStart + nameLen; + if (nameEnd + extraLen + commentLen > buf.length) { + throw new Error("Corrupt ZIP: truncated central directory entry"); + } + const name = buf.slice(nameStart, nameEnd).toString(flags & 0x800 ? "utf8" : "utf8"); + if (uncompressedSize > MAX_ZIP_ENTRY_BYTES) { + throw new Error( + `[ZIP_ENTRY_SIZE_LIMIT] ZIP entry ${name} declares ${uncompressedSize} bytes; maximum is ${MAX_ZIP_ENTRY_BYTES}.` + ); + } + totalUncompressedSize += uncompressedSize; + if (totalUncompressedSize > MAX_ZIP_TOTAL_BYTES) { + throw new Error( + `[ZIP_TOTAL_SIZE_LIMIT] Workbook ZIP declares ${totalUncompressedSize} uncompressed bytes; maximum is ${MAX_ZIP_TOTAL_BYTES}.` + ); + } + if ( + method === METHOD_DEFLATE && + uncompressedSize > 0 && + (compressedSize === 0 || uncompressedSize / compressedSize > MAX_ZIP_COMPRESSION_RATIO) + ) { + throw new Error( + `[ZIP_COMPRESSION_RATIO_LIMIT] ZIP entry ${name} exceeds the maximum compression ratio of ${MAX_ZIP_COMPRESSION_RATIO}:1.` + ); + } + if (method !== METHOD_STORE && method !== METHOD_DEFLATE) { + throw new Error(`Unsupported ZIP compression method ${method} for ${name}`); + } + if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== SIG_LOCAL) { + throw new Error(`Corrupt ZIP: local header missing for ${name}`); + } + const localNameLen = buf.readUInt16LE(localOffset + 26); + const localExtraLen = buf.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLen + localExtraLen; + const dataEnd = dataStart + compressedSize; + if (dataEnd > buf.length) throw new Error(`Corrupt ZIP: truncated entry ${name}`); + entries.push({ + name, + method, + expectedCrc, + compressedSize, + uncompressedSize, + dataStart, + dataEnd, + }); + offset = nameEnd + extraLen + commentLen; + } + + for (const entry of entries) { + const compressed = buf.slice(entry.dataStart, entry.dataEnd); + let content; + if (entry.method === METHOD_STORE) { + content = compressed; + } else { + try { + content = zlib.inflateRawSync(compressed, { + maxOutputLength: Math.max(1, entry.uncompressedSize), + }); + } catch (error) { + throw new Error(`Failed to inflate ZIP entry ${entry.name}: ${error.message}`); + } + } + if (content.length !== entry.uncompressedSize || crc32(content) !== entry.expectedCrc) { + throw new Error(`Corrupt ZIP: size or CRC mismatch for ${entry.name}`); + } + if (!entry.name.endsWith("/")) { + files.set(entry.name.replace(/\\/g, "/"), content); + } + } + + if (files.size === 0) { + throw new Error("ZIP archive contained no files"); + } + return files; +} + +function dosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const dosTime = + (date.getHours() << 11) | + (date.getMinutes() << 5) | + Math.floor(date.getSeconds() / 2); + const dosDate = + ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { dosTime, dosDate }; +} + +/** + * Build a ZIP archive using STORE (no compression) for simplicity and reliability. + * @param {Array<{ name: string, data: Buffer|string }>} entries + */ +function zipStore(entries) { + const { dosTime, dosDate } = dosDateTime(); + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const entry of entries) { + const name = String(entry.name).replace(/\\/g, "/"); + const nameBuf = Buffer.from(name, "utf8"); + const data = Buffer.isBuffer(entry.data) + ? entry.data + : Buffer.from(entry.data == null ? "" : String(entry.data), "utf8"); + const crc = crc32(data); + const size = data.length; + + const local = Buffer.alloc(30); + local.writeUInt32LE(SIG_LOCAL, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(METHOD_STORE, 8); + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(size, 18); + local.writeUInt32LE(size, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + + const central = Buffer.alloc(46); + central.writeUInt32LE(SIG_CENTRAL, 0); + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed + central.writeUInt16LE(0, 8); + central.writeUInt16LE(METHOD_STORE, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(size, 20); + central.writeUInt32LE(size, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); // extra + central.writeUInt16LE(0, 32); // comment + central.writeUInt16LE(0, 34); // disk + central.writeUInt16LE(0, 36); // int attrs + central.writeUInt32LE(0, 38); // ext attrs + central.writeUInt32LE(offset, 42); + + localParts.push(local, nameBuf, data); + centralParts.push(central, nameBuf); + offset += local.length + nameBuf.length + data.length; + } + + const centralDir = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(SIG_END, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDir.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, centralDir, end]); +} + +function parseSharedStrings(xml) { + const strings = []; + if (!xml) { + return strings; + } + const text = String(xml); + // Match each ... block + const siRe = /]*>([\s\S]*?)<\/si>/gi; + let match; + while ((match = siRe.exec(text))) { + const block = match[1]; + const parts = []; + const tRe = /]*>([\s\S]*?)<\/t>/gi; + let tMatch; + while ((tMatch = tRe.exec(block))) { + parts.push(decodeXmlEntities(tMatch[1])); + } + strings.push(parts.join("")); + } + return strings; +} + +function parseSheetNames(workbookXml) { + const names = []; + if (!workbookXml) { + return names; + } + const re = /]*\bname="([^"]+)"[^>]*>/gi; + let match; + while ((match = re.exec(String(workbookXml)))) { + names.push(decodeXmlEntities(match[1])); + } + return names; +} + +const BUILTIN_NUMBER_FORMATS = Object.freeze({ + 0: "General", 1: "0", 2: "0.00", 3: "#,##0", 4: "#,##0.00", + 9: "0%", 10: "0.00%", 11: "0.00E+00", 12: "# ?/?", 13: "# ??/??", + 14: "mm-dd-yy", 15: "d-mmm-yy", 16: "d-mmm", 17: "mmm-yy", + 18: "h:mm AM/PM", 19: "h:mm:ss AM/PM", 20: "h:mm", 21: "h:mm:ss", 22: "m/d/yy h:mm", + 37: "#,##0 ;(#,##0)", 38: "#,##0 ;[Red](#,##0)", 39: "#,##0.00;(#,##0.00)", + 40: "#,##0.00;[Red](#,##0.00)", 49: "@", +}); + +function xmlAttribute(attrs, name) { + const match = String(attrs || "").match(new RegExp(`\\b${name}="([^"]*)"`, "i")); + return match ? decodeXmlEntities(match[1]) : null; +} + +function xmlElements(xml, tag) { + const elements = []; + const re = new RegExp(`<${tag}\\b([^>]*?)(?:>([\\s\\S]*?)<\\/${tag}>|\\/\\s*>)`, "gi"); + let match; + while ((match = re.exec(String(xml || "")))) elements.push({ attrs: match[1] || "", body: match[2] || "" }); + return elements; +} + +function rgbToColor(rgb) { + const value = String(rgb || "").trim().toUpperCase(); + if (/^[0-9A-F]{8}$/.test(value)) return `#${value.slice(2)}`; + if (/^[0-9A-F]{6}$/.test(value)) return `#${value}`; + return null; +} + +function parseXmlColor(xml) { + const attrs = String(xml || "").match(/]*)\/?\s*>/i)?.[1] + || String(xml || "").match(/]*)\/?\s*>/i)?.[1] + || ""; + return rgbToColor(xmlAttribute(attrs, "rgb")); +} + +function parseStyles(xml) { + const text = String(xml || ""); + if (!text) return []; + const customNumberFormats = new Map(); + for (const item of xmlElements(text.match(/]*>([\s\S]*?)<\/numFmts>/i)?.[1] || "", "numFmt")) { + const id = Number(xmlAttribute(item.attrs, "numFmtId")); + const code = xmlAttribute(item.attrs, "formatCode"); + if (Number.isSafeInteger(id) && code) customNumberFormats.set(id, code); + } + const fonts = xmlElements(text.match(/]*>([\s\S]*?)<\/fonts>/i)?.[1] || "", "font").map((item) => { + const font = {}; + if (/]*\bval="([^"]+)"/i)?.[1]); + if (Number.isFinite(size) && size > 0) font.size = size; + return Object.keys(font).length ? font : null; + }); + const fills = xmlElements(text.match(/]*>([\s\S]*?)<\/fills>/i)?.[1] || "", "fill").map((item) => { + const color = parseXmlColor(item.body); + return color ? { color } : null; + }); + const borders = xmlElements(text.match(/]*>([\s\S]*?)<\/borders>/i)?.[1] || "", "border").map((item) => { + const border = {}; + for (const side of ["top", "right", "bottom", "left"]) { + const match = item.body.match(new RegExp(`<${side}\\b([^>]*?)(?:>([\\s\\S]*?)<\\/${side}>|\\/\\s*>)`, "i")); + if (!match) continue; + const style = xmlAttribute(match[1], "style"); + if (!style) continue; + border[side] = { style }; + const color = parseXmlColor(match[2]); + if (color) border[side].color = color; + } + return Object.keys(border).length ? border : null; + }); + const cellXfs = text.match(/]*>([\s\S]*?)<\/cellXfs>/i)?.[1] || ""; + return xmlElements(cellXfs, "xf").map((xf) => { + const style = {}; + const fontId = Number(xmlAttribute(xf.attrs, "fontId")); + const fillId = Number(xmlAttribute(xf.attrs, "fillId")); + const borderId = Number(xmlAttribute(xf.attrs, "borderId")); + const numFmtId = Number(xmlAttribute(xf.attrs, "numFmtId")); + if (fonts[fontId]) style.font = fonts[fontId]; + if (fills[fillId]) style.fill = fills[fillId]; + if (borders[borderId]) style.border = borders[borderId]; + const numberFormat = customNumberFormats.get(numFmtId) || BUILTIN_NUMBER_FORMATS[numFmtId]; + if (numberFormat && numFmtId !== 0) style.numberFormat = numberFormat; + const alignmentAttrs = xf.body.match(/]*)\/?\s*>/i)?.[1]; + if (alignmentAttrs != null) { + const alignment = {}; + const horizontal = xmlAttribute(alignmentAttrs, "horizontal"); + const vertical = xmlAttribute(alignmentAttrs, "vertical"); + if (horizontal) alignment.horizontal = horizontal; + if (vertical) alignment.vertical = vertical; + if (xmlAttribute(alignmentAttrs, "wrapText") === "1") alignment.wrapText = true; + if (Object.keys(alignment).length) style.alignment = alignment; + } + return Object.keys(style).length ? style : null; + }); +} + +function parseWorksheetLayout(xml) { + const text = String(xml || ""); + const columns = []; + const colsBody = text.match(/]*>([\s\S]*?)<\/cols>/i)?.[1] || ""; + for (const column of xmlElements(colsBody, "col")) { + const start = Number(xmlAttribute(column.attrs, "min")) - 1; + const end = Number(xmlAttribute(column.attrs, "max")) - 1; + const width = Number(xmlAttribute(column.attrs, "width")); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) continue; + const band = { start, end }; + if (Number.isFinite(width) && width > 0) band.width = width; + if (xmlAttribute(column.attrs, "bestFit") === "1") band.autoFit = true; + if (band.width != null || band.autoFit) columns.push(band); + } + const rows = []; + const rowRe = /]*?)(?:>[\s\S]*?<\/row>|\/\s*>)/gi; + let rowMatch; + while ((rowMatch = rowRe.exec(text))) { + const row = Number(xmlAttribute(rowMatch[1], "r")) - 1; + const height = Number(xmlAttribute(rowMatch[1], "ht")); + if (!Number.isSafeInteger(row) || row < 0) continue; + if (Number.isFinite(height) && height > 0) rows.push({ start: row, end: row, height }); + } + const paneAttrs = text.match(/]*)\/?\s*>/i)?.[1] || ""; + const paneState = xmlAttribute(paneAttrs, "state"); + const freezePanes = { + rows: paneState && paneState.toLowerCase().startsWith("frozen") ? Math.max(0, Number(xmlAttribute(paneAttrs, "ySplit")) || 0) : 0, + columns: paneState && paneState.toLowerCase().startsWith("frozen") ? Math.max(0, Number(xmlAttribute(paneAttrs, "xSplit")) || 0) : 0, + }; + const filterRef = text.match(/]*\bref="([^"]+)"/i)?.[1] || null; + return { + units: { columnWidth: "excelCharacters", rowHeight: "points" }, + columns, + rows, + freezePanes, + autoFilter: filterRef ? { a1: decodeXmlEntities(filterRef).replaceAll("$", "") } : null, + }; +} + +function parseWorkbookSheetRefs(workbookXml) { + const sheets = []; + const re = /]*)\/?\s*>/gi; + let match; + while ((match = re.exec(String(workbookXml || "")))) { + const attrs = match[1] || ""; + const nameMatch = attrs.match(/\bname="([^"]+)"/i); + const relMatch = attrs.match(/\br:id="([^"]+)"/i); + if (nameMatch && relMatch) { + sheets.push({ name: decodeXmlEntities(nameMatch[1]), relId: relMatch[1] }); + } + } + return sheets; +} + +function parseRelationships(xml) { + const relationships = new Map(); + const re = /]*)\/?\s*>/gi; + let match; + while ((match = re.exec(String(xml || "")))) { + const attrs = match[1] || ""; + const id = attrs.match(/\bId="([^"]+)"/i)?.[1]; + const target = attrs.match(/\bTarget="([^"]+)"/i)?.[1]; + const type = attrs.match(/\bType="([^"]+)"/i)?.[1] || ""; + if (id && target) relationships.set(id, { target: decodeXmlEntities(target), type }); + } + return relationships; +} + +function resolveWorkbookTarget(target) { + const normalized = String(target || "").replaceAll("\\", "/"); + if (normalized.startsWith("/")) return normalized.slice(1); + if (normalized.startsWith("xl/")) return path.posix.normalize(normalized); + return path.posix.normalize(path.posix.join("xl", normalized)); +} + +function parseCellRef(ref) { + const normalized = String(ref || "").toUpperCase(); + const match = normalized.match(/^([A-Z]+)([1-9]\d*)$/); + if (!match) { + throw new Error( + `[XLSX_CELL_REF_INVALID] Worksheet contains an invalid cell reference: ${JSON.stringify(ref)}.` + ); + } + if (match[1].length > 3 || match[2].length > 7) { + throw new Error( + `[XLSX_CELL_REF_LIMIT] Cell reference ${normalized} exceeds Excel's maximum of ${EXCEL_MAX_ROWS} rows and ${EXCEL_MAX_COLUMNS} columns (XFD).` + ); + } + const rowNumber = Number(match[2]); + if (!Number.isSafeInteger(rowNumber) || rowNumber < 1 || rowNumber > EXCEL_MAX_ROWS) { + throw new Error( + `[XLSX_CELL_REF_LIMIT] Cell reference ${normalized} exceeds Excel's maximum of ${EXCEL_MAX_ROWS} rows.` + ); + } + let columnNumber = 0; + for (const character of match[1]) { + columnNumber = columnNumber * 26 + character.charCodeAt(0) - 64; + if (!Number.isSafeInteger(columnNumber) || columnNumber > EXCEL_MAX_COLUMNS) { + throw new Error( + `[XLSX_CELL_REF_LIMIT] Cell reference ${normalized} exceeds Excel's maximum of ${EXCEL_MAX_COLUMNS} columns (XFD).` + ); + } + } + return { + c: columnNumber - 1, + r: rowNumber - 1, + }; +} + +function assertZeroBasedCellCoordinates(row, column, context) { + try { + assertCellCoordinates(row, column, context); + } catch (_error) { + throw new Error( + `[XLSX_CELL_REF_LIMIT] ${context} must use safe integer coordinates within rows 0-${EXCEL_MAX_ROWS - 1} and columns 0-${EXCEL_MAX_COLUMNS - 1}.` + ); + } +} + +function createCellParseBudget() { + return { parsed: 0, limit: MAX_XLSX_PARSED_CELLS }; +} + +function consumeCellParseBudget(budget) { + budget.parsed += 1; + if (!Number.isSafeInteger(budget.parsed) || budget.parsed > budget.limit) { + throw new Error( + `[XLSX_CELL_COUNT_LIMIT] Workbook contains more than ${budget.limit} serialized cell records.` + ); + } +} + +function formulaAttribute(attrs, name) { + return String(attrs || "").match( + new RegExp(`\\b${name}="([^"]*)"`, "i") + )?.[1] || null; +} + +function parseFormulaRangeRef(ref, formulaType, anchor) { + const normalized = String(ref || "").replaceAll("$", "").trim(); + if (!normalized) { + return { + type: formulaType, + ref: null, + r1: anchor.r, + c1: anchor.c, + r2: anchor.r, + c2: anchor.c, + }; + } + const parts = normalized.split(":"); + if (parts.length < 1 || parts.length > 2) { + throw new Error( + `[COMPLEX_FORMULA_REF_INVALID] Invalid ${formulaType} formula range: ${ref}` + ); + } + const start = parseCellRef(parts[0]); + const end = parseCellRef(parts[1] || parts[0]); + return { + type: formulaType, + ref: normalized, + r1: Math.min(start.r, end.r), + c1: Math.min(start.c, end.c), + r2: Math.max(start.r, end.r), + c2: Math.max(start.c, end.c), + }; +} + +function applyComplexFormulaEvidence(cells, ranges) { + let coveredCellCount = 0; + for (const range of ranges) { + coveredCellCount += cellCount(range.r1, range.c1, range.r2, range.c2); + if (coveredCellCount > MAX_COMPLEX_FORMULA_COVERAGE_CELLS) { + throw new Error( + `[COMPLEX_FORMULA_COVERAGE_LIMIT] Complex formula ranges cover more than ${MAX_COMPLEX_FORMULA_COVERAGE_CELLS} cells.` + ); + } + } + for (const range of ranges) { + for (let r = range.r1; r <= range.r2; r += 1) { + for (let c = range.c1; c <= range.c2; c += 1) { + const cell = cells[cellKey(r, c)]; + if (!cell) continue; + cell.formulaEvidence = true; + cell.formulaGroupType = range.type; + if (range.ref) cell.formulaGroupRef = range.ref; + } + } + } +} + +function parseWorksheet(xml, sharedStrings, budget = createCellParseBudget(), styles = []) { + const cells = {}; + const complexFormulaRanges = []; + const layout = parseWorksheetLayout(xml); + let maxR = 0; + let maxC = 0; + if (!xml) { + return { cells, rows: 50, cols: 26, complexFormulaRanges, layout }; + } + const text = String(xml); + const cellRe = /]*)>([\s\S]*?)<\/c>|]*)\/>/gi; + let match; + while ((match = cellRe.exec(text))) { + consumeCellParseBudget(budget); + const attrs = match[1] || match[3] || ""; + const body = match[2] || ""; + const refMatch = attrs.match(/\br="([^"]+)"/i); + if (!refMatch) { + throw new Error( + "[XLSX_CELL_REF_INVALID] Worksheet contains a cell record without an explicit r coordinate." + ); + } + const pos = parseCellRef(refMatch[1]); + const typeMatch = attrs.match(/\bt="([^"]+)"/i); + const cellType = typeMatch ? typeMatch[1] : null; + const styleIndex = Number(attrs.match(/\bs="([^"]+)"/i)?.[1]); + const formulaMatch = body.match(/]*?)(?:\/\s*>|>([\s\S]*?)<\/f>)/i); + const valueMatch = body.match(/]*>([\s\S]*?)<\/v>/i); + const isMatch = body.match(/]*>([\s\S]*?)<\/is>/i); + + const cell = {}; + if (formulaMatch) { + const formulaAttrs = formulaMatch[1] || ""; + const formulaText = decodeXmlEntities(formulaMatch[2] || ""); + const formulaType = String(formulaAttribute(formulaAttrs, "t") || "normal").toLowerCase(); + const formulaRef = formulaAttribute(formulaAttrs, "ref"); + cell.formulaEvidence = true; + if (formulaText) cell.f = formulaText; + if (formulaType !== "normal") cell.formulaType = formulaType; + if (formulaRef) cell.formulaRef = formulaRef; + if (["shared", "array", "datatable"].includes(formulaType)) { + const range = parseFormulaRangeRef(formulaRef, formulaType, pos); + // Shared followers normally omit ref; the shared master contributes + // the authoritative group range. Keep a single-cell fallback only so + // an isolated complex formula is still protected. + if (formulaRef || formulaType !== "shared") { + complexFormulaRanges.push(range); + } + } + } + if (isMatch) { + const parts = []; + const textRe = /]*>([\s\S]*?)<\/t>/gi; + let textMatch; + while ((textMatch = textRe.exec(isMatch[1]))) { + parts.push(decodeXmlEntities(textMatch[1])); + } + cell.v = parts.join(""); + cell.t = "s"; + } else if (valueMatch) { + const raw = decodeXmlEntities(valueMatch[1]); + if (cellType === "s") { + const idx = Number(raw); + cell.v = Number.isFinite(idx) && sharedStrings[idx] != null + ? sharedStrings[idx] + : raw; + cell.t = "s"; + } else if (cellType === "b") { + cell.v = raw === "1" || raw === "true"; + cell.t = "b"; + } else if (cellType === "str" || cellType === "inlineStr") { + cell.v = raw; + cell.t = "s"; + } else if (raw !== "" && !Number.isNaN(Number(raw)) && /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(raw)) { + cell.v = Number(raw); + cell.t = "n"; + } else { + cell.v = raw; + cell.t = cellType || "s"; + } + } else if (cell.formulaEvidence) { + cell.t = "f"; + } else if (!Number.isSafeInteger(styleIndex)) { + continue; + } + + if (Number.isSafeInteger(styleIndex) && styleIndex >= 0) { + cell._sourceStyleIndex = styleIndex; + if (styles[styleIndex]) cell.style = JSON.parse(JSON.stringify(styles[styleIndex])); + } + + cells[cellKey(pos.r, pos.c)] = cell; + if (pos.r > maxR) maxR = pos.r; + if (pos.c > maxC) maxC = pos.c; + } + + applyComplexFormulaEvidence(cells, complexFormulaRanges); + + return { + cells, + rows: Math.max(50, maxR + 1), + cols: Math.max(26, maxC + 1), + complexFormulaRanges, + layout, + }; +} + +function listSheetXmlPaths(files) { + const paths = []; + for (const name of files.keys()) { + const m = name.match(/^xl\/worksheets\/sheet(\d+)\.xml$/i); + if (m) { + paths.push({ name, index: Number(m[1]) }); + } + } + paths.sort((a, b) => a.index - b.index); + return paths.map((p) => p.name); +} + +function workbookSheetDescriptors(files) { + const workbookXml = (files.get("xl/workbook.xml") || Buffer.alloc(0)).toString("utf8"); + const relsXml = (files.get("xl/_rels/workbook.xml.rels") || Buffer.alloc(0)).toString("utf8"); + const refs = parseWorkbookSheetRefs(workbookXml); + const relationships = parseRelationships(relsXml); + const descriptors = refs + .map((ref) => { + const relation = relationships.get(ref.relId); + if (!relation || (relation.type && !relation.type.toLowerCase().endsWith("/worksheet"))) { + return null; + } + return { name: ref.name, path: resolveWorkbookTarget(relation.target) }; + }) + .filter(Boolean) + .filter((sheet) => files.has(sheet.path)); + if (descriptors.length > 0) return descriptors; + const names = parseSheetNames(workbookXml); + return listSheetXmlPaths(files).map((sheetPath, index) => ({ + name: names[index] || `Sheet${index + 1}`, + path: sheetPath, + })); +} + +function calculationStatusWithFormulaEvidence(sheets) { + let formulaCount = 0; + for (const sheet of sheets || []) { + for (const cell of Object.values(sheet.cells || {})) { + if (cell && (cell.formulaEvidence === true || (typeof cell.f === "string" && cell.f))) { + formulaCount += 1; + } + } + } + if (formulaCount === 0) return defaultCalculationStatus(sheets); + return { + engine: "none", + status: "cached", + formulaCount, + lastCalculatedRevision: null, + warning: "Formula results are cached values; this engine does not recalculate formulas.", + }; +} + +function assertWorkbookShapeLimits(workbook) { + let serializedCellCount = 0; + const sheetNames = new Set(); + for (const [index, sheet] of (workbook?.sheets || []).entries()) { + const sheetName = validateExcelSheetName(sheet.name || `Sheet${index + 1}`); + const sheetKey = sheetName.toLowerCase(); + if (sheetNames.has(sheetKey)) throw new Error(`[DUPLICATE_SHEET_NAME] Worksheet name already exists: ${sheetName}`); + sheetNames.add(sheetKey); + assertSheetDimensions(Number(sheet.rows), Number(sheet.cols), `Worksheet ${index + 1}`); + for (const key of Object.keys(sheet.cells || {})) { + serializedCellCount += 1; + if (serializedCellCount > MAX_XLSX_PARSED_CELLS) { + throw new Error( + `[XLSX_CELL_COUNT_LIMIT] Workbook contains more than ${MAX_XLSX_PARSED_CELLS} cell records.` + ); + } + const parts = key.split(","); + if (parts.length !== 2) { + throw new Error(`[XLSX_CELL_REF_INVALID] Invalid workbook cell key: ${key}`); + } + assertZeroBasedCellCoordinates( + Number(parts[0]), + Number(parts[1]), + `Workbook cell key ${JSON.stringify(key)}` + ); + } + } + return workbook; +} + +function workbookFromSheets(sheets, options = {}) { + const createdAt = nowIso(); + const normalized = sheets.map((sheet, index) => { + const rows = sheet.rows || 50; + const cols = sheet.cols || 26; + assertSheetDimensions(rows, cols, `Worksheet ${index + 1}`); + return { + id: sheet.id || newId("sheet"), + name: sheet.name || `Sheet${index + 1}`, + rows, + cols, + cells: sheet.cells || {}, + layout: sheet.layout || defaultSheetLayout(), + complexFormulaRanges: Array.isArray(sheet.complexFormulaRanges) + ? sheet.complexFormulaRanges + : [], + }; + }); + if (normalized.length === 0) { + normalized.push(createEmptySheet("Sheet1")); + } + const sourcePath = options.path || null; + const sourceFormat = formatFromPath(sourcePath); + return assertWorkbookShapeLimits({ + schemaVersion: WORKBOOK_SCHEMA_VERSION, + workbookId: options.workbookId || newId("wb"), + path: sourcePath, + sourcePath, + sourceFormat, + sourceFingerprint: options.sourceFingerprint || null, + lastExportPath: null, + lastExportedRevision: null, + title: options.title || path.basename(options.path || "Workbook", path.extname(options.path || "")) || "Workbook", + sheets: normalized, + activeSheetId: normalized[0].id, + dirty: false, + revision: 0, + mode: "edit", + focus: { + sheetId: normalized[0].id, + a1: "A1", + kind: "cell", + }, + proposal: null, + fidelity: defaultFidelity(sourceFormat), + calculationStatus: calculationStatusWithFormulaEvidence(normalized), + history: [], + undoStack: [], + redoStack: [], + createdAt, + updatedAt: createdAt, + }); +} + +function readCsvFile(filePath, options = {}) { + const text = readStableFile(filePath, { + maxBytes: MAX_CSV_FILE_BYTES, + sizeLimitCode: "CSV_FILE_SIZE_LIMIT", + fileKind: "CSV source file", + }).buffer.toString("utf8"); + const rows = parseCsv(text, { + maxRows: EXCEL_MAX_ROWS, + maxColumns: EXCEL_MAX_COLUMNS, + maxCells: MAX_CSV_PARSED_CELLS, + }); + const cells = {}; + let maxCols = 26; + for (let r = 0; r < rows.length; r += 1) { + const row = rows[r] || []; + if (row.length > maxCols) maxCols = row.length; + for (let c = 0; c < row.length; c += 1) { + const value = row[c]; + if (value === "" || value == null) continue; + const num = Number(value); + if (value !== "" && !Number.isNaN(num) && /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(String(value).trim())) { + cells[cellKey(r, c)] = { v: num, t: "n" }; + } else { + cells[cellKey(r, c)] = { v: value, t: "s" }; + } + } + } + const sheet = { + id: newId("sheet"), + name: "Sheet1", + rows: Math.max(50, rows.length), + cols: Math.max(26, maxCols), + cells, + }; + return workbookFromSheets([sheet], { + path: filePath, + title: options.title || path.basename(filePath, path.extname(filePath)), + workbookId: options.workbookId, + }); +} + +function readJsonFile(filePath, options = {}) { + const text = readStableFile(filePath, { + maxBytes: MAX_JSON_FILE_BYTES, + sizeLimitCode: "JSON_FILE_SIZE_LIMIT", + fileKind: "JSON source file", + }).buffer.toString("utf8"); + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`Failed to parse workbook JSON: ${error.message}`); + } + if (parsed && Array.isArray(parsed.sheets)) { + const wb = createEmpty({ + workbookId: options.workbookId || parsed.workbookId, + title: options.title || parsed.title, + path: filePath, + }); + const imported = normalizeWorkbook({ + ...wb, + ...parsed, + path: filePath, + sourcePath: filePath, + sourceFormat: "json", + workbookId: options.workbookId || parsed.workbookId || wb.workbookId, + dirty: false, + proposal: parsed.proposal || null, + }); + imported.fidelity = defaultFidelity("json"); + return assertWorkbookShapeLimits(imported); + } + throw new Error("JSON file is not a Sparo workbook store"); +} + +function readXlsxFile(filePath, options = {}) { + let snapshot; + try { + snapshot = readStableFile(filePath); + } catch (error) { + throw new Error(`Failed to read xlsx file: ${error.message}`); + } + const buffer = snapshot.buffer; + + let files; + try { + files = unzipBuffer(buffer); + } catch (error) { + throw new Error(`Failed to parse xlsx as ZIP: ${error.message}`); + } + + const sharedStrings = parseSharedStrings( + (files.get("xl/sharedStrings.xml") || Buffer.alloc(0)).toString("utf8") + ); + const styles = parseStyles( + (files.get("xl/styles.xml") || Buffer.alloc(0)).toString("utf8") + ); + const sheetDescriptors = workbookSheetDescriptors(files); + if (sheetDescriptors.length === 0) { + throw new Error("xlsx package has no worksheets under xl/worksheets/"); + } + + const cellBudget = createCellParseBudget(); + const sheets = sheetDescriptors.map((descriptor, index) => { + const xml = files.get(descriptor.path).toString("utf8"); + const parsed = parseWorksheet(xml, sharedStrings, cellBudget, styles); + return { + id: newId("sheet"), + name: descriptor.name || `Sheet${index + 1}`, + rows: parsed.rows, + cols: parsed.cols, + cells: parsed.cells, + complexFormulaRanges: parsed.complexFormulaRanges, + layout: parsed.layout, + }; + }); + + const workbook = workbookFromSheets(sheets, { + path: filePath, + title: options.title || path.basename(filePath, path.extname(filePath)), + workbookId: options.workbookId, + sourceFingerprint: snapshot.fingerprint, + }); + if ([...files.keys()].some((name) => name.toLowerCase().startsWith("_xmlsignatures/"))) { + workbook.fidelity.canRoundTrip = false; + workbook.fidelity.level = "limited"; + workbook.fidelity.warning = + "This workbook has an OOXML package signature that would be invalidated by editing."; + } + return workbook; +} + +function readWorkbookFile(filePath, options = {}) { + if (!filePath) { + throw new Error("File path is required"); + } + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".csv") { + return readCsvFile(filePath, options); + } + if (ext === ".json") { + return readJsonFile(filePath, options); + } + if (ext === ".xlsx" || ext === ".xlsm") { + try { + return readXlsxFile(filePath, options); + } catch (error) { + throw new Error( + `Failed to open xlsx workbook at ${filePath}: ${error.message}. ` + + "Only simple OOXML sheets (values, shared strings, basic formulas) are supported." + ); + } + } + throw new Error(`Unsupported workbook file type: ${ext || "(none)"}`); +} + +function inferCellType(cell) { + if (!cell) return null; + if (cell.f) return "f"; + if (cell.t) return cell.t; + if (typeof cell.v === "number") return "n"; + if (typeof cell.v === "boolean") return "b"; + return "s"; +} + +function buildSharedStrings(workbook) { + const list = []; + const index = new Map(); + function add(str) { + const key = String(str); + if (index.has(key)) return index.get(key); + const i = list.length; + list.push(key); + index.set(key, i); + return i; + } + for (const sheet of workbook.sheets) { + for (const cell of Object.values(sheet.cells || {})) { + if (!cell) continue; + if (cell.f) continue; + const t = inferCellType(cell); + if (t === "s" || t === "str") { + add(cell.v == null ? "" : String(cell.v)); + } + } + } + return { list, index }; +} + +function visualStyle(style) { + if (!style || typeof style !== "object") return null; + const { role: _role, ...visual } = style; + return Object.keys(visual).length ? visual : null; +} + +function colorToArgb(color) { + const value = String(color || "").replace(/^#/, "").toUpperCase(); + if (!/^[0-9A-F]{6}$/.test(value)) throw new Error(`[INVALID_STYLE_COLOR] Expected #RRGGBB, received ${JSON.stringify(color)}.`); + return `FF${value}`; +} + +function createStyleTable(workbook) { + const fonts = [null]; + const fills = [null, { gray125: true }]; + const borders = [null]; + const numberFormats = []; + const fontIds = new Map([["null", 0]]); + const fillIds = new Map([["null", 0]]); + const borderIds = new Map([["null", 0]]); + const numberFormatIds = new Map(); + const xfs = [{ fontId: 0, fillId: 0, borderId: 0, numFmtId: 0, alignment: null }]; + const xfIds = new Map([["null", 0]]); + + const componentId = (value, values, ids) => { + const key = JSON.stringify(value || null); + if (ids.has(key)) return ids.get(key); + const id = values.length; + values.push(value); + ids.set(key, id); + return id; + }; + const numberFormatId = (code) => { + if (!code) return 0; + const key = String(code); + if (numberFormatIds.has(key)) return numberFormatIds.get(key); + const id = 164 + numberFormats.length; + numberFormats.push({ id, code: key }); + numberFormatIds.set(key, id); + return id; + }; + const register = (style) => { + const visual = visualStyle(style); + const key = JSON.stringify(visual || null); + if (xfIds.has(key)) return xfIds.get(key); + if (xfs.length >= MAX_AUTHORED_CELL_STYLES) { + throw new Error(`[XLSX_STYLE_LIMIT] Workbook exceeds ${MAX_AUTHORED_CELL_STYLES} distinct authored cell styles.`); + } + const xf = { + fontId: componentId(visual?.font, fonts, fontIds), + fillId: componentId(visual?.fill, fills, fillIds), + borderId: componentId(visual?.border, borders, borderIds), + numFmtId: numberFormatId(visual?.numberFormat), + alignment: visual?.alignment || null, + }; + const id = xfs.length; + xfs.push(xf); + xfIds.set(key, id); + return id; + }; + for (const sheet of workbook.sheets || []) { + for (const cell of Object.values(sheet.cells || {})) if (cell?.style) register(cell.style); + } + + const fontXml = fonts.map((font) => { + if (!font) return ''; + return `${font.bold ? "" : ""}${font.italic ? "" : ""}` + + `` + + `${font.color ? `` : ""}`; + }).join(""); + const fillXml = fills.map((fill) => { + if (!fill) return ''; + if (fill.gray125) return ''; + return ``; + }).join(""); + const borderXml = borders.map((border) => { + const sideXml = (side) => { + const value = border?.[side]; + if (!value) return `<${side}/>`; + return `<${side} style="${escapeXml(value.style)}">${value.color ? `` : ""}`; + }; + return `${sideXml("left")}${sideXml("right")}${sideXml("top")}${sideXml("bottom")}`; + }).join(""); + const xfXml = xfs.map((xf) => { + const apply = `${xf.numFmtId ? ' applyNumberFormat="1"' : ""}${xf.fontId ? ' applyFont="1"' : ""}${xf.fillId ? ' applyFill="1"' : ""}${xf.borderId ? ' applyBorder="1"' : ""}${xf.alignment ? ' applyAlignment="1"' : ""}`; + const alignment = xf.alignment + ? `` + : ""; + return `${alignment}`; + }).join(""); + const numFmtXml = numberFormats.length + ? `${numberFormats.map((item) => ``).join("")}` + : ""; + const xml = `` + + `${numFmtXml}` + + `${fontXml}${fillXml}` + + `${borderXml}` + + `` + + `${xfXml}` + + ``; + return { xml, indexFor: register, count: xfs.length }; +} + +function layoutXml(sheet) { + const layout = sheet.layout || {}; + const freezeRows = Number(layout.freezePanes?.rows || 0); + const freezeColumns = Number(layout.freezePanes?.columns || 0); + const activePane = freezeRows && freezeColumns + ? "bottomRight" + : freezeRows + ? "bottomLeft" + : "topRight"; + const sheetViews = freezeRows || freezeColumns + ? `` + : ''; + const columns = (layout.columns || []).map((band) => { + const width = band.width == null ? 18 : Number(band.width); + return ``; + }).join(""); + const cols = columns ? `${columns}` : ""; + const autoFilter = layout.autoFilter?.a1 ? `` : ""; + return { sheetViews, cols, autoFilter }; +} + +function sheetToXml(sheet, shared, styleTable) { + const rowsMap = new Map(); + for (const [key, cell] of Object.entries(sheet.cells || {})) { + const [rs, cs] = key.split(","); + const r = Number(rs); + const c = Number(cs); + assertZeroBasedCellCoordinates(r, c, `Workbook cell key ${JSON.stringify(key)}`); + if (!rowsMap.has(r)) rowsMap.set(r, []); + rowsMap.get(r).push({ c, cell }); + } + + let materializedLayoutRows = 0; + for (const band of sheet.layout?.rows || []) { + materializedLayoutRows += band.end - band.start + 1; + if (materializedLayoutRows > 20_000) { + throw new Error("[LAYOUT_ROW_LIMIT] XLSX export cannot materialize more than 20000 custom-height rows."); + } + for (let row = band.start; row <= band.end; row += 1) if (!rowsMap.has(row)) rowsMap.set(row, []); + } + + const rowNums = [...rowsMap.keys()].sort((a, b) => a - b); + const rowXml = []; + for (const r of rowNums) { + const cells = rowsMap.get(r).sort((a, b) => a.c - b.c); + const cellXml = cells.map(({ c, cell }) => { + const ref = `${indexToCol(c)}${r + 1}`; + const styleId = cell.style ? styleTable.indexFor(cell.style) : 0; + const styleAttr = styleId ? ` s="${styleId}"` : ""; + if (cell.f) { + const f = escapeXml(cell.f); + const v = + cell.v != null && cell.v !== "" + ? `${escapeXml(String(cell.v))}` + : ""; + return `${f}${v}`; + } + const t = inferCellType(cell); + if (t === "n") { + return `${escapeXml(String(cell.v))}`; + } + if (t === "b") { + return `${cell.v ? 1 : 0}`; + } + if (cell.v == null && styleId) return ``; + const text = cell.v == null ? "" : String(cell.v); + const idx = shared.index.has(text) + ? shared.index.get(text) + : (() => { + const i = shared.list.length; + shared.list.push(text); + shared.index.set(text, i); + return i; + })(); + return `${idx}`; + }); + const rowBand = (sheet.layout?.rows || []).find((band) => r >= band.start && r <= band.end); + const rowAttrs = rowBand + ? `${rowBand.height != null || rowBand.autoFit ? ` ht="${Number(rowBand.height || 24)}" customHeight="1"` : ""}${rowBand.autoFit ? ' bestFit="1"' : ""}` + : ""; + rowXml.push(`${cellXml.join("")}`); + } + + const declaredRows = Number(sheet.rows || 1); + const declaredColumns = Number(sheet.cols || 1); + assertZeroBasedCellCoordinates( + declaredRows - 1, + declaredColumns - 1, + `Worksheet dimensions ${JSON.stringify({ rows: sheet.rows, cols: sheet.cols })}` + ); + const dim = `A1:${indexToCol(declaredColumns - 1)}${declaredRows}`; + const layout = layoutXml(sheet); + return ( + `` + + `` + + `` + + layout.sheetViews + + layout.cols + + `${rowXml.join("")}` + + layout.autoFilter + + `` + ); +} + +function sourceCellsEqual(left, right) { + const a = left || null; + const b = right || null; + if (a === b) return true; + if (!a || !b) return false; + return ( + a.f === b.f && + a.v === b.v && + Boolean(a.formulaEvidence) === Boolean(b.formulaEvidence) && + (a.formulaType || null) === (b.formulaType || null) && + (a.formulaRef || null) === (b.formulaRef || null) && + (a.formulaGroupType || null) === (b.formulaGroupType || null) && + (a.formulaGroupRef || null) === (b.formulaGroupRef || null) && + JSON.stringify(a.style || null) === JSON.stringify(b.style || null) + ); +} + +function withoutXmlAttribute(attrs, name) { + return String(attrs || "").replace( + new RegExp(`\\s\\b${name}="[^"]*"`, "gi"), + "" + ); +} + +function cellAttributes(existingAttrs, ref, type) { + let rest = withoutXmlAttribute(withoutXmlAttribute(existingAttrs, "r"), "t").trim(); + const parts = [`r="${ref}"`]; + if (rest) parts.push(rest); + if (type) parts.push(`t="${type}"`); + return ` ${parts.join(" ")}`; +} + +function patchedCellXml(cell, ref, existingAttrs = "") { + if (!cell) { + const rest = withoutXmlAttribute(withoutXmlAttribute(existingAttrs, "r"), "t").trim(); + return rest ? `` : null; + } + if (cell.f) { + let resultType = null; + if (typeof cell.v === "string") resultType = "str"; + if (typeof cell.v === "boolean") resultType = "b"; + const attrs = cellAttributes(existingAttrs, ref, resultType); + const value = cell.v == null || cell.v === "" + ? "" + : `${escapeXml(typeof cell.v === "boolean" ? (cell.v ? "1" : "0") : String(cell.v))}`; + return `${escapeXml(cell.f)}${value}`; + } + const type = inferCellType(cell); + if (type === "n") { + return `${escapeXml(String(cell.v))}`; + } + if (type === "b") { + return `${cell.v ? 1 : 0}`; + } + const text = cell.v == null ? "" : String(cell.v); + const preserve = /^\s|\s$/.test(text) ? ' xml:space="preserve"' : ""; + return `${escapeXml(text)}`; +} + +function parseRowCellTokens(body) { + const tokens = []; + const re = /]*)>([\s\S]*?)<\/c>|]*)\/>/gi; + let match; + while ((match = re.exec(String(body || "")))) { + const attrs = match[1] || match[3] || ""; + const ref = attrs.match(/\br="([^"]+)"/i)?.[1]; + let col = null; + if (ref) col = parseCellRef(ref).c; + tokens.push({ + xml: match[0], + attrs, + ref: ref || null, + col, + start: match.index, + end: re.lastIndex, + }); + } + return tokens; +} + +function patchRowBody(body, changes, row) { + const source = String(body || ""); + const tokens = parseRowCellTokens(source); + const existingColumns = new Set( + tokens.filter((token) => token.col != null).map((token) => token.col) + ); + const pending = [...changes.entries()] + .filter(([col]) => !existingColumns.has(col)) + .sort((a, b) => a[0] - b[0]); + const handled = new Set(); + let pendingIndex = 0; + let cursor = 0; + let output = ""; + + const appendPendingBefore = (columnLimit = Infinity) => { + while (pendingIndex < pending.length && pending[pendingIndex][0] < columnLimit) { + const [col, cell] = pending[pendingIndex]; + const ref = `${indexToCol(col)}${row + 1}`; + const nextXml = patchedCellXml(cell, ref, ""); + if (nextXml) output += nextXml; + handled.add(col); + pendingIndex += 1; + } + }; + + for (const token of tokens) { + output += source.slice(cursor, token.start); + if (token.col != null) appendPendingBefore(token.col); + if (token.col != null && changes.has(token.col)) { + const ref = `${indexToCol(token.col)}${row + 1}`; + const nextXml = patchedCellXml(changes.get(token.col), ref, token.attrs); + if (nextXml) output += nextXml; + handled.add(token.col); + } else { + output += token.xml; + } + cursor = token.end; + } + appendPendingBefore(); + output += source.slice(cursor); + + for (const col of changes.keys()) { + if (!handled.has(col)) { + throw new Error(`Failed to patch ${indexToCol(col)}${row + 1} in worksheet row XML.`); + } + } + return output; +} + +function rowAttributeText(attrs, rowNumber) { + const rest = withoutXmlAttribute(attrs, "r").trim(); + return rest ? ` r="${rowNumber}" ${rest}` : ` r="${rowNumber}"`; +} + +function expandWorksheetDimension(xml, sheet) { + let usedR = 0; + let usedC = 0; + for (const key of Object.keys(sheet.cells || {})) { + const [r, c] = key.split(",").map(Number); + assertZeroBasedCellCoordinates(r, c, `Workbook cell key ${JSON.stringify(key)}`); + usedR = Math.max(usedR, r); + usedC = Math.max(usedC, c); + } + const dimensionMatch = String(xml).match(/]*)\bref="([^"]+)"([^>]*)\/>/i); + if (!dimensionMatch) return xml; + const endRef = dimensionMatch[2].split(":").pop(); + const end = parseCellRef(endRef); + if (end) { + usedR = Math.max(usedR, end.r); + usedC = Math.max(usedC, end.c); + } + const ref = `A1:${indexToCol(usedC)}${usedR + 1}`; + return String(xml).replace(dimensionMatch[0], ``); +} + +function patchWorksheetXml(xml, sourceSheet, currentSheet) { + const sourceCells = sourceSheet.cells || {}; + const currentCells = currentSheet.cells || {}; + const changedByRow = new Map(); + const keys = new Set([...Object.keys(sourceCells), ...Object.keys(currentCells)]); + let patchedCellCount = 0; + for (const key of keys) { + if (JSON.stringify(sourceCells[key]?.style || null) !== JSON.stringify(currentCells[key]?.style || null)) { + const [r, c] = key.split(",").map(Number); + throw new Error( + `[SOURCE_STYLE_PATCH_UNSAFE] ${indexToCol(c)}${r + 1} has a new or changed style. The source-preserving patcher will not silently discard it; use an acknowledged Save As rebuild.` + ); + } + if (sourceCellsEqual(sourceCells[key], currentCells[key])) continue; + const [r, c] = key.split(",").map(Number); + assertZeroBasedCellCoordinates(r, c, `Workbook cell key ${JSON.stringify(key)}`); + const complexRange = (sourceSheet.complexFormulaRanges || []).find( + (range) => r >= range.r1 && r <= range.r2 && c >= range.c1 && c <= range.c2 + ); + if (complexRange) { + const ref = `${indexToCol(c)}${r + 1}`; + throw new Error( + `[COMPLEX_FORMULA_PATCH_UNSAFE] ${ref} intersects ${complexRange.type} formula range ${complexRange.ref || ref} and cannot be patched independently.` + ); + } + if (!changedByRow.has(r)) changedByRow.set(r, new Map()); + changedByRow.get(r).set(c, currentCells[key] || null); + patchedCellCount += 1; + } + if (patchedCellCount === 0) return { xml, patchedCellCount: 0 }; + + const sheetDataMatch = String(xml).match(/]*)>([\s\S]*?)<\/sheetData>|]*)\/>/i); + if (!sheetDataMatch) throw new Error("Worksheet is missing sheetData"); + const sheetDataAttrs = sheetDataMatch[1] || sheetDataMatch[3] || ""; + const body = sheetDataMatch[2] || ""; + const rows = new Map(); + const rowRe = /]*?)(?:>([\s\S]*?)<\/row>|\/\s*>)/gi; + let rowMatch; + while ((rowMatch = rowRe.exec(body))) { + const attrs = rowMatch[1] || ""; + const rowNumber = Number(attrs.match(/\br="([^"]+)"/i)?.[1]); + if (!Number.isInteger(rowNumber) || rowNumber < 1) continue; + rows.set(rowNumber - 1, { + attrs, + body: rowMatch[2] || "", + original: rowMatch[0], + }); + } + + for (const [r, changes] of changedByRow) { + const record = rows.get(r) || { attrs: ` r="${r + 1}"`, body: "", original: null }; + for (const token of parseRowCellTokens(record.body)) { + if ( + token.col != null && + changes.has(token.col) && + /]*\bt="(?:shared|array|dataTable)"/i.test(token.xml) + ) { + const ref = `${indexToCol(token.col)}${r + 1}`; + throw new Error( + `[COMPLEX_FORMULA_PATCH_UNSAFE] ${ref} belongs to a shared, array, or data-table formula and cannot be patched safely.` + ); + } + } + const patchedBody = patchRowBody(record.body, changes, r); + rows.set(r, { + ...record, + original: `${patchedBody}`, + }); + } + + const rowsXml = [...rows.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, row]) => row.original) + .join(""); + const replacement = `${rowsXml}`; + const patched = String(xml).replace(sheetDataMatch[0], replacement); + return { xml: expandWorksheetDimension(patched, currentSheet), patchedCellCount }; +} + +function setFullCalculationOnLoad(workbookXml) { + const calcMatch = String(workbookXml).match(/]*?)(?:\/\s*>|>[\s\S]*?<\/calcPr>)/i); + if (calcMatch) { + let attrs = calcMatch[1] || ""; + for (const name of ["fullCalcOnLoad", "forceFullCalc", "calcMode"]) { + attrs = withoutXmlAttribute(attrs, name); + } + return String(workbookXml).replace( + calcMatch[0], + `` + ); + } + return String(workbookXml).replace( + /<\/workbook>\s*$/i, + '' + ); +} + +function removeCalcChain(files) { + for (const name of [...files.keys()]) { + if (name.toLowerCase() === "xl/calcchain.xml") files.delete(name); + } + const relsPath = "xl/_rels/workbook.xml.rels"; + if (files.has(relsPath)) { + const rels = files.get(relsPath).toString("utf8").replace( + /]*(?:calcChain|calcchain))[^>]*\/>/gi, + "" + ); + files.set(relsPath, Buffer.from(rels, "utf8")); + } + const contentTypesPath = "[Content_Types].xml"; + if (files.has(contentTypesPath)) { + const types = files.get(contentTypesPath).toString("utf8").replace( + /]*(?:calcChain|calcchain))[^>]*\/>/gi, + "" + ); + files.set(contentTypesPath, Buffer.from(types, "utf8")); + } +} + +function writeSourcePreservingWorkbook(workbook, filePath, options = {}) { + assertWorkbookShapeLimits(workbook); + const sourcePath = workbook.sourcePath; + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error("[SOURCE_PACKAGE_MISSING] The imported Excel source package is unavailable."); + } + if (!workbook.sourceFingerprint) { + throw new Error( + "[SOURCE_FINGERPRINT_MISSING] This live workbook predates source fingerprinting. Reopen it explicitly before source-preserving export." + ); + } + if (!workbook.fidelity?.canRoundTrip || workbook.fidelity?.structureChanged) { + throw new Error( + "[SOURCE_PATCH_UNSAFE] Workbook structure changed; a source-preserving cell patch is no longer safe." + ); + } + const sourceExt = path.extname(sourcePath).toLowerCase(); + const targetExt = path.extname(filePath).toLowerCase(); + if (sourceExt !== targetExt) { + throw new Error( + `[SOURCE_FORMAT_MISMATCH] Source-preserving export requires ${sourceExt}; requested ${targetExt}.` + ); + } + assertDistinctSourceTarget(sourcePath, filePath); + const sourceSnapshot = readStableFile(sourcePath); + if (!fingerprintsEqual(workbook.sourceFingerprint, sourceSnapshot.fingerprint)) { + throw new Error( + `[SOURCE_CHANGED_EXTERNALLY] ${sourcePath} changed after it was opened (size, mtime, or SHA-256 differs). Reopen/reload the workbook at the current revision before exporting; the stale live model will not be applied.` + ); + } + const files = unzipBuffer(sourceSnapshot.buffer); + if ([...files.keys()].some((name) => name.toLowerCase().startsWith("_xmlsignatures/"))) { + throw new Error( + "[SIGNED_PACKAGE_PATCH_UNSAFE] The workbook has an OOXML package signature that would be invalidated by editing." + ); + } + const descriptors = workbookSheetDescriptors(files); + if (descriptors.length !== workbook.sheets.length) { + throw new Error("[SOURCE_PATCH_UNSAFE] Sheet count differs from the imported source."); + } + for (let index = 0; index < descriptors.length; index += 1) { + if (descriptors[index].name !== workbook.sheets[index].name) { + throw new Error( + `[SOURCE_PATCH_UNSAFE] Sheet ${index + 1} changed from ${descriptors[index].name} to ${workbook.sheets[index].name}.` + ); + } + } + const sharedStrings = parseSharedStrings( + (files.get("xl/sharedStrings.xml") || Buffer.alloc(0)).toString("utf8") + ); + const styles = parseStyles( + (files.get("xl/styles.xml") || Buffer.alloc(0)).toString("utf8") + ); + const cellBudget = createCellParseBudget(); + let patchedCellCount = 0; + for (let index = 0; index < descriptors.length; index += 1) { + const descriptor = descriptors[index]; + const xml = files.get(descriptor.path).toString("utf8"); + const sourceSheet = parseWorksheet(xml, sharedStrings, cellBudget, styles); + if (JSON.stringify(sourceSheet.layout || null) !== JSON.stringify(workbook.sheets[index].layout || null)) { + throw new Error( + `[SOURCE_LAYOUT_PATCH_UNSAFE] Layout changed on ${descriptors[index].name}. The source-preserving patcher will not silently discard it; use an acknowledged Save As rebuild.` + ); + } + const patched = patchWorksheetXml(xml, sourceSheet, workbook.sheets[index]); + patchedCellCount += patched.patchedCellCount; + files.set(descriptor.path, Buffer.from(patched.xml, "utf8")); + } + const workbookPath = "xl/workbook.xml"; + files.set( + workbookPath, + Buffer.from(setFullCalculationOnLoad(files.get(workbookPath).toString("utf8")), "utf8") + ); + removeCalcChain(files); + const buffer = zipStore([...files.entries()].map(([name, data]) => ({ name, data }))); + const finalSourceFingerprint = readStableFile(sourcePath).fingerprint; + if (!fingerprintsEqual(sourceSnapshot.fingerprint, finalSourceFingerprint)) { + throw new Error( + `[SOURCE_CHANGED_EXTERNALLY] ${sourcePath} changed during export preparation. Reopen/reload and retry.` + ); + } + const resolvedTargetPath = assertDistinctSourceTarget(sourcePath, filePath); + writeFileAtomic(resolvedTargetPath, buffer, undefined, options.atomicWriteOptions || {}); + const outputFingerprint = readStableFile(resolvedTargetPath).fingerprint; + return { + path: filePath, + format: targetExt.slice(1), + mode: "source-preserving-cell-patch", + sourcePath, + patchedCellCount, + preservedPackagePartCount: files.size, + fullCalcOnLoad: true, + sourceFingerprintVerified: true, + outputFingerprint, + }; +} + +function writeXlsxBuffer(workbook) { + assertWorkbookShapeLimits(workbook); + const shared = buildSharedStrings(workbook); + const styleTable = createStyleTable(workbook); + const sheets = workbook.sheets || []; + const sheetEntries = sheets.map((sheet, i) => ({ + name: `xl/worksheets/sheet${i + 1}.xml`, + data: sheetToXml(sheet, shared, styleTable), + })); + + const sharedXml = + `` + + `` + + shared.list.map((s) => `${escapeXml(s)}`).join("") + + ``; + + const workbookXml = + `` + + `` + + `` + + sheets + .map( + (sheet, i) => + `` + ) + .join("") + + `` + + `` + + ``; + + const workbookRels = + `` + + `` + + sheets + .map( + (_, i) => + `` + ) + .join("") + + `` + + `` + + ``; + + const rootRels = + `` + + `` + + `` + + ``; + + const contentTypes = + `` + + `` + + `` + + `` + + `` + + sheets + .map( + (_, i) => + `` + ) + .join("") + + `` + + `` + + ``; + + const entries = [ + { name: "[Content_Types].xml", data: contentTypes }, + { name: "_rels/.rels", data: rootRels }, + { name: "xl/workbook.xml", data: workbookXml }, + { name: "xl/_rels/workbook.xml.rels", data: workbookRels }, + { name: "xl/sharedStrings.xml", data: sharedXml }, + { name: "xl/styles.xml", data: styleTable.xml }, + ...sheetEntries, + ]; + + return zipStore(entries); +} + +function sheetToMatrix(sheet) { + assertSheetDimensions(Number(sheet?.rows), Number(sheet?.cols), "CSV worksheet"); + const rows = []; + const maxR = Math.max(0, (sheet.rows || 1) - 1); + const maxC = Math.max(0, (sheet.cols || 1) - 1); + // Prefer used region + let usedR = 0; + let usedC = 0; + for (const key of Object.keys(sheet.cells || {})) { + const [r, c] = key.split(",").map(Number); + assertZeroBasedCellCoordinates(r, c, `Workbook cell key ${JSON.stringify(key)}`); + if (r > usedR) usedR = r; + if (c > usedC) usedC = c; + } + const rowCount = Math.min(maxR, usedR) + 1; + const colCount = Math.min(maxC, usedC) + 1; + const denseCellCount = rowCount * colCount; + if (!Number.isSafeInteger(denseCellCount) || denseCellCount > MAX_DENSE_EXPORT_CELLS) { + throw new Error( + `[DENSE_EXPORT_CELL_LIMIT] CSV export would materialize ${denseCellCount} cells; maximum is ${MAX_DENSE_EXPORT_CELLS}. Narrow the used range before exporting.` + ); + } + for (let r = 0; r < rowCount; r += 1) { + const row = []; + for (let c = 0; c < colCount; c += 1) { + const cell = sheet.cells[cellKey(r, c)]; + if (!cell) { + row.push(""); + } else if (cell.f) { + row.push(cell.v != null ? cell.v : `=${cell.f}`); + } else { + row.push(cell.v == null ? "" : cell.v); + } + } + rows.push(row); + } + return rows; +} + +function writeWorkbookFile(workbook, filePath, options = {}) { + if (!filePath) { + throw new Error("Output path is required"); + } + const ext = path.extname(filePath).toLowerCase(); + const dir = path.dirname(filePath); + if (dir) { + fs.mkdirSync(dir, { recursive: true }); + } + if (ext === ".csv") { + const sheet = workbook.sheets.find((s) => s.id === workbook.activeSheetId) || workbook.sheets[0]; + const csv = serializeCsv(sheetToMatrix(sheet)); + writeFileAtomic(filePath, csv, "utf8", options.atomicWriteOptions || {}); + return { path: filePath, format: "csv" }; + } + if (ext === ".json") { + writeFileAtomic( + filePath, + `${JSON.stringify(workbook, null, 2)}\n`, + "utf8", + options.atomicWriteOptions || {} + ); + return { path: filePath, format: "json" }; + } + if (ext === ".xlsx" || ext === ".xlsm") { + const sourceExt = path.extname(workbook.sourcePath || "").toLowerCase(); + const hasExcelSource = sourceExt === ".xlsx" || sourceExt === ".xlsm"; + if (hasExcelSource) { + try { + return writeSourcePreservingWorkbook(workbook, filePath, options); + } catch (error) { + if ( + /^\[(?:SOURCE_CHANGED_EXTERNALLY|SOURCE_CHANGED_DURING_READ|SOURCE_FINGERPRINT_MISSING|SOURCE_PACKAGE_MISSING|SOURCE_TARGET_ALIAS_BLOCKED|SOURCE_TARGET_IDENTITY_CHECK_FAILED|EXPORT_TARGET_CHANGED|EXPORT_TARGET_APPEARED)\]/.test( + String(error.message || "") + ) + ) { + throw error; + } + if (!options.allowLossyRebuild) throw error; + if (ext === ".xlsm") { + throw new Error( + `[LOSSY_XLSM_BLOCKED] Cannot rebuild ${filePath} without losing or invalidating VBA content. Save As .xlsx instead.` + ); + } + const buffer = writeXlsxBuffer(workbook); + writeFileAtomic(filePath, buffer, undefined, options.atomicWriteOptions || {}); + return { + path: filePath, + format: "xlsx", + mode: "lossy-rebuild", + warning: error.message, + fullCalcOnLoad: true, + }; + } + } + if (ext === ".xlsm") { + throw new Error("Creating a new macro-enabled .xlsm package is not supported."); + } + const buffer = writeXlsxBuffer(workbook); + writeFileAtomic(filePath, buffer, undefined, options.atomicWriteOptions || {}); + return { path: filePath, format: "xlsx", mode: "new-minimal-package" }; + } + throw new Error(`Unsupported export type: ${ext || "(none)"}`); +} + +function writeFileAtomic(filePath, data, encoding, options = {}) { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + const tempPath = path.join( + dir, + `.${base}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp` + ); + let handle = null; + try { + handle = fs.openSync(tempPath, "wx", 0o600); + if (encoding) { + fs.writeFileSync(handle, data, encoding); + } else { + fs.writeFileSync(handle, data); + } + fs.fsyncSync(handle); + fs.closeSync(handle); + handle = null; + if (options.expectedTargetFingerprint) { + if (!fs.existsSync(filePath)) { + throw new Error(`[EXPORT_TARGET_CHANGED] ${filePath} disappeared before replacement.`); + } + const currentFingerprint = readStableFile(filePath).fingerprint; + if (!fingerprintsEqual(options.expectedTargetFingerprint, currentFingerprint)) { + throw new Error(`[EXPORT_TARGET_CHANGED] ${filePath} changed before replacement.`); + } + } else if (options.expectTargetMissing === true && fs.existsSync(filePath)) { + throw new Error(`[EXPORT_TARGET_APPEARED] ${filePath} was created before export completed.`); + } + fs.renameSync(tempPath, filePath); + } catch (error) { + if (handle != null) { + try { + fs.closeSync(handle); + } catch (_closeError) { + // Preserve the original write error. + } + } + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch (_cleanupError) { + // Best-effort cleanup; never mask the original write error. + } + throw new Error(`Failed to atomically write ${filePath}: ${error.message}`); + } +} + +module.exports = { + limits: Object.freeze({ + maxZipFileBytes: MAX_ZIP_FILE_BYTES, + maxCsvFileBytes: MAX_CSV_FILE_BYTES, + maxJsonFileBytes: MAX_JSON_FILE_BYTES, + maxParsedCells: MAX_XLSX_PARSED_CELLS, + maxCsvParsedCells: MAX_CSV_PARSED_CELLS, + maxDenseExportCells: MAX_DENSE_EXPORT_CELLS, + maxComplexFormulaCoverageCells: MAX_COMPLEX_FORMULA_COVERAGE_CELLS, + excelMaxRows: EXCEL_MAX_ROWS, + excelMaxColumns: EXCEL_MAX_COLUMNS, + }), + unzipBuffer, + zipStore, + readStableFile, + fingerprintsEqual, + readWorkbookFile, + writeWorkbookFile, + writeFileAtomic, + writeXlsxBuffer, + writeSourcePreservingWorkbook, + sheetToMatrix, + crc32, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/advanced-creation.test.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/advanced-creation.test.js new file mode 100644 index 00000000..91d3a94b --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/advanced-creation.test.js @@ -0,0 +1,376 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const engine = require("../src/engine"); +const io = require("../src/xlsx-io"); +const store = require("../src/workbook-store"); + +function workspace(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "excel-advanced-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function hash(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +test("advanced Excel creation contract", async (t) => { + await t.test("reviews, applies, reads, undoes, and redoes style and layout proposals", (t) => { + const root = workspace(t); + const created = engine.dispatch("createWorkbook", { workspacePath: root, title: "Styled" }); + const workbookId = created.meta.workbookId; + const sheetId = created.meta.activeSheetId; + + const proposal = engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + expectedRevision: 0, + sheetId, + intent: "Create a readable financial table", + cells: [ + { row: 0, col: 0, value: "Revenue", styleRole: "header" }, + { row: 1, col: 0, value: 1200, style: { numberFormat: "#,##0.00" } }, + { row: 1, col: 1, value: 300 }, + { row: 1, col: 2, formula: "SUM(A2:B2)", styleRole: "output" }, + ], + layout: { + columns: [{ start: 0, end: 2, width: 14 }], + rows: [{ start: 0, height: 24 }], + freezePanes: { rows: 1, columns: 0 }, + autoFilter: { a1: "A1:C2" }, + }, + resultCellLimit: 2, + }); + + assert.equal(proposal.proposal.layout.after.units.columnWidth, "excelCharacters"); + assert.equal(proposal.proposal.validation.formulaLint.recalculated, false); + assert.match(proposal.proposal.validation.formulaLint.note, /not calculated/i); + assert.equal(proposal.cells[0].after.style.role, "header"); + assert.equal(proposal.cells.length, 2); + assert.equal(proposal.totalCellCount, 4); + assert.equal(proposal.cellDetailsTruncated, true); + const fullProposal = engine.dispatch("getProposal", { workspacePath: root, workbookId }); + assert.equal(fullProposal.proposal.cells.length, 4, "direct callers receive full proposal details by default"); + assert.equal(fullProposal.cellDetailsTruncated, false); + assert.equal(created.meta.revision, 0, "a proposal must not mutate committed workbook content"); + + const accepted = engine.dispatch("acceptProposal", { + workspacePath: root, + workbookId, + expectedRevision: 0, + proposalId: proposal.proposal.id, + }); + assert.equal(accepted.meta.revision, 1); + assert.equal(accepted.applied.layoutApplied, true); + + let read = engine.dispatch("readRange", { + workspacePath: root, + workbookId, + a1: "A1:C2", + }); + assert.equal(read.cells.find((cell) => cell.a1 === "A1").style.fill.color, "#1F4E78"); + assert.equal(read.cells.find((cell) => cell.a1 === "A2").style.numberFormat, "#,##0.00"); + assert.equal(read.cells.find((cell) => cell.a1 === "C2").formula, "SUM(A2:B2)"); + assert.deepEqual(read.layout.freezePanes, { rows: 1, columns: 0 }); + + engine.dispatch("undo", { workspacePath: root, workbookId, expectedRevision: 1 }); + read = engine.dispatch("readRange", { workspacePath: root, workbookId, a1: "A1:C2" }); + assert.equal(read.cells.length, 0); + assert.deepEqual(read.layout.freezePanes, { rows: 0, columns: 0 }); + + engine.dispatch("redo", { workspacePath: root, workbookId, expectedRevision: 2 }); + read = engine.dispatch("readRange", { workspacePath: root, workbookId, a1: "A1:C2" }); + assert.equal(read.cells.find((cell) => cell.a1 === "A1").style.role, "header"); + assert.deepEqual(read.layout.autoFilter, { a1: "A1:C2" }); + }); + + await t.test("merges partial styles and splits overlapping layout bands without data loss", (t) => { + const root = workspace(t); + const created = engine.dispatch("createWorkbook", { workspacePath: root }); + const workbookId = created.meta.workbookId; + const sheetId = created.meta.activeSheetId; + + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId, + expectedRevision: 0, + sheetId, + row: 0, + col: 0, + value: "Header", + styleRole: "header", + }); + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId, + expectedRevision: 1, + sheetId, + row: 0, + col: 0, + style: { font: { italic: true }, numberFormat: "@" }, + }); + let read = engine.dispatch("readRange", { workspacePath: root, workbookId, a1: "A1" }); + assert.equal(read.cells[0].style.font.bold, true); + assert.equal(read.cells[0].style.font.italic, true); + assert.equal(read.cells[0].style.fill.color, "#1F4E78"); + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId, + expectedRevision: 2, + sheetId, + row: 0, + col: 0, + styleRole: "input", + }); + read = engine.dispatch("readRange", { workspacePath: root, workbookId, a1: "A1" }); + assert.equal(read.cells[0].style.role, "input"); + assert.equal(read.cells[0].style.fill.color, "#FFF2CC"); + assert.equal(read.cells[0].style.font, undefined, "switching roles must not retain header font styling"); + assert.equal(read.cells[0].style.numberFormat, "@", "role changes preserve independent number formats"); + + engine.dispatch("applyLayout", { + workspacePath: root, + workbookId, + expectedRevision: 3, + sheetId, + layout: { columns: [{ start: 0, end: 5, width: 10 }] }, + }); + const changed = engine.dispatch("applyLayout", { + workspacePath: root, + workbookId, + expectedRevision: 4, + sheetId, + layout: { columns: [{ start: 2, end: 3, width: 20 }] }, + }); + assert.deepEqual(changed.layout.columns, [ + { start: 0, end: 1, width: 10 }, + { start: 2, end: 3, width: 20 }, + { start: 4, end: 5, width: 10 }, + ]); + const autoFit = engine.dispatch("applyLayout", { + workspacePath: root, + workbookId, + expectedRevision: 5, + sheetId, + layout: { columns: [{ start: 6, autoFit: true }] }, + }); + assert.deepEqual(autoFit.layout.columns.at(-1), { + start: 6, + end: 6, + width: 18, + autoFit: true, + }); + assert.throws( + () => engine.dispatch("insertColumns", { + workspacePath: root, + workbookId, + expectedRevision: 6, + sheetId, + at: 0, + count: 1, + }), + /LAYOUT_REFERENCE_RISK/ + ); + }); + + await t.test("rejects conflicting sheet targets and Excel-invalid sheet names", (t) => { + const root = workspace(t); + const created = engine.dispatch("createWorkbook", { workspacePath: root, sheetName: "Data" }); + const workbookId = created.meta.workbookId; + const dataSheetId = created.meta.activeSheetId; + const added = engine.dispatch("addSheet", { + workspacePath: root, + workbookId, + expectedRevision: 0, + name: "Summary", + }); + assert.throws( + () => engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + expectedRevision: 1, + sheetId: dataSheetId, + a1: "Summary!A1", + values: [[1]], + }), + /SHEET_QUALIFIER_CONFLICT/ + ); + assert.throws( + () => engine.dispatch("renameSheet", { + workspacePath: root, + workbookId, + expectedRevision: 1, + sheetId: added.sheet.id, + name: "Invalid/Name", + }), + /INVALID_SHEET_NAME/ + ); + assert.throws( + () => engine.dispatch("addSheet", { + workspacePath: root, + workbookId, + expectedRevision: 1, + name: "x".repeat(32), + }), + /INVALID_SHEET_NAME/ + ); + }); + + await t.test("blocks invalid formulas and reports external links as static warnings", (t) => { + const root = workspace(t); + const created = engine.dispatch("createWorkbook", { workspacePath: root }); + const workbookId = created.meta.workbookId; + const sheetId = created.meta.activeSheetId; + + assert.throws( + () => engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + expectedRevision: 0, + sheetId, + cells: [{ row: 0, col: 0, formula: "A1+1" }], + }), + /FORMULA_SELF_REFERENCE/ + ); + assert.throws( + () => engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + expectedRevision: 0, + sheetId, + cells: [{ row: 0, col: 1, formula: "SUM(A1:B1" }], + }), + /FORMULA_PAREN_UNBALANCED/ + ); + const warned = engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + expectedRevision: 0, + sheetId, + cells: [{ row: 0, col: 2, formula: "'[Budget.xlsx]Sheet1'!A1" }], + }); + assert.equal(warned.proposal.validation.formulaLint.recalculated, false); + assert.ok(warned.proposal.validation.formulaLint.warnings.some((issue) => issue.code === "FORMULA_EXTERNAL_LINK")); + }); + + await t.test("writes and reopens styled XLSX with valid layout XML", (t) => { + const root = workspace(t); + const workbook = store.createEmpty({ title: "Export" }); + const sheet = workbook.sheets[0]; + sheet.cells["0,0"] = { + v: "Total", + t: "s", + style: { + role: "total", + fill: { color: "#DDEBF7" }, + font: { bold: true, color: "#17365D", size: 12 }, + border: { top: { style: "double", color: "#1F1F1F" } }, + alignment: { horizontal: "right", wrapText: true }, + numberFormat: "#,##0.00", + }, + }; + sheet.layout = { + units: { columnWidth: "excelCharacters", rowHeight: "points" }, + columns: [{ start: 0, end: 1, width: 16, autoFit: true }], + rows: [{ start: 0, end: 0, height: 27 }], + freezePanes: { rows: 1, columns: 0 }, + autoFilter: { a1: "A1:B5" }, + }; + const output = path.join(root, "styled.xlsx"); + fs.writeFileSync(output, io.writeXlsxBuffer(workbook)); + + const files = io.unzipBuffer(fs.readFileSync(output)); + assert.ok(files.has("xl/styles.xml")); + assert.match(files.get("xl/styles.xml").toString("utf8"), /formatCode="#,##0\.00"/); + const sheetXml = files.get("xl/worksheets/sheet1.xml").toString("utf8"); + assert.match(sheetXml, /activePane="bottomLeft"/); + assert.match(sheetXml, //); + sheet.layout.freezePanes = { rows: 0, columns: 1 }; + let paneXml = io.unzipBuffer(io.writeXlsxBuffer(workbook)) + .get("xl/worksheets/sheet1.xml").toString("utf8"); + assert.match(paneXml, /activePane="topRight"/); + sheet.layout.freezePanes = { rows: 1, columns: 1 }; + paneXml = io.unzipBuffer(io.writeXlsxBuffer(workbook)) + .get("xl/worksheets/sheet1.xml").toString("utf8"); + assert.match(paneXml, /activePane="bottomRight"/); + sheet.layout.freezePanes = { rows: 1, columns: 0 }; + + const reopened = io.readWorkbookFile(output); + assert.equal(reopened.sheets[0].cells["0,0"].style.fill.color, "#DDEBF7"); + assert.equal(reopened.sheets[0].cells["0,0"].style.numberFormat, "#,##0.00"); + assert.equal(reopened.sheets[0].cells["0,0"].style.border.top.style, "double"); + assert.equal(reopened.sheets[0].cells["0,0"].style.alignment.horizontal, "right"); + assert.equal(reopened.sheets[0].cells["0,0"].style.alignment.wrapText, true); + assert.equal(reopened.sheets[0].layout.columns[0].width, 16); + assert.equal(reopened.sheets[0].layout.rows[0].height, 27); + assert.deepEqual(reopened.sheets[0].layout.freezePanes, { rows: 1, columns: 0 }); + }); + + await t.test("never silently drops style changes when exporting an imported Excel source", (t) => { + const root = workspace(t); + const source = path.join(root, "source.xlsx"); + const sourceWorkbook = store.createEmpty({ title: "Source" }); + sourceWorkbook.sheets[0].cells["0,0"] = { v: 1, t: "n", style: { numberFormat: "0.00" } }; + fs.writeFileSync(source, io.writeXlsxBuffer(sourceWorkbook)); + const sourceHash = hash(source); + + const opened = engine.dispatch("openWorkbook", { workspacePath: root, path: source }); + const workbookId = opened.meta.workbookId; + const sheetId = opened.meta.activeSheetId; + const proposal = engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + expectedRevision: 0, + sheetId, + cells: [{ row: 0, col: 0, style: { fill: { color: "#FFF2CC" } } }], + }); + const accepted = engine.dispatch("acceptProposal", { + workspacePath: root, + workbookId, + expectedRevision: 0, + proposalId: proposal.proposal.id, + }); + assert.equal(accepted.meta.fidelity.canRoundTrip, false); + assert.match(accepted.meta.fidelity.warning, /style/i); + const undone = engine.dispatch("undo", { + workspacePath: root, + workbookId, + expectedRevision: 1, + }); + assert.equal(undone.meta.fidelity.canRoundTrip, true); + const redone = engine.dispatch("redo", { + workspacePath: root, + workbookId, + expectedRevision: 2, + }); + assert.equal(redone.meta.fidelity.canRoundTrip, false); + + const copy = path.join(root, "styled-copy.xlsx"); + assert.throws( + () => engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId, + expectedRevision: 3, + path: copy, + }), + /FIDELITY_ACK_REQUIRED/ + ); + const saved = engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId, + expectedRevision: 3, + path: copy, + acknowledgeFidelityLoss: true, + }); + assert.equal(saved.export.mode, "lossy-rebuild"); + assert.equal(saved.export.fullCalcOnLoad, true); + assert.equal(hash(source), sourceHash); + assert.equal(io.readWorkbookFile(copy).sheets[0].cells["0,0"].style.fill.color, "#FFF2CC"); + }); +}); diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/contract-consistency.test.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/contract-consistency.test.js new file mode 100644 index 00000000..858421fa --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/contract-consistency.test.js @@ -0,0 +1,436 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const vm = require("node:vm"); + +const APP_ROOT = path.resolve(__dirname, "../../../../.."); +const AGENT_ROOT = path.join( + APP_ROOT, + "components", + "agents", + "excel-live-agent" +); +const BRIDGE_ROOT = path.join( + APP_ROOT, + "components", + "bridges", + "builtin-excel-runtime" +); +const SURFACE_ROOT = path.join( + APP_ROOT, + "components", + "surfaces", + "builtin-excel-live-surface" +); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function walkFiles(root, predicate = () => true) { + const files = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const filePath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(filePath, predicate)); + } else if (predicate(filePath)) { + files.push(filePath); + } + } + return files; +} + +function sortedUnique(values) { + return [...new Set(values)].sort(); +} + +function assertSameSet(actual, expected, message) { + assert.deepEqual(sortedUnique(actual), sortedUnique(expected), message); +} + +function assertPathInside(root, candidate, message) { + const relative = path.relative(root, candidate); + assert.ok( + relative && !relative.startsWith("..") && !path.isAbsolute(relative), + message + ); +} + +function topLevelFunctionSource(source, name) { + const start = source.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `Engine action ${name} must have a top-level function`); + const next = source.indexOf("\nfunction ", start + 1); + return source.slice(start, next === -1 ? source.length : next); +} + +function loadSurfaceMessages() { + const i18nPath = path.join(SURFACE_ROOT, "source", "src", "i18n.js"); + const executable = fs + .readFileSync(i18nPath, "utf8") + .replace(/^import .*$/m, "const state = {};") + .replace( + /export\s*\{\s*MESSAGES\s*,\s*t\s*\};/, + "globalThis.__EXCEL_LIVE_MESSAGES__ = MESSAGES;" + ); + const sandbox = {}; + vm.createContext(sandbox); + vm.runInContext(executable, sandbox, { filename: i18nPath }); + return sandbox.__EXCEL_LIVE_MESSAGES__; +} + +function loadCommonJsModule(filePath) { + const source = fs.readFileSync(filePath, "utf8"); + const module = { exports: {} }; + const execute = new Function("require", "module", "exports", source); + execute(require, module, module.exports); + return module.exports; +} + +test("Excel Live package contract consistency", async (t) => { + const app = readJson(path.join(APP_ROOT, "app.json")); + const agentComponent = readJson(path.join(AGENT_ROOT, "component.json")); + const agentManifest = readJson(path.join(AGENT_ROOT, "source", "manifest.json")); + const bridgeComponent = readJson(path.join(BRIDGE_ROOT, "component.json")); + const bridgeManifest = readJson(path.join(BRIDGE_ROOT, "source", "manifest.json")); + const surfaceComponent = readJson(path.join(SURFACE_ROOT, "component.json")); + const engineSource = fs.readFileSync( + path.join(BRIDGE_ROOT, "source", "src", "engine.js"), + "utf8" + ); + const engineActions = require("../src/engine").actions; + const bridgeCapability = bridgeManifest.capabilities.find( + (capability) => capability.id === "sparo.excelEngine" + ); + + await t.test("all package JSON parses and declared entries exist", () => { + const jsonFiles = walkFiles(APP_ROOT, (filePath) => filePath.endsWith(".json")); + assert.ok(jsonFiles.length > 0, "Excel Live package must contain JSON manifests"); + for (const filePath of jsonFiles) { + assert.doesNotThrow( + () => readJson(filePath), + `Invalid JSON: ${path.relative(APP_ROOT, filePath)}` + ); + } + + const bridgeEntry = path.resolve( + BRIDGE_ROOT, + "source", + bridgeManifest.runtime.entry + ); + assertPathInside( + path.join(BRIDGE_ROOT, "source"), + bridgeEntry, + "Bridge entry must stay inside its source directory" + ); + assert.ok(fs.existsSync(bridgeEntry), "Bridge runtime entry must exist"); + + const surfaceManifest = readJson( + path.join(SURFACE_ROOT, "source", "source_manifest.json") + ); + for (const entry of [ + surfaceManifest.uiEntry, + surfaceManifest.workerEntry, + ...(surfaceManifest.styleEntries || []), + ]) { + const entryPath = path.resolve(SURFACE_ROOT, "source", entry); + assertPathInside( + path.join(SURFACE_ROOT, "source"), + entryPath, + `Surface entry ${entry} must stay inside its source directory` + ); + assert.ok(fs.existsSync(entryPath), `Surface entry must exist: ${entry}`); + } + }); + + await t.test("app component references match private component contracts", () => { + const privateComponents = new Map([ + [agentComponent.id, agentComponent], + [bridgeComponent.id, bridgeComponent], + [surfaceComponent.id, surfaceComponent], + ]); + + for (const reference of app.components.filter( + (component) => component.source === "private" + )) { + const component = privateComponents.get(reference.componentId); + assert.ok(component, `Missing private component ${reference.componentId}`); + assert.equal(component.kind, reference.kind); + assert.equal(component.ownerApp?.appId, app.id); + assert.equal(component.ownerApp?.appVersion, app.version); + assert.ok(component.usedByApps?.includes(app.id)); + const capabilityIds = component.capabilities.map((capability) => capability.id); + for (const capabilityId of reference.capabilities || []) { + assert.ok( + capabilityIds.includes(capabilityId), + `${reference.componentId} does not declare capability ${capabilityId}` + ); + } + } + + const referencedIds = new Set(app.components.map((component) => component.componentId)); + for (const component of privateComponents.values()) { + for (const dependency of component.dependencies || []) { + assert.ok( + referencedIds.has(dependency.componentId), + `${component.id} depends on component absent from app.json: ${dependency.componentId}` + ); + } + } + }); + + await t.test("bridge action declarations stay aligned with the engine", () => { + assert.ok(bridgeCapability, "sparo.excelEngine capability must exist"); + const componentCapability = bridgeComponent.capabilities.find( + (capability) => capability.id === bridgeCapability.id + ); + const runtimeTool = bridgeManifest.tools.find( + (tool) => tool.capabilityId === bridgeCapability.id + ); + assert.ok(componentCapability, "Bridge component capability must exist"); + assert.ok(runtimeTool, "Bridge runtime tool must exist"); + + assertSameSet( + componentCapability.actions, + bridgeCapability.actions, + "component.json and manifest capability actions must match" + ); + assertSameSet( + bridgeManifest.actions.map((action) => action.name), + bridgeCapability.actions, + "manifest action definitions and capability actions must match" + ); + assertSameSet( + runtimeTool.actions, + bridgeCapability.actions, + "runtime tool and capability actions must match" + ); + + for (const action of bridgeCapability.actions) { + assert.equal( + typeof engineActions[action], + "function", + `Declared bridge action is missing from engine: ${action}` + ); + } + + const surfaceActions = new Set(); + for (const filePath of walkFiles( + path.join(SURFACE_ROOT, "source"), + (candidate) => candidate.endsWith(".js") + )) { + const source = fs.readFileSync(filePath, "utf8"); + for (const match of source.matchAll(/\bcallExcel\(\s*["']([^"']+)["']/g)) { + surfaceActions.add(match[1]); + } + } + for (const action of surfaceActions) { + assert.ok( + bridgeCapability.actions.includes(action), + `Surface calls undeclared bridge action: ${action}` + ); + assert.equal(typeof engineActions[action], "function"); + } + }); + + await t.test("agent tools match manifest, bridge actions, and revision guards", () => { + const toolsDirectory = path.join(AGENT_ROOT, "source", "tools"); + const toolDefinitions = walkFiles( + toolsDirectory, + (filePath) => filePath.endsWith(".tool.json") + ).map((filePath) => ({ filePath, manifest: readJson(filePath) })); + const toolNames = toolDefinitions.map(({ manifest }) => manifest.name); + assert.equal( + new Set(toolNames).size, + toolNames.length, + "Agent tool names must be unique" + ); + + const runtimePrefix = `agentcomponent__${agentManifest.id}__`; + const selectedRuntimeTools = agentManifest.tools + .filter((name) => name.startsWith(runtimePrefix)) + .map((name) => name.slice(runtimePrefix.length)); + assertSameSet( + selectedRuntimeTools, + toolNames, + "Agent manifest runtime tools must match .tool.json definitions" + ); + + const mutatingActions = new Set([ + "openWorkbook", + "createWorkbook", + "proposePatch", + "acceptProposal", + "rejectProposal", + "undo", + "redo", + "saveWorkbook", + "switchSheet", + ]); + + for (const { filePath, manifest } of toolDefinitions) { + const entryPath = path.resolve(AGENT_ROOT, "source", manifest.entry); + assertPathInside( + path.join(AGENT_ROOT, "source"), + entryPath, + `${manifest.name} entry must stay inside the agent source directory` + ); + assert.ok(fs.existsSync(entryPath), `${manifest.name} entry must exist`); + + const properties = manifest.inputSchema?.properties || {}; + assert.equal( + Object.hasOwn(properties, "workspacePath") || + Object.hasOwn(properties, "workspace_path"), + false, + `${manifest.name} must not expose the host-owned workspace path` + ); + + const entrySource = fs.readFileSync(entryPath, "utf8"); + const actionMatch = entrySource.match( + /callExcelEngine\(\s*["']([^"']+)["']/ + ); + assert.ok( + actionMatch, + `${path.relative(APP_ROOT, filePath)} must call a static Excel Engine action` + ); + const action = actionMatch[1]; + assert.ok( + bridgeCapability.actions.includes(action), + `${manifest.name} calls undeclared bridge action ${action}` + ); + assert.equal( + typeof engineActions[action], + "function", + `${manifest.name} calls missing engine action ${action}` + ); + + if (mutatingActions.has(action)) { + assert.equal( + manifest.readonly, + false, + `${manifest.name} persists workbook or proposal state and cannot be readonly` + ); + } + + const actionSource = topLevelFunctionSource(engineSource, action); + const required = new Set(manifest.inputSchema?.required || []); + if (actionSource.includes("assertExpectedRevision")) { + assert.ok( + required.has("expectedRevision") && properties.expectedRevision, + `${manifest.name} must require expectedRevision because ${action} enforces it` + ); + } + if (actionSource.includes("[PROPOSAL_ID_REQUIRED]")) { + assert.ok( + required.has("proposalId") && properties.proposalId, + `${manifest.name} must require proposalId because ${action} enforces it` + ); + } + } + + const toolNameSet = new Set(toolNames); + for (const serviceAction of agentManifest.serviceActions || []) { + for (const runtimeTool of (serviceAction.toolPolicy || []).filter((name) => + name.startsWith(runtimePrefix) + )) { + assert.ok( + toolNameSet.has(runtimeTool.slice(runtimePrefix.length)), + `${serviceAction.name} references missing tool ${runtimeTool}` + ); + } + } + }); + + await t.test("agent paths stay workspace-relative and CSV cannot bypass safe export", (t) => { + const bridgeHelpers = loadCommonJsModule( + path.join(AGENT_ROOT, "source", "tools", "excel_bridge.js") + ); + assert.deepEqual( + bridgeHelpers.normalizeInput( + { + workspacePath: "C:/model-owned", + workspace_path: "C:/also-model-owned", + workbook_id: "wb_1", + path: "reports/book.xlsx", + }, + { workspaceRoot: "D:/trusted-workspace" } + ), + { + workbookId: "wb_1", + path: "reports/book.xlsx", + workspacePath: "D:/trusted-workspace", + } + ); + assert.throws( + () => bridgeHelpers.normalizeInput({}, {}), + /host-bound workspace root/ + ); + for (const unsafePath of [ + "C:/outside/book.xlsx", + "/outside/book.xlsx", + "../outside/book.xlsx", + "nested/../../outside/book.xlsx", + "..\\outside\\book.xlsx", + "book.xlsx\0suffix", + ]) { + assert.throws( + () => + bridgeHelpers.normalizeInput( + { path: unsafePath }, + { workspaceRoot: "D:/trusted-workspace" } + ), + /workspace-relative|active workspace/, + unsafePath + ); + } + + const workspacePath = fs.mkdtempSync( + path.join(os.tmpdir(), "excel-contract-csv-bypass-") + ); + t.after(() => fs.rmSync(workspacePath, { recursive: true, force: true })); + const engine = require("../src/engine"); + const created = engine.dispatch("createWorkbook", { workspacePath }); + assert.throws( + () => + engine.dispatch("saveWorkbook", { + workspacePath, + workbookId: created.meta.workbookId, + expectedRevision: created.meta.revision, + path: path.join(workspacePath, "unsafe.csv"), + exportCopy: true, + }), + /CSV_EXPORT_ACTION_REQUIRED/ + ); + assert.equal(fs.existsSync(path.join(workspacePath, "unsafe.csv")), false); + }); + + await t.test("surface translations have locale parity and cover static keys", () => { + const messages = loadSurfaceMessages(); + assert.ok(messages?.["en-US"] && messages?.["zh-CN"]); + const englishKeys = Object.keys(messages["en-US"]); + const chineseKeys = Object.keys(messages["zh-CN"]); + assertSameSet( + englishKeys, + chineseKeys, + "Excel Live surface locales must have identical key sets" + ); + + const usedKeys = new Set(); + for (const filePath of walkFiles( + path.join(SURFACE_ROOT, "source"), + (candidate) => candidate.endsWith(".js") + )) { + const source = fs.readFileSync(filePath, "utf8"); + for (const match of source.matchAll(/\bt\(\s*["']([^"']+)["']/g)) { + usedKeys.add(match[1]); + } + } + for (const key of usedKeys) { + assert.ok( + Object.hasOwn(messages["en-US"], key), + `Static surface translation key is missing: ${key}` + ); + } + }); +}); diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/engine-safety.test.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/engine-safety.test.js new file mode 100644 index 00000000..91672a80 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/engine-safety.test.js @@ -0,0 +1,986 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const engine = require("../src/engine"); +const store = require("../src/workbook-store"); +const io = require("../src/xlsx-io"); +const csv = require("../src/csv-io"); + +function tempWorkspace(t, prefix) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => { + const resolved = path.resolve(root); + const tempRoot = `${path.resolve(os.tmpdir())}${path.sep}`; + assert.ok(resolved.startsWith(tempRoot), `Refusing unsafe cleanup: ${resolved}`); + fs.rmSync(resolved, { recursive: true, force: true }); + }); + return root; +} + +function sha256(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function writeInstrumentedExcelSource(root, options = {}) { + const extension = options.extension || ".xlsx"; + const filePath = path.join(root, `source${extension}`); + const workbook = store.createEmpty({ title: "Source" }); + workbook.sheets[0].cells["0,0"] = { v: 1, t: "n" }; + if (options.withFormula !== false) { + workbook.sheets[0].cells["0,1"] = { f: "A1*2", v: 2, t: "f" }; + } + const files = io.unzipBuffer(io.writeXlsxBuffer(workbook)); + const sheetPath = "xl/worksheets/sheet1.xml"; + files.set( + sheetPath, + Buffer.from( + files.get(sheetPath).toString("utf8").replace('', + "utf8" + ) + ); + files.set("custom/opaque.bin", Buffer.from([0, 1, 2, 253, 254, 255])); + files.set( + "xl/calcChain.xml", + Buffer.from( + '', + "utf8" + ) + ); + files.set( + "xl/_rels/workbook.xml.rels", + Buffer.from( + files + .get("xl/_rels/workbook.xml.rels") + .toString("utf8") + .replace( + "", + '' + ), + "utf8" + ) + ); + files.set( + "[Content_Types].xml", + Buffer.from( + files + .get("[Content_Types].xml") + .toString("utf8") + .replace( + "", + '' + ), + "utf8" + ) + ); + + if (extension === ".xlsm") { + const macroBytes = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0, 1, 2, 3]); + files.set("xl/vbaProject.bin", macroBytes); + files.set( + "[Content_Types].xml", + Buffer.from( + files + .get("[Content_Types].xml") + .toString("utf8") + .replace( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", + "application/vnd.ms-excel.sheet.macroEnabled.main+xml" + ) + .replace( + "", + '' + ), + "utf8" + ) + ); + files.set( + "xl/_rels/workbook.xml.rels", + Buffer.from( + files + .get("xl/_rels/workbook.xml.rels") + .toString("utf8") + .replace( + "", + '' + ), + "utf8" + ) + ); + } + + fs.writeFileSync( + filePath, + io.zipStore([...files].map(([name, data]) => ({ name, data }))) + ); + return filePath; +} + +function writeWorkbookWithCellRef(root, fileName, cellRef) { + const workbook = store.createEmpty({ title: "Cell reference" }); + workbook.sheets[0].cells["0,0"] = { v: 1, t: "n" }; + const files = io.unzipBuffer(io.writeXlsxBuffer(workbook)); + const sheetPath = "xl/worksheets/sheet1.xml"; + files.set( + sheetPath, + Buffer.from( + files.get(sheetPath).toString("utf8").replace(' ({ name, data })))); + return filePath; +} + +function writeWorkbookWithSerializedCellCount(root, fileName, cellCount) { + const workbook = store.createEmpty({ title: "Cell budget" }); + const files = io.unzipBuffer(io.writeXlsxBuffer(workbook)); + const sheetPath = "xl/worksheets/sheet1.xml"; + const xml = files.get(sheetPath).toString("utf8").replace( + /[\s\S]*?<\/sheetData>/, + `${'1'.repeat(cellCount)}` + ); + files.set(sheetPath, Buffer.from(xml, "utf8")); + const filePath = path.join(root, fileName); + fs.writeFileSync(filePath, io.zipStore([...files].map(([name, data]) => ({ name, data })))); + return filePath; +} + +function writeSparseFile(filePath, size) { + fs.writeFileSync(filePath, ""); + fs.truncateSync(filePath, size); +} + +test("Excel Live engine safety contract", async (t) => { + await t.test("enforces Inspect mode, monotonic revision, undo, redo, and formula structure safety", (t) => { + const root = tempWorkspace(t, "excel-engine-revision-"); + const created = engine.dispatch("createWorkbook", { workspacePath: root, title: "Revision" }); + const workbookId = created.meta.workbookId; + const sheetId = created.meta.activeSheetId; + + engine.dispatch("setFocus", { + workspacePath: root, + workbookId, + sheetId, + a1: "B2", + mode: "inspect", + }); + let meta = engine.dispatch("getMeta", { workspacePath: root, workbookId }).meta; + assert.equal(meta.revision, 0); + assert.equal(meta.dirty, false); + assert.equal(meta.mode, "inspect"); + assert.throws( + () => engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId, + sheetId, + row: 0, + col: 0, + value: 1, + }), + /INSPECT_MODE_READ_ONLY/ + ); + assert.throws( + () => engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + sheetId, + cells: [{ row: 0, col: 0, value: 1 }], + }), + /INSPECT_MODE_READ_ONLY/ + ); + + engine.dispatch("setMode", { workspacePath: root, workbookId, mode: "edit" }); + const applied = engine.dispatch("applyLocalPatch", { + workspacePath: root, + workbookId, + sheetId, + a1: "A1", + values: [[1, 2], [3, 4]], + baseRevision: 0, + }); + assert.equal(applied.meta.revision, 1); + assert.throws( + () => engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId, + sheetId, + row: 0, + col: 0, + value: 8, + expectedRevision: 0, + }), + /REVISION_CONFLICT/ + ); + + const undone = engine.dispatch("undo", { + workspacePath: root, + workbookId, + expectedRevision: 1, + }); + assert.equal(undone.meta.revision, 2); + assert.deepEqual( + engine.dispatch("readRange", { + workspacePath: root, + workbookId, + sheetId, + a1: "A1:B2", + }).values, + [[null, null], [null, null]] + ); + + const redone = engine.dispatch("redo", { + workspacePath: root, + workbookId, + expectedRevision: 2, + }); + assert.equal(redone.meta.revision, 3); + assert.deepEqual( + engine.dispatch("readRange", { + workspacePath: root, + workbookId, + sheetId, + a1: "A1:B2", + }).values, + [[1, 2], [3, 4]] + ); + const history = engine.dispatch("getHistory", { workspacePath: root, workbookId }); + assert.equal(history.entries.length, 3); + assert.equal(history.canUndo, true); + assert.equal(history.canRedo, false); + + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId, + sheetId, + row: 2, + col: 0, + formula: "=A1+B1", + expectedRevision: 3, + }); + assert.throws( + () => engine.dispatch("insertRows", { + workspacePath: root, + workbookId, + sheetId, + at: 0, + expectedRevision: 4, + }), + /FORMULA_REFERENCE_RISK/ + ); + }); + + await t.test("keeps proposals uncommitted, rejects replacement, and safely partially accepts", (t) => { + const root = tempWorkspace(t, "excel-engine-proposal-"); + const created = engine.dispatch("createWorkbook", { workspacePath: root }); + const workbookId = created.meta.workbookId; + const sheetId = created.meta.activeSheetId; + engine.dispatch("applyLocalPatch", { + workspacePath: root, + workbookId, + sheetId, + a1: "A1", + values: [[1, 2]], + expectedRevision: 0, + }); + + const proposal = engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + sheetId, + cells: [ + { row: 0, col: 0, value: 10 }, + { row: 0, col: 1, value: 20 }, + ], + expectedRevision: 1, + intent: "Raise values", + }); + assert.equal(proposal.proposal.baseRevision, 1); + assert.equal(proposal.proposal.intent, "Raise values"); + assert.deepEqual( + engine.dispatch("readRange", { + workspacePath: root, + workbookId, + sheetId, + a1: "A1:B1", + }).values, + [[1, 2]], + "proposePatch must not mutate committed cells" + ); + assert.throws( + () => engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + sheetId, + cells: [{ row: 1, col: 0, value: 30 }], + expectedRevision: 1, + }), + /ACTIVE_PROPOSAL_EXISTS/ + ); + + const partial = engine.dispatch("acceptProposal", { + workspacePath: root, + workbookId, + proposalId: proposal.proposal.id, + cellRefs: [`${sheetId}!A1`], + baseRevision: 1, + }); + assert.equal(partial.applied.cellCount, 1); + assert.equal(partial.meta.revision, 2); + assert.equal(partial.remainingProposal.cellCount, 1); + assert.equal(partial.remainingProposal.baseRevision, 2); + assert.deepEqual( + engine.dispatch("readRange", { + workspacePath: root, + workbookId, + sheetId, + a1: "A1:B1", + }).values, + [[10, 2]] + ); + + const complete = engine.dispatch("acceptProposal", { + workspacePath: root, + workbookId, + proposalId: proposal.proposal.id, + cellRefs: ["B1"], + expectedRevision: 2, + }); + assert.equal(complete.meta.revision, 3); + assert.equal(complete.remainingProposal, null); + + const conflicting = engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + sheetId, + cells: [{ row: 1, col: 0, value: 99 }], + expectedRevision: 3, + }); + engine.sessions.get(workbookId).workbook.sheets[0].cells["1,0"] = { v: 7, t: "n" }; + assert.throws( + () => engine.dispatch("acceptProposal", { + workspacePath: root, + workbookId, + proposalId: conflicting.proposal.id, + expectedRevision: 3, + }), + /PROPOSAL_BEFORE_CONFLICT/ + ); + assert.equal(engine.sessions.get(workbookId).workbook.revision, 3); + }); + + await t.test("exports a source-preserving copy without changing source hash or workbook identity", (t) => { + const root = tempWorkspace(t, "excel-engine-safe-copy-"); + const sourcePath = writeInstrumentedExcelSource(root); + const copyPath = path.join(root, "copy.xlsx"); + const sourceHash = sha256(sourcePath); + const opened = engine.dispatch("openWorkbook", { workspacePath: root, path: sourcePath }); + const workbookId = opened.meta.workbookId; + const sheetId = opened.meta.activeSheetId; + + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId, + sheetId, + row: 0, + col: 0, + value: 3, + expectedRevision: 0, + }); + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId, + sheetId, + row: 0, + col: 1, + formula: "=A1*4", + expectedRevision: 1, + }); + assert.throws( + () => engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId, + expectedRevision: 2, + }), + /SOURCE_OVERWRITE_BLOCKED/ + ); + assert.throws( + () => engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId, + path: copyPath, + exportCopy: true, + expectedRevision: 1, + }), + /REVISION_CONFLICT/ + ); + + const saved = engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId, + path: copyPath, + exportCopy: true, + expectedRevision: 2, + }); + assert.equal(saved.export.mode, "source-preserving-cell-patch"); + assert.equal(saved.workingPath, sourcePath); + assert.equal(saved.meta.path, sourcePath); + assert.equal(saved.meta.sourcePath, sourcePath); + assert.equal(saved.meta.lastExportPath, copyPath); + assert.equal(saved.meta.lastExportedRevision, 2); + assert.equal(saved.meta.dirty, false); + assert.equal(sha256(sourcePath), sourceHash, "export copy must not mutate the imported source"); + + const output = io.unzipBuffer(fs.readFileSync(copyPath)); + assert.deepEqual(output.get("custom/opaque.bin"), Buffer.from([0, 1, 2, 253, 254, 255])); + const sheetXml = output.get("xl/worksheets/sheet1.xml").toString("utf8"); + assert.match(sheetXml, /3<\/v><\/c>/); + assert.deepEqual(output.get("xl/styles.xml"), io.unzipBuffer(fs.readFileSync(sourcePath)).get("xl/styles.xml")); + assert.match(sheetXml, /A1\*4<\/f><\/c>/); + assert.equal(output.has("xl/calcChain.xml"), false); + assert.doesNotMatch( + output.get("xl/_rels/workbook.xml.rels").toString("utf8"), + /calcChain/i + ); + assert.match( + output.get("xl/workbook.xml").toString("utf8"), + /fullCalcOnLoad="1"[^>]*forceFullCalc="1"/ + ); + assert.equal(io.readWorkbookFile(copyPath).sheets[0].cells["0,1"].f, "A1*4"); + + engine.sessions.clear(); + const resumed = engine.dispatch("getMeta", { workspacePath: root, workbookId }).meta; + assert.equal(resumed.path, sourcePath); + assert.equal(resumed.sourcePath, sourcePath); + assert.equal(resumed.lastExportPath, copyPath); + }); + + await t.test("preserves unknown row XML while patching a source cell", (t) => { + const root = tempWorkspace(t, "excel-engine-row-xml-"); + const sourcePath = writeInstrumentedExcelSource(root, { withFormula: false }); + const copyPath = path.join(root, "row-xml-copy.xlsx"); + const files = io.unzipBuffer(fs.readFileSync(sourcePath)); + const sheetPath = "xl/worksheets/sheet1.xml"; + const marker = ''; + files.set( + sheetPath, + Buffer.from( + files.get(sheetPath).toString("utf8").replace("", `${marker}`), + "utf8" + ) + ); + fs.writeFileSync( + sourcePath, + io.zipStore([...files].map(([name, data]) => ({ name, data }))) + ); + + const opened = engine.dispatch("openWorkbook", { workspacePath: root, path: sourcePath }); + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId: opened.meta.workbookId, + sheetId: opened.meta.activeSheetId, + row: 0, + col: 0, + value: 7, + expectedRevision: 0, + }); + const saved = engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId: opened.meta.workbookId, + path: copyPath, + exportCopy: true, + expectedRevision: 1, + }); + assert.equal(saved.export.mode, "source-preserving-cell-patch"); + const outputXml = io + .unzipBuffer(fs.readFileSync(copyPath)) + .get(sheetPath) + .toString("utf8"); + assert.match(outputXml, /SPARO-ROW-METADATA/); + assert.match(outputXml, /7<\/v><\/c>/); + }); + + await t.test("recognizes shared formula followers and blocks edits inside complex formula ranges", (t) => { + const root = tempWorkspace(t, "excel-engine-complex-formula-"); + const sourcePath = writeInstrumentedExcelSource(root, { withFormula: false }); + const copyPath = path.join(root, "complex-formula-copy.xlsx"); + const files = io.unzipBuffer(fs.readFileSync(sourcePath)); + const sheetPath = "xl/worksheets/sheet1.xml"; + const sourceXml = files.get(sheetPath).toString("utf8"); + files.set( + sheetPath, + Buffer.from( + sourceXml.replace( + /[\s\S]*?<\/sheetData>/, + 'ROW()1ROW(B1:B2)122' + ), + "utf8" + ) + ); + fs.writeFileSync( + sourcePath, + io.zipStore([...files].map(([name, data]) => ({ name, data }))) + ); + + const opened = engine.dispatch("openWorkbook", { workspacePath: root, path: sourcePath }); + assert.equal(opened.meta.calculationStatus.status, "cached"); + assert.equal(opened.meta.calculationStatus.formulaCount, 4); + const read = engine.dispatch("readRange", { + workspacePath: root, + workbookId: opened.meta.workbookId, + sheetId: opened.meta.activeSheetId, + a1: "A1:B2", + maxCells: 4, + }); + assert.equal(read.formulas.length, 4); + assert.equal(read.formulas[2].formula, null); + assert.equal(read.formulas[2].formulaEvidence, true); + assert.equal(read.formulas[2].formulaType, "shared"); + assert.equal(read.formulas[3].formula, null); + assert.equal(read.formulas[3].formulaType, "array"); + + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId: opened.meta.workbookId, + sheetId: opened.meta.activeSheetId, + row: 1, + col: 1, + value: 9, + expectedRevision: 0, + }); + assert.throws( + () => engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId: opened.meta.workbookId, + path: copyPath, + exportCopy: true, + expectedRevision: 1, + }), + /COMPLEX_FORMULA_PATCH_UNSAFE/ + ); + }); + + await t.test("preserves VBA package parts for value-only xlsm copies", (t) => { + const root = tempWorkspace(t, "excel-engine-xlsm-"); + const sourcePath = writeInstrumentedExcelSource(root, { extension: ".xlsm" }); + const copyPath = path.join(root, "copy.xlsm"); + const workbook = io.readWorkbookFile(sourcePath); + workbook.sheets[0].cells["0,0"] = { v: 8, t: "n" }; + const result = io.writeWorkbookFile(workbook, copyPath); + assert.equal(result.mode, "source-preserving-cell-patch"); + const source = io.unzipBuffer(fs.readFileSync(sourcePath)); + const output = io.unzipBuffer(fs.readFileSync(copyPath)); + assert.deepEqual(output.get("xl/vbaProject.bin"), source.get("xl/vbaProject.bin")); + assert.match(output.get("[Content_Types].xml").toString("utf8"), /macroEnabled/); + assert.match(output.get("xl/_rels/workbook.xml.rels").toString("utf8"), /vbaProject/); + }); + + await t.test("strictly enforces Excel cell bounds and a workbook-wide parsed-cell budget", (t) => { + const root = tempWorkspace(t, "excel-engine-xlsx-limits-"); + const edgePath = writeWorkbookWithCellRef( + root, + "edge.xlsx", + `XFD${io.limits.excelMaxRows}` + ); + const edge = io.readWorkbookFile(edgePath); + assert.equal( + edge.sheets[0].cells[`${io.limits.excelMaxRows - 1},${io.limits.excelMaxColumns - 1}`].v, + 1 + ); + + const invalidRefs = [ + ["column-overflow.xlsx", "XFE1"], + ["row-overflow.xlsx", `A${io.limits.excelMaxRows + 1}`], + ["unsafe-integer.xlsx", "A9007199254740992"], + ]; + for (const [fileName, cellRef] of invalidRefs) { + const filePath = writeWorkbookWithCellRef(root, fileName, cellRef); + assert.throws(() => io.readWorkbookFile(filePath), /XLSX_CELL_REF_LIMIT/); + } + + const overBudgetPath = writeWorkbookWithSerializedCellCount( + root, + "cell-budget.xlsx", + io.limits.maxParsedCells + 1 + ); + assert.throws(() => io.readWorkbookFile(overBudgetPath), /XLSX_CELL_COUNT_LIMIT/); + }); + + await t.test("bounds patch, accept, CSV parse, and dense export work", (t) => { + const root = tempWorkspace(t, "excel-engine-operation-limits-"); + assert.throws( + () => engine.dispatch("createWorkbook", { + workspacePath: root, + rows: io.limits.excelMaxRows + 1, + }), + /EXCEL_SHEET_LIMIT/ + ); + + const created = engine.dispatch("createWorkbook", { workspacePath: root }); + const workbookId = created.meta.workbookId; + const sheetId = created.meta.activeSheetId; + assert.throws( + () => engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + sheetId, + expectedRevision: 0, + intent: "Reject an out-of-bounds cell", + cells: [{ row: io.limits.excelMaxRows, col: 0, value: 1 }], + }), + /EXCEL_CELL_LIMIT/ + ); + + const proposal = engine.dispatch("proposePatch", { + workspacePath: root, + workbookId, + sheetId, + expectedRevision: 0, + intent: "Bound selection expansion", + cells: [{ row: 0, col: 0, value: 1 }], + }); + assert.throws( + () => engine.dispatch("acceptProposal", { + workspacePath: root, + workbookId, + proposalId: proposal.proposal.id, + expectedRevision: 0, + cellRefs: [`A1:XFD${io.limits.excelMaxRows}`], + }), + /ACCEPT_CELL_LIMIT/ + ); + + assert.throws( + () => csv.parseCsv("a,b\nc,d", { maxCells: 3 }), + /CSV_CELL_COUNT_LIMIT/ + ); + + const sparse = store.createEmpty({ + rows: io.limits.excelMaxRows, + cols: io.limits.excelMaxColumns, + }); + sparse.sheets[0].cells[ + `${io.limits.excelMaxRows - 1},${io.limits.excelMaxColumns - 1}` + ] = { v: 1, t: "n" }; + assert.throws(() => io.sheetToMatrix(sparse.sheets[0]), /DENSE_EXPORT_CELL_LIMIT/); + }); + + await t.test("rejects oversized ZIP, CSV, and JSON sources before whole-file reads", (t) => { + const root = tempWorkspace(t, "excel-engine-source-size-"); + const oversized = [ + ["oversized.xlsx", io.limits.maxZipFileBytes, /ZIP_FILE_SIZE_LIMIT/], + ["oversized.csv", io.limits.maxCsvFileBytes, /CSV_FILE_SIZE_LIMIT/], + ["oversized.json", io.limits.maxJsonFileBytes, /JSON_FILE_SIZE_LIMIT/], + ]; + for (const [fileName, limit, expectedError] of oversized) { + const filePath = path.join(root, fileName); + writeSparseFile(filePath, limit + 1); + assert.throws(() => io.readWorkbookFile(filePath), expectedError); + } + }); + + await t.test("blocks direct, junction, symlink, and hard-link aliases of the imported source", (t) => { + const root = tempWorkspace(t, "excel-engine-source-alias-"); + const realDirectory = path.join(root, "real"); + fs.mkdirSync(realDirectory); + const sourcePath = writeInstrumentedExcelSource(realDirectory); + const workbook = io.readWorkbookFile(sourcePath); + const sourceHash = sha256(sourcePath); + + assert.throws( + () => io.writeWorkbookFile(workbook, sourcePath, { allowLossyRebuild: true }), + /SOURCE_TARGET_ALIAS_BLOCKED/ + ); + + const hardLinkPath = path.join(root, "source-hard-link.xlsx"); + fs.linkSync(sourcePath, hardLinkPath); + assert.throws( + () => io.writeWorkbookFile(workbook, hardLinkPath, { allowLossyRebuild: true }), + /SOURCE_TARGET_ALIAS_BLOCKED/ + ); + + const aliasDirectory = path.join(root, "directory-alias"); + fs.symlinkSync(realDirectory, aliasDirectory, process.platform === "win32" ? "junction" : "dir"); + assert.throws( + () => io.writeWorkbookFile(workbook, path.join(aliasDirectory, "source.xlsx"), { + allowLossyRebuild: true, + }), + /SOURCE_TARGET_ALIAS_BLOCKED/ + ); + const safeAliasCopyPath = path.join(aliasDirectory, "copy.xlsx"); + const safeAliasCopy = io.writeWorkbookFile(workbook, safeAliasCopyPath); + assert.equal(safeAliasCopy.path, safeAliasCopyPath); + assert.equal(fs.existsSync(path.join(realDirectory, "copy.xlsx")), true); + + const symlinkPath = path.join(root, "source-symlink.xlsx"); + try { + fs.symlinkSync(sourcePath, symlinkPath, "file"); + assert.throws( + () => io.writeWorkbookFile(workbook, symlinkPath, { allowLossyRebuild: true }), + /SOURCE_TARGET_ALIAS_BLOCKED/ + ); + } catch (error) { + if (error?.code !== "EPERM") throw error; + t.diagnostic("File symlink assertion skipped because this Windows account cannot create symlinks."); + } + assert.equal(sha256(sourcePath), sourceHash, "alias checks must leave the source unchanged"); + }); + + await t.test("restores source round-trip fidelity across structural undo and redo", (t) => { + const root = tempWorkspace(t, "excel-engine-fidelity-"); + const sourcePath = writeInstrumentedExcelSource(root, { withFormula: false }); + const opened = engine.dispatch("openWorkbook", { workspacePath: root, path: sourcePath }); + const workbookId = opened.meta.workbookId; + const sheetId = opened.meta.activeSheetId; + const inserted = engine.dispatch("insertRows", { + workspacePath: root, + workbookId, + sheetId, + at: 1, + expectedRevision: 0, + }); + assert.equal(inserted.meta.fidelity.canRoundTrip, false); + const undone = engine.dispatch("undo", { + workspacePath: root, + workbookId, + expectedRevision: 1, + }); + assert.equal(undone.meta.fidelity.canRoundTrip, true); + const redone = engine.dispatch("redo", { + workspacePath: root, + workbookId, + expectedRevision: 2, + }); + assert.equal(redone.meta.fidelity.canRoundTrip, false); + }); + + await t.test("guards stale formula CSV export and sanitizes text formula injection", (t) => { + const root = tempWorkspace(t, "excel-engine-csv-"); + const created = engine.dispatch("createWorkbook", { workspacePath: root }); + const workbookId = created.meta.workbookId; + const sheetId = created.meta.activeSheetId; + engine.dispatch("applyLocalPatch", { + workspacePath: root, + workbookId, + sheetId, + cells: [ + { row: 0, col: 0, value: "=2+2" }, + { row: 0, col: 1, value: "+cmd" }, + { row: 0, col: 2, value: "-cmd" }, + { row: 0, col: 3, value: "@SUM(A1:A2)" }, + { row: 0, col: 4, value: "\t=evil" }, + { row: 0, col: 5, value: "\r=evil" }, + { row: 0, col: 6, formula: "=1+1", value: 2 }, + ], + expectedRevision: 0, + }); + const csvPath = path.join(root, "safe.csv"); + assert.throws( + () => engine.dispatch("exportCsv", { + workspacePath: root, + workbookId, + sheetId, + path: csvPath, + expectedRevision: 1, + }), + /STALE_FORMULA_VALUES_ACK_REQUIRED/ + ); + assert.equal(fs.existsSync(csvPath), false); + + const exported = engine.dispatch("exportCsv", { + workspacePath: root, + workbookId, + sheetId, + path: csvPath, + acknowledgeStaleFormulaValues: true, + expectedRevision: 1, + }); + assert.equal(exported.formulaCount, 1); + assert.equal(exported.sanitizedCellCount, 6); + assert.match(exported.warning, /cached values .*may be stale/); + assert.match(exported.warning, /formula-injection prefixes/); + const csv = fs.readFileSync(csvPath, "utf8"); + assert.match(csv, /^'=2\+2,'\+cmd,'-cmd,'@SUM/); + assert.match(csv, /'\t=evil/); + assert.match(csv, /'\r=evil/); + + const uncached = engine.dispatch("createWorkbook", { + workspacePath: root, + title: "Uncached formula", + }); + engine.dispatch("applyLocalEdit", { + workspacePath: root, + workbookId: uncached.meta.workbookId, + sheetId: uncached.meta.activeSheetId, + row: 0, + col: 0, + formula: "=1+1", + expectedRevision: 0, + }); + assert.throws( + () => engine.dispatch("exportCsv", { + workspacePath: root, + workbookId: uncached.meta.workbookId, + sheetId: uncached.meta.activeSheetId, + acknowledgeStaleFormulaValues: true, + expectedRevision: 1, + }), + /UNCACHED_FORMULA_CSV_BLOCKED/ + ); + }); + + await t.test("requires revisions at mutation boundaries and isolates reload sessions by workspace", (t) => { + const rootA = tempWorkspace(t, "excel-engine-workspace-a-"); + const rootB = tempWorkspace(t, "excel-engine-workspace-b-"); + const created = engine.dispatch("createWorkbook", { workspacePath: rootA, title: "Revision guard" }); + assert.throws( + () => engine.dispatch("applyLocalEdit", { + workspacePath: rootA, + workbookId: created.meta.workbookId, + sheetId: created.meta.activeSheetId, + row: 0, + col: 0, + value: 1, + }), + /EXPECTED_REVISION_REQUIRED/ + ); + + const sharedId = "shared_workspace_book"; + const sourceA = writeInstrumentedExcelSource(rootA, { withFormula: false }); + const sourceB = writeInstrumentedExcelSource(rootB, { withFormula: false }); + const workbookA = io.readWorkbookFile(sourceA, { workbookId: sharedId, title: "Workspace A" }); + const workbookB = io.readWorkbookFile(sourceB, { workbookId: sharedId, title: "Workspace B" }); + workbookA.title = "Workspace A"; + workbookB.title = "Workspace B"; + store.saveJson(rootA, workbookA); + store.saveJson(rootB, workbookB); + engine.sessions.clear(); + + assert.equal( + engine.dispatch("getMeta", { workspacePath: rootA, workbookId: sharedId }).meta.sourcePath, + sourceA + ); + const reloadedB = engine.dispatch("reloadWorkbook", { + workspacePath: rootB, + workbookId: sharedId, + expectedRevision: 0, + }); + assert.equal(reloadedB.meta.sourcePath, sourceB); + assert.equal(reloadedB.meta.title, "Workspace B"); + }); + + await t.test("separates Agent workspace paths from user-confirmed Product App exports", (t) => { + const root = tempWorkspace(t, "excel-engine-path-policy-"); + const outside = tempWorkspace(t, "excel-engine-path-outside-"); + const externalSource = writeInstrumentedExcelSource(outside, { withFormula: false }); + + assert.throws( + () => engine.dispatch("openWorkbook", { + workspacePath: root, + path: externalSource, + __trustedConsumerKind: "agentComponent", + }), + /WORKSPACE_PATH_REQUIRED/ + ); + const openedByUser = engine.dispatch("openWorkbook", { + workspacePath: root, + path: externalSource, + __trustedConsumerKind: "productAppRuntime", + }); + assert.equal(openedByUser.meta.sourcePath, externalSource); + + const created = engine.dispatch("createWorkbook", { workspacePath: root, title: "Export guard" }); + const target = path.join(root, "existing-unrelated.xlsx"); + fs.writeFileSync(target, "unrelated user file", "utf8"); + assert.throws( + () => engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId: created.meta.workbookId, + path: target, + exportCopy: true, + expectedRevision: 0, + __trustedConsumerKind: "productAppRuntime", + }), + /EXISTING_TARGET_OVERWRITE_BLOCKED/ + ); + assert.equal(fs.readFileSync(target, "utf8"), "unrelated user file"); + assert.throws( + () => engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId: created.meta.workbookId, + path: target, + exportCopy: true, + expectedRevision: 0, + overwriteExisting: true, + __trustedConsumerKind: "agentComponent", + }), + /EXISTING_TARGET_OVERWRITE_BLOCKED/ + ); + assert.throws( + () => engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId: created.meta.workbookId, + path: target, + exportCopy: true, + expectedRevision: 0, + overwriteExisting: true, + expectedTargetFingerprint: { + algorithm: "sha256", + hash: "0".repeat(64), + size: 0, + mtimeMs: 0, + }, + __trustedConsumerKind: "productAppRuntime", + }), + /EXPORT_TARGET_CHANGED/ + ); + + engine.dispatch("saveWorkbook", { + workspacePath: root, + workbookId: created.meta.workbookId, + path: target, + exportCopy: true, + expectedRevision: 0, + overwriteExisting: true, + __trustedConsumerKind: "productAppRuntime", + }); + const persisted = store.loadJson(root, created.meta.workbookId); + assert.equal(persisted.lastExportPath, target); + assert.equal(persisted.lastExportFingerprint.algorithm, "sha256"); + assert.notEqual(fs.readFileSync(target, "utf8"), "unrelated user file"); + }); + + await t.test("migrates old stores and leaves no temporary file after atomic replacement", (t) => { + const root = tempWorkspace(t, "excel-engine-store-"); + const old = { + workbookId: "wb_old", + path: path.join(root, "old.xlsx"), + title: "Old", + sheets: [{ id: "s1", name: "Sheet1", rows: 5, cols: 5, cells: {} }], + activeSheetId: "s1", + dirty: true, + focus: { sheetId: "s1", a1: "A1", kind: "cell" }, + proposal: null, + }; + const migrated = store.normalizeWorkbook(old); + assert.equal(migrated.schemaVersion, 2); + assert.equal(migrated.revision, 0); + assert.equal(migrated.mode, "edit"); + assert.equal(migrated.sourceFormat, "xlsx"); + store.saveJson(root, migrated); + store.saveJson(root, { ...migrated, title: "Old 2" }); + assert.deepEqual(fs.readdirSync(store.workbookDir(root, migrated.workbookId)), ["workbook.json"]); + assert.equal(store.loadJson(root, migrated.workbookId).title, "Old 2"); + }); +}); diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/worker-protocol.test.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/worker-protocol.test.js new file mode 100644 index 00000000..c444c634 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/worker-protocol.test.js @@ -0,0 +1,44 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const test = require("node:test"); + +test("worker uses the host workspace and emits correlated daemon envelopes", (t) => { + const safeWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "excel-worker-safe-")); + const payloadWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "excel-worker-payload-")); + t.after(() => fs.rmSync(safeWorkspace, { recursive: true, force: true })); + t.after(() => fs.rmSync(payloadWorkspace, { recursive: true, force: true })); + + const workerPath = path.join(__dirname, "..", "worker.js"); + const request = { + bridgeId: "builtin-excel-runtime", + runId: "worker-contract-run", + action: "createWorkbook", + workspacePath: safeWorkspace, + input: { + workspacePath: payloadWorkspace, + title: "Host-bound workbook", + __trustedConsumerKind: "agentComponent", + }, + consumer: { kind: "productAppRuntime", id: "surface-test" }, + }; + const stdout = execFileSync(process.execPath, [workerPath], { + input: `${JSON.stringify(request)}\n`, + encoding: "utf8", + }); + const frames = stdout.trim().split(/\r?\n/).map((line) => JSON.parse(line)); + + assert.equal(frames.length, 2); + for (const frame of frames) { + assert.equal(frame.bridgeId, request.bridgeId); + assert.equal(frame.runId, request.runId); + assert.ok(frame.event && typeof frame.event.type === "string"); + } + assert.equal(frames[0].event.type, "run.started"); + assert.equal(frames[1].event.type, "run.completed"); + assert.equal(frames[1].event.output.meta.title, "Host-bound workbook"); + assert.equal(fs.existsSync(path.join(safeWorkspace, ".sparo_os", "excel-live")), true); + assert.equal(fs.existsSync(path.join(payloadWorkspace, ".sparo_os", "excel-live")), false); +}); diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/worker.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/worker.js new file mode 100644 index 00000000..ebd57754 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/worker.js @@ -0,0 +1,98 @@ +const { emit } = require("./src/protocol"); +const { dispatch } = require("./src/engine"); + +/** + * Persistent daemon loop for the Sparo Excel Engine. + * + * Hosts that still send a single JSON blob and close stdin keep working: + * we process that one request and exit when stdin ends with no more lines. + * Daemon hosts send one NDJSON request per line and keep the process alive + * so workbook sessions stay warm across edits / agent tool calls. + */ +function handleRequest(request) { + const action = request.action; + const rawInput = request.input && typeof request.input === "object" && !Array.isArray(request.input) + ? request.input + : {}; + const consumer = request.consumer && typeof request.consumer === "object" + ? request.consumer + : {}; + const input = { + ...rawInput, + // The host envelope owns these values. A bridge action payload must not + // replace the workspace or consumer authority selected by the host. + workspacePath: request.workspacePath || request.workspace_path || null, + __trustedConsumerKind: consumer.kind || null, + }; + const runId = request.runId || request.run_id || `excel-${Date.now()}`; + const bridgeId = request.bridgeId || request.bridge_id || "builtin-excel-runtime"; + const emitEvent = (event) => emit({ bridgeId, runId, event }); + emitEvent({ type: "run.started", run_id: runId }); + try { + const output = dispatch(action, input); + emitEvent({ type: "run.completed", output }); + } catch (error) { + emitEvent({ + type: "run.failed", + error: { + message: error instanceof Error ? error.message : String(error), + }, + }); + } +} + +function main() { + let buffer = ""; + let sawRequest = false; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + if (!line) continue; + sawRequest = true; + let request; + try { + request = JSON.parse(line); + } catch (error) { + process.stderr.write( + `Invalid Bridge worker request: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + } + handleRequest(request); + } + }); + process.stdin.on("end", () => { + const leftover = buffer.trim(); + if (leftover) { + try { + sawRequest = true; + handleRequest(JSON.parse(leftover)); + } catch (error) { + process.stderr.write( + `Invalid Bridge worker request: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + return; + } + } + // One-shot hosts close stdin after a single request; exit cleanly. + if (!sawRequest) { + process.stderr.write("No Bridge worker request received on stdin\n"); + process.exitCode = 1; + } + process.exit(process.exitCode || 0); + }); + process.stdin.on("error", (error) => { + process.stderr.write( + `Bridge worker stdin failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + }); +} + +main(); diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/component.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/component.json new file mode 100644 index 00000000..12ae47f9 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/component.json @@ -0,0 +1,81 @@ +{ + "id": "builtin-excel-live-surface", + "kind": "surface", + "name": "Excel Live Workbench", + "description": "The app-owned revisioned spreadsheet grid, grounded focus bar, proposal inspector, undo history, and source-safe export surface for Excel Live.", + "packageSource": "appPrivate", + "ownerApp": { + "appId": "builtin-excel-live", + "appVersion": "1.0.0" + }, + "capabilities": [ + { + "id": "interactiveSurface", + "title": "Interactive surface", + "description": "Hosts the Excel Live primary spreadsheet workbench.", + "actions": [ + "open", + "createWorkbook", + "saveWorkbook", + "exportCopy", + "setFocus", + "acceptProposal", + "rejectProposal", + "undo", + "redo", + "applyLocalPatch" + ] + } + ], + "permissions": [ + { + "kind": "fs", + "summary": "Reads and writes workbook files under the workspace via the Excel engine bridge.", + "scopes": [ + "{workspace}", + "{appdata}" + ] + } + ], + "usedByApps": [ + "builtin-excel-live" + ], + "visibility": "appDependency", + "dependencies": [ + { + "componentId": "excel-live-agent", + "kind": "agent", + "source": "private", + "role": "excelAgent", + "capabilities": [ + "inspectWorkbook", + "transformRange", + "authorWorkbook" + ] + }, + { + "componentId": "builtin-excel-runtime", + "kind": "bridge", + "source": "private", + "role": "excelEngine", + "capabilities": [ + "sparo.excelEngine" + ] + }, + { + "componentId": "product-app-runtime-host", + "kind": "runtime", + "source": "shared", + "role": "runtimeHost", + "version": "^1.0.0", + "capabilities": [ + "uiWorker" + ] + } + ], + "implementationRef": "app://builtin-excel-live@1.0.0/surfaces/builtin-excel-live-surface", + "usesCapabilities": [ + "session.read", + "component.run" + ] +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/esm_dependencies.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/esm_dependencies.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/esm_dependencies.json @@ -0,0 +1 @@ +[] diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/index.html b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/index.html new file mode 100644 index 00000000..8cccbd9e --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/index.html @@ -0,0 +1,6 @@ +
+
+
+

Loading Excel Live...

+
+
diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/source_manifest.json b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/source_manifest.json new file mode 100644 index 00000000..05606755 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/source_manifest.json @@ -0,0 +1,6 @@ +{ + "uiEntry": "ui.js", + "workerEntry": "worker.js", + "styleEntries": ["style.css"], + "buildMode": "nativeEsm" +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/a1.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/a1.js new file mode 100644 index 00000000..3776346c --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/a1.js @@ -0,0 +1,106 @@ +function colToIndex(col) { + if (col == null || typeof col !== 'string' || !col.length) { + throw new Error('Column letter is required'); + } + const upper = col.toUpperCase(); + if (!/^[A-Z]+$/.test(upper)) { + throw new Error(`Invalid column letters: ${col}`); + } + let index = 0; + for (let i = 0; i < upper.length; i += 1) { + index = index * 26 + (upper.charCodeAt(i) - 64); + } + return index - 1; +} + +function indexToCol(index) { + if (!Number.isInteger(index) || index < 0) { + throw new Error(`Invalid column index: ${index}`); + } + let n = index + 1; + let col = ''; + while (n > 0) { + const rem = (n - 1) % 26; + col = String.fromCharCode(65 + rem) + col; + n = Math.floor((n - 1) / 26); + } + return col; +} + +function parseCellToken(token) { + const match = String(token || '') + .trim() + .toUpperCase() + .match(/^([A-Z]+)(\d+)$/); + if (!match) { + throw new Error(`Invalid A1 cell reference: ${token}`); + } + return { + c: colToIndex(match[1]), + r: Number(match[2]) - 1, + }; +} + +/** + * Parse an A1 range or cell. + * Accepts: "A1", "A1:B10", "Sheet1!A1:B2", "'My Sheet'!A1" + */ +function parseA1(ref) { + if (ref == null || String(ref).trim() === '') { + throw new Error('A1 reference is required'); + } + let raw = String(ref).trim(); + let sheet = null; + + const bang = raw.lastIndexOf('!'); + if (bang >= 0) { + sheet = raw.slice(0, bang).trim(); + raw = raw.slice(bang + 1).trim(); + if ( + (sheet.startsWith("'") && sheet.endsWith("'")) || + (sheet.startsWith('"') && sheet.endsWith('"')) + ) { + sheet = sheet.slice(1, -1).replace(/''/g, "'"); + } + } + + const parts = raw.split(':'); + if (parts.length === 1) { + const cell = parseCellToken(parts[0]); + return { sheet, r1: cell.r, c1: cell.c, r2: cell.r, c2: cell.c }; + } + if (parts.length !== 2) { + throw new Error(`Invalid A1 range: ${ref}`); + } + const a = parseCellToken(parts[0]); + const b = parseCellToken(parts[1]); + return { + sheet, + r1: Math.min(a.r, b.r), + c1: Math.min(a.c, b.c), + r2: Math.max(a.r, b.r), + c2: Math.max(a.c, b.c), + }; +} + +function formatA1(r1, c1, r2 = r1, c2 = c1) { + if ( + !Number.isInteger(r1) || + !Number.isInteger(c1) || + !Number.isInteger(r2) || + !Number.isInteger(c2) + ) { + throw new Error('formatA1 requires integer row/col indices'); + } + const top = Math.min(r1, r2); + const left = Math.min(c1, c2); + const bottom = Math.max(r1, r2); + const right = Math.max(c1, c2); + const start = `${indexToCol(left)}${top + 1}`; + if (top === bottom && left === right) { + return start; + } + return `${start}:${indexToCol(right)}${bottom + 1}`; +} + +export { colToIndex, formatA1, indexToCol, parseA1 }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/actions.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/actions.js new file mode 100644 index 00000000..045c791b --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/actions.js @@ -0,0 +1,958 @@ +import { callExcel } from './backend.js'; +import { + commitModeToBackend, + pinFocus as pinFocusAction, + setFocusFromSelection, + syncFocusToHost, +} from './focus.js'; +import { + applyMeta, + activeSheet, + cellEditValue, + focusCacheCoverage, + mergeReadIntoCache, + normalizeFocusRange, +} from './model.js'; +import { acceptProposal, refreshProposal, rejectProposal } from './proposal.js'; +import { refreshViewportCells, resetScrollPosition, scrollToCell } from './grid.js'; +import { state, invalidateCells } from './state.js'; +import { formatA1 } from './a1.js'; +import { cellCacheKey, normalizePathForCompare, runtime } from './util.js'; +import { t } from './i18n.js'; +import { render } from './render.js'; +import { showToast, updateChrome } from './views.js'; +import { buildSelectionStyleCells, exportCopyPlan } from './interaction.js'; + +function setLoading(loading, status = null) { + state.loading = loading; + if (status != null) state.status = status; + updateChrome(); +} + +function setError(error) { + const message = error?.message || (typeof error === 'string' ? error : null); + if (message) showToast('error', message); +} + +function writeBoundaryBlocked() { + if (state.loading || state.dialogPending) { + setError(t('operationPending')); + return true; + } + if (state.modePending) { + setError(t('modeChangePending')); + return true; + } + if (state.mode === 'inspect') { + setError(t('inspectEditBlocked')); + return true; + } + return false; +} + +const MAX_FORMAT_PROPOSAL_CELLS = 5000; + +async function proposeSelectionFormat(change = {}) { + if (writeBoundaryBlocked() || !state.workbookId) return false; + if (state.mode === 'inspect') { + showToast('error', t('inspectEditBlocked')); + return false; + } + if (state.proposal) { + showToast('error', t('formatProposalExists')); + return false; + } + const sheet = activeSheet(); + const focus = normalizeFocusRange(state.focus, sheet); + if (!sheet) return false; + const cellCount = focus.rowCount * focus.columnCount; + if (cellCount > MAX_FORMAT_PROPOSAL_CELLS) { + showToast('error', t('formatRangeTooLarge', { count: MAX_FORMAT_PROPOSAL_CELLS })); + return false; + } + const styleRole = String(change.styleRole || '').trim() || null; + const style = change.style && typeof change.style === 'object' ? change.style : null; + if (!styleRole && !style) return false; + const cells = buildSelectionStyleCells(focus, { styleRole, style }, MAX_FORMAT_PROPOSAL_CELLS); + const intent = change.intent || t('formatIntent', { range: `${sheet.name}!${focus.a1}` }); + setLoading(true); + try { + const result = await callExcel('proposePatch', { + workbookId: state.workbookId, + sheetId: sheet.id, + expectedRevision: state.meta?.revision ?? undefined, + baseRevision: state.meta?.revision ?? undefined, + intent, + cells, + }); + if (result?.meta) applyMeta(result.meta); + await refreshProposal(); + if (!state.proposal) throw new Error(t('formatProposalMissing')); + state.status = t('formatProposalReady'); + render(); + return true; + } catch (error) { + showToast('error', error?.message || t('formatProposalFailed')); + return false; + } finally { + setLoading(false); + } +} + +function beginDialogBoundary() { + if (state.loading || state.dialogPending || state.modePending) { + setError(t(state.modePending ? 'modeChangePending' : 'operationPending')); + return false; + } + state.dialogPending = true; + updateChrome(); + return true; +} + +function endDialogBoundary() { + state.dialogPending = false; + updateChrome(); +} + +function samePath(a, b) { + if (!a || !b) return false; + return normalizePathForCompare(a) === normalizePathForCompare(b); +} + +function staleFormulaExportRisk(meta = state.meta) { + const calculation = meta?.calculationStatus; + if (calculation == null) return false; + const token = String( + typeof calculation === 'string' + ? calculation + : calculation.status || calculation.state || calculation.kind || '', + ).trim().toLowerCase().replaceAll('_', '-'); + const formulaCount = typeof calculation === 'object' + ? Number(calculation.formulaCount ?? calculation.formula_count) + : Number.NaN; + const hasFormulas = typeof calculation === 'object' && typeof calculation.hasFormulas === 'boolean' + ? calculation.hasFormulas + : Number.isFinite(formulaCount) + ? formulaCount > 0 + : !['current', 'not-required'].includes(token); + return hasFormulas && !['current', 'not-required'].includes(token); +} + +function applyHistory(result) { + const history = result?.history && typeof result.history === 'object' && !Array.isArray(result.history) + ? result.history + : result && typeof result === 'object' + ? result + : {}; + const entries = Array.isArray(history.entries) + ? history.entries + : Array.isArray(history.items) + ? history.items + : Array.isArray(result?.entries) + ? result.entries + : Array.isArray(result?.history) + ? result.history + : []; + state.history = { + canUndo: Boolean(history.canUndo ?? history.can_undo ?? entries.some((entry) => entry?.undoable !== false)), + canRedo: Boolean(history.canRedo ?? history.can_redo), + entries, + }; +} + +async function refreshHistory() { + if (!state.workbookId) { + applyHistory(null); + updateChrome(); + return state.history; + } + try { + const result = await callExcel('getHistory', { workbookId: state.workbookId }); + applyHistory(result); + } catch (_error) { + // History is an enhancement; an unavailable history endpoint must not + // block the workbook itself. + applyHistory(null); + } + updateChrome(); + return state.history; +} + +async function adoptWorkbookResult(result, statusKey, options = {}) { + const resultMeta = result?.meta; + const hasResultPath = resultMeta && ( + Object.prototype.hasOwnProperty.call(resultMeta, 'path') + || Object.prototype.hasOwnProperty.call(resultMeta, 'sourcePath') + ); + const meta = resultMeta && options.sourcePath && !hasResultPath + ? { ...resultMeta, path: options.sourcePath } + : resultMeta; + applyMeta(meta); + const persistedMode = state.mode; + const modeOverride = ['inspect', 'edit', 'author'].includes(options.modeOverride) + ? options.modeOverride + : null; + let modeOverrideCommitted = false; + if (modeOverride && modeOverride !== persistedMode) { + try { + await commitModeToBackend(modeOverride); + state.mode = modeOverride; + modeOverrideCommitted = true; + } catch (_error) { + state.mode = persistedMode; + showToast('error', t('modeSwitchFailed', { mode: t(`mode${persistedMode[0].toUpperCase()}${persistedMode.slice(1)}`) })); + } + } + if (result?.meta?.focus) { + state.focus = normalizeFocusRange(result.meta.focus); + } else if (!state.focus?.a1) { + setFocusFromSelection(0, 0, 0, 0, { sync: false }); + } + invalidateCells(); + resetScrollPosition(); + if (options.seedViewport && result?.viewport) { + mergeReadIntoCache(result.viewport); + } + await refreshProposal(); + await refreshViewportCells(); + await refreshHistory(); + syncFocusToHost({ force: true, broadcast: !modeOverrideCommitted }); + state.status = t(statusKey); + render(); +} + +async function createWorkbook(options = {}) { + if (state.loading || state.dialogPending) { + setError(t('operationPending')); + return; + } + if (state.modePending) { + setError(t('modeChangePending')); + return; + } + if (!state.workspacePath) { + setError(t('noWorkspace')); + return; + } + setLoading(true, t('loading')); + try { + const result = await callExcel('createWorkbook', { + title: options.title || t('untitled'), + rows: options.rows, + cols: options.cols, + sheetName: options.sheetName, + }); + await adoptWorkbookResult(result, 'statusCreated', { + seedViewport: true, + modeOverride: 'author', + }); + } catch (error) { + setError(error?.message || t('createFailed')); + } finally { + setLoading(false); + } +} + +async function openWorkbookAtPath(filePath, options = {}) { + if (state.loading || (state.dialogPending && options.fromDialog !== true)) { + setError(t('operationPending')); + return; + } + if (state.modePending) { + setError(t('modeChangePending')); + return; + } + if (!state.workspacePath) { + setError(t('noWorkspace')); + return; + } + if (!filePath) return; + if (state.workbookId && samePath(state.path, filePath)) return; + setLoading(true, t('loading')); + try { + const result = await callExcel('openWorkbook', { path: filePath }); + await adoptWorkbookResult(result, 'statusOpened', { + seedViewport: true, + sourcePath: filePath, + }); + } catch (error) { + setError(error?.message || t('openFailed')); + } finally { + setLoading(false); + } +} + +async function resumeWorkbookById(workbookId, statusKey = 'statusResumed') { + setLoading(true, t('loading')); + try { + const result = await callExcel('getMeta', { workbookId }); + if (!result?.meta) return false; + await adoptWorkbookResult(result, statusKey); + return true; + } catch (_error) { + return false; + } finally { + setLoading(false); + } +} + +async function resumeLatestWorkbook() { + try { + const list = await callExcel('listWorkbooks', {}); + const newest = Array.isArray(list?.workbooks) ? list.workbooks[0] : null; + if (!newest?.workbookId) return false; + return await resumeWorkbookById(newest.workbookId); + } catch (_error) { + return false; + } +} + +async function openFile() { + if (!beginDialogBoundary()) return; + const host = runtime(); + if (!host.dialog?.open) { + setError('dialog.open unavailable'); + endDialogBoundary(); + return; + } + try { + const selected = await host.dialog.open({ + title: t('openDialogTitle'), + multiple: false, + filters: [ + { name: 'Excel', extensions: ['xlsx', 'xlsm', 'csv'] }, + ], + }); + const path = Array.isArray(selected) ? selected[0] : selected; + if (!path) return; + await openWorkbookAtPath(path, { fromDialog: true }); + } catch (error) { + setError(error?.message || t('openFailed')); + } finally { + endDialogBoundary(); + } +} + +async function saveWorkbook() { + if (!state.workbookId) return; + if (!beginDialogBoundary()) return; + try { + const host = runtime(); + const sourcePath = state.path || state.meta?.path || null; + if (!host.dialog?.save) { + setError(t('exportCopyDialogMissing')); + return; + } + const fidelity = state.meta?.fidelity; + const exportPlan = exportCopyPlan(sourcePath, fidelity); + const { + macroEnabled, + acknowledgeFidelityLoss, + lossyMacroRebuild, + extension, + } = exportPlan; + const fidelityPrompt = lossyMacroRebuild + ? t('fidelityXlsmToXlsxConfirm') + : t('fidelityExportConfirm'); + if (acknowledgeFidelityLoss && !window.confirm(fidelityPrompt)) { + state.status = t('statusExportCancelled'); + updateChrome(); + return; + } + const safeTitle = String(state.meta?.title || 'workbook') + .replace(/\.(xlsx|xlsm|csv)$/i, '') + .replace(/[\\/:*?"<>|]+/g, '-'); + const targetPath = await host.dialog.save({ + title: t('saveDialogTitle'), + defaultPath: `${safeTitle}-copy.${extension}`, + filters: [{ name: extension === 'xlsm' ? t('excelMacroFile') : 'Excel', extensions: [extension] }], + }); + if (!targetPath) return; + if (sourcePath && samePath(sourcePath, targetPath)) { + setError(t('exportCopySourceBlocked')); + return; + } + if (!String(targetPath).toLowerCase().endsWith(`.${extension}`)) { + setError(t(extension === 'xlsm' ? 'exportCopyMacroExtension' : 'exportCopyXlsxExtension')); + return; + } + setLoading(true); + try { + const result = await callExcel('saveWorkbook', { + workbookId: state.workbookId, + path: targetPath, + exportCopy: true, + expectedRevision: state.meta?.revision ?? undefined, + acknowledgeFidelityLoss, + // The native save dialog is the explicit user confirmation boundary. + // The engine still fingerprints an existing target and rechecks it + // immediately before the atomic replacement. + overwriteExisting: true, + }); + applyMeta(result?.meta); + // Exporting a copy must never retarget the live workbook to the new path. + state.path = sourcePath; + if (state.meta) state.meta = { ...state.meta, path: sourcePath }; + state.status = t('statusExportedCopy'); + } catch (error) { + const message = String(error?.message || ''); + if (message.includes('LOSSY_XLSM_BLOCKED')) { + setError(t('exportCopyMacroStructureBlocked')); + } else if (message.includes('FIDELITY_ACK_REQUIRED')) { + setError(t('exportCopyFidelityAckRequired')); + } else { + setError(message || t('saveFailed')); + } + } finally { + setLoading(false); + } + } finally { + endDialogBoundary(); + } +} + +async function exportCsv() { + if (!state.workbookId) return; + if (!beginDialogBoundary()) return; + try { + const host = runtime(); + if (!host.dialog?.save) { + setError(t('exportCopyDialogMissing')); + return; + } + const acknowledgeStaleFormulaValues = staleFormulaExportRisk(); + if (acknowledgeStaleFormulaValues && !window.confirm(t('staleFormulaCsvConfirm'))) { + state.status = t('statusExportCancelled'); + updateChrome(); + return; + } + const outPath = await host.dialog.save({ + title: t('exportDialogTitle'), + defaultPath: `${state.meta?.title || 'sheet'}-copy.csv`, + filters: [{ name: 'CSV', extensions: ['csv'] }], + }); + if (!outPath) return; + if (state.path && samePath(state.path, outPath)) { + setError(t('exportCopySourceBlocked')); + return; + } + setLoading(true); + try { + const result = await callExcel('exportCsv', { + workbookId: state.workbookId, + sheetId: state.activeSheetId, + path: outPath || undefined, + expectedRevision: state.meta?.revision ?? undefined, + acknowledgeStaleFormulaValues, + overwriteExisting: true, + }); + const sanitizedCellCount = Number( + result?.sanitizedCellCount + ?? result?.sanitized_cell_count + ?? result?.warning?.sanitizedCellCount + ?? 0, + ); + state.status = sanitizedCellCount > 0 + ? t('statusExportedSanitized', { count: sanitizedCellCount }) + : t('statusExported'); + } catch (error) { + const message = String(error?.message || ''); + setError( + message.includes('UNCACHED_FORMULA_CSV_BLOCKED') + ? t('uncachedFormulaCsvBlocked') + : message.includes('STALE_FORMULA_VALUES_ACK_REQUIRED') + ? t('staleFormulaCsvAckRequired') + : message || t('exportFailed'), + ); + } finally { + setLoading(false); + } + } finally { + endDialogBoundary(); + } +} + +async function switchSheet(sheetId) { + if (!state.workbookId || !sheetId || sheetId === state.activeSheetId) return; + if (state.loading || state.dialogPending) { + setError(t('operationPending')); + return; + } + if (state.modePending) { + setError(t('modeChangePending')); + return; + } + // Optimistic UI: switch tabs immediately, then hydrate from the engine. + // Avoid a full-screen loading bar for a single sheet change. + const previousSheetId = state.activeSheetId; + setLoading(true); + state.activeSheetId = sheetId; + state.lastSheetTabsKey = null; + invalidateCells(); + resetScrollPosition(); + setFocusFromSelection(0, 0, 0, 0, { sync: false }); + render(); + try { + const result = await callExcel('switchSheet', { + workbookId: state.workbookId, + sheetId, + }); + applyMeta(result?.meta); + if (result?.viewport) mergeReadIntoCache(result.viewport); + await refreshProposal(); + await refreshViewportCells(); + syncFocusToHost({ force: true, broadcast: true }); + } catch (error) { + state.activeSheetId = previousSheetId; + state.lastSheetTabsKey = null; + setError(error?.message || t('switchFailed')); + } finally { + setLoading(false); + render(); + } +} + +async function addSheet() { + if (!state.workbookId) return; + if (writeBoundaryBlocked()) return; + setLoading(true); + try { + const result = await callExcel('addSheet', { + workbookId: state.workbookId, + expectedRevision: state.meta?.revision ?? undefined, + }); + applyMeta(result?.meta); + invalidateCells(); + resetScrollPosition(); + await refreshProposal(); + await refreshViewportCells(); + await refreshHistory(); + syncFocusToHost({ force: true }); + } catch (error) { + setError(error?.message || t('addSheetFailed')); + } finally { + setLoading(false); + render(); + } +} + +async function askAboutFocus() { + const sheet = activeSheet(); + const focus = normalizeFocusRange(state.focus); + const prompt = t('askPrompt', { + sheet: sheet?.name || 'Sheet', + a1: focus.a1, + title: state.meta?.title || t('untitled'), + }); + syncFocusToHost({ force: true }); + const host = runtime(); + if (typeof host.host?.fillChatInput === 'function') { + await host.host.fillChatInput(prompt); + } else if (typeof host.fillChatInput === 'function') { + await host.fillChatInput(prompt); + } + state.status = t('statusAsked'); + updateChrome(); +} + +async function insertRow() { + if (!state.workbookId) return; + if (writeBoundaryBlocked()) return; + const focus = normalizeFocusRange(state.focus); + setLoading(true); + try { + const result = await callExcel('insertRows', { + workbookId: state.workbookId, + sheetId: state.activeSheetId, + at: focus.r1, + count: 1, + expectedRevision: state.meta?.revision ?? undefined, + }); + applyMeta(result?.meta); + invalidateCells(); + await refreshProposal(); + await refreshViewportCells(); + await refreshHistory(); + } catch (error) { + setError(error?.message || t('insertFailed')); + } finally { + setLoading(false); + } + render(); +} + +async function insertColumn() { + if (!state.workbookId) return; + if (writeBoundaryBlocked()) return; + const focus = normalizeFocusRange(state.focus); + setLoading(true); + try { + const result = await callExcel('insertColumns', { + workbookId: state.workbookId, + sheetId: state.activeSheetId, + at: focus.c1, + count: 1, + expectedRevision: state.meta?.revision ?? undefined, + }); + applyMeta(result?.meta); + invalidateCells(); + await refreshProposal(); + await refreshViewportCells(); + await refreshHistory(); + } catch (error) { + setError(error?.message || t('insertFailed')); + } finally { + setLoading(false); + } + render(); +} + +async function pinFocus() { + try { + await pinFocusAction(); + } catch (error) { + setError(error?.message || String(error)); + } + updateChrome(); +} + +async function acceptProposalAction() { + const accepted = await acceptProposal(); + if (accepted) await refreshHistory(); + render(); +} + +async function rejectProposalAction() { + const rejected = await rejectProposal(); + if (rejected) await refreshHistory(); + render(); +} + +async function jumpToProposal() { + const proposal = state.proposal; + if (!proposal?.a1) return; + try { + if (proposal.sheetId && proposal.sheetId !== state.activeSheetId) { + await switchSheet(proposal.sheetId); + } + const focus = normalizeFocusRange({ a1: proposal.a1, sheetId: proposal.sheetId }); + setFocusFromSelection(focus.r1, focus.c1, focus.r2, focus.c2, { + kind: focus.kind, + force: true, + }); + scrollToCell(focus.r1, focus.c1); + } catch (_error) { + // Ignore malformed proposal ranges. + } +} + +const MAX_CLIPBOARD_CELLS = 20000; + +function encodeTsvCell(value) { + const text = String(value ?? ''); + if (!/[\t\r\n"]/.test(text)) return text; + return `"${text.replaceAll('"', '""')}"`; +} + +function parseTsv(text) { + const rows = []; + let row = []; + let value = ''; + let quoted = false; + const source = String(text ?? ''); + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (quoted) { + if (char === '"' && source[index + 1] === '"') { + value += '"'; + index += 1; + } else if (char === '"') { + quoted = false; + } else { + value += char; + } + continue; + } + if (char === '"' && value === '') { + quoted = true; + } else if (char === '\t') { + row.push(value); + value = ''; + } else if (char === '\n' || char === '\r') { + if (char === '\r' && source[index + 1] === '\n') index += 1; + row.push(value); + rows.push(row); + row = []; + value = ''; + } else { + value += char; + } + } + row.push(value); + rows.push(row); + while (rows.length > 1 && rows.at(-1)?.length === 1 && rows.at(-1)?.[0] === '') { + rows.pop(); + } + return rows; +} + +async function writeClipboardText(text) { + const host = runtime(); + if (typeof host.clipboard?.writeText === 'function') { + await host.clipboard.writeText(text); + return; + } + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + throw new Error(t('clipboardUnavailable')); +} + +async function readClipboardText() { + const host = runtime(); + if (typeof host.clipboard?.readText === 'function') { + return host.clipboard.readText(); + } + if (navigator.clipboard?.readText) { + return navigator.clipboard.readText(); + } + throw new Error(t('clipboardUnavailable')); +} + +async function hydrateFocusForClipboard(focus, sheet) { + const total = focus.rowCount * focus.columnCount; + if (total > MAX_CLIPBOARD_CELLS) { + throw new Error(t('clipboardRangeTooLarge', { count: MAX_CLIPBOARD_CELLS })); + } + let coverage = focusCacheCoverage(focus, sheet); + if (coverage.complete) return; + const result = await callExcel('readRange', { + workbookId: state.workbookId, + sheetId: sheet.id, + a1: focus.a1, + maxCells: total, + }); + mergeReadIntoCache(result); + coverage = focusCacheCoverage(focus, sheet); + if (!coverage.complete) throw new Error(t('clipboardRangeIncomplete')); +} + +async function copySelection() { + if (!state.workbookId) return; + if (state.loading || state.dialogPending || state.modePending) { + setError(t(state.modePending ? 'modeChangePending' : 'operationPending')); + return; + } + const sheet = activeSheet(); + if (!sheet) return; + const focus = normalizeFocusRange(state.focus, sheet); + setLoading(true, t('copying')); + try { + await hydrateFocusForClipboard(focus, sheet); + const rows = []; + for (let row = focus.r1; row <= focus.r2; row += 1) { + const values = []; + for (let col = focus.c1; col <= focus.c2; col += 1) { + const cell = state.cells.get(cellCacheKey(sheet.id, row, col)); + values.push(encodeTsvCell(cellEditValue(cell))); + } + rows.push(values.join('\t')); + } + await writeClipboardText(rows.join('\r\n')); + state.status = t('statusCopied', { count: focus.rowCount * focus.columnCount }); + updateChrome(); + } catch (error) { + setError(error?.message || t('copyFailed')); + } finally { + setLoading(false); + } +} + +async function pasteSelection() { + if (!state.workbookId) return; + if (writeBoundaryBlocked()) return; + const sheet = activeSheet(); + if (!sheet) return; + const focus = normalizeFocusRange(state.focus, sheet); + try { + const text = await readClipboardText(); + if (!String(text || '').length) return; + const values = parseTsv(text); + const width = values.reduce((max, row) => Math.max(max, row.length), 0); + const cellCount = values.length * width; + if (cellCount > MAX_CLIPBOARD_CELLS) { + throw new Error(t('clipboardRangeTooLarge', { count: MAX_CLIPBOARD_CELLS })); + } + const endRow = focus.r1 + values.length - 1; + const endCol = focus.c1 + Math.max(0, width - 1); + setLoading(true, t('pasting')); + const result = await callExcel('applyLocalPatch', { + workbookId: state.workbookId, + sheetId: sheet.id, + a1: formatA1(focus.r1, focus.c1, endRow, endCol), + values, + baseRevision: state.meta?.revision ?? undefined, + expectedRevision: state.meta?.revision ?? undefined, + }); + applyMeta(result?.meta); + invalidateCells(); + if (result?.viewport) mergeReadIntoCache(result.viewport); + await refreshProposal(); + await refreshViewportCells(); + await refreshHistory(); + setFocusFromSelection(focus.r1, focus.c1, endRow, endCol, { force: true }); + syncFocusToHost({ force: true, broadcast: true }); + state.status = t('statusPasted', { count: cellCount }); + render(); + } catch (error) { + setError(error?.message || t('pasteFailed')); + } finally { + setLoading(false); + } +} + +async function applyHistoryAction(action, statusKey) { + if (!state.workbookId || state.loading) return; + if (writeBoundaryBlocked()) return; + const allowed = action === 'undo' ? state.history.canUndo : state.history.canRedo; + if (!allowed) return; + setLoading(true); + try { + const result = await callExcel(action, { + workbookId: state.workbookId, + revision: state.meta?.revision ?? undefined, + expectedRevision: state.meta?.revision ?? undefined, + }); + applyMeta(result?.meta); + invalidateCells(); + if (result?.viewport) mergeReadIntoCache(result.viewport); + await refreshProposal(); + await refreshViewportCells(); + await refreshHistory(); + syncFocusToHost({ force: true, broadcast: true }); + state.status = t(statusKey); + render(); + } catch (error) { + setError(error?.message || t(action === 'undo' ? 'undoFailed' : 'redoFailed')); + } finally { + setLoading(false); + } +} + +async function undoWorkbook() { + return applyHistoryAction('undo', 'statusUndone'); +} + +async function redoWorkbook() { + return applyHistoryAction('redo', 'statusRedone'); +} + +async function ensureWorkbook(payload = {}) { + if (!state.workspacePath) { + state.bootDone = true; + setError(t('noWorkspace')); + render(); + return; + } + const launchPath = + payload.path || + payload.workbookPath || + payload.filePath || + payload.launchPath || + payload.workbench?.path || + state.pendingLaunchPath || + null; + state.pendingLaunchPath = null; + + try { + if (launchPath) { + await openWorkbookAtPath(launchPath); + return; + } + if (state.workbookId) return; + const resumed = await resumeLatestWorkbook(); + if (!resumed && !state.workbookId) { + await createWorkbook(); + } + } finally { + state.bootDone = true; + render(); + } +} + +/** + * Refresh surface state after the chat agent touched the workbook through + * the shared Excel engine (proposals, edits, sheet or workbook changes). + */ +async function refreshFromEngine(targetWorkbookId = null) { + if (!state.workspacePath) return; + if (state.editing || state.editCommitPromise) { + state.pendingAgentRefresh = { targetWorkbookId }; + return; + } + if (state.loading || state.dialogPending || state.modePending) { + if (state.agentRefreshTimer) clearTimeout(state.agentRefreshTimer); + state.agentRefreshTimer = setTimeout(() => { + state.agentRefreshTimer = null; + void refreshFromEngine(targetWorkbookId); + }, 400); + return; + } + setLoading(true); + try { + const target = typeof targetWorkbookId === 'string' ? targetWorkbookId.trim() : ''; + if (state.workbookId) { + if (target && target !== state.workbookId) { + return; + } + } else if (target) { + await resumeWorkbookById(target, 'statusAgentUpdated'); + return; + } else { + // Tool completion without an explicit workbook id is not a safe basis + // for rebinding when multiple Excel Live instances share a workspace. + return; + } + const metaResult = await callExcel('getMeta', { workbookId: state.workbookId }); + applyMeta(metaResult?.meta); + await refreshProposal(); + await refreshHistory(); + invalidateCells(); + await refreshViewportCells(); + state.status = t('statusAgentUpdated'); + render(); + } catch (_error) { + // Agent refresh is best-effort; the next user action re-syncs anyway. + } finally { + setLoading(false); + } +} + +export { + acceptProposalAction, + addSheet, + askAboutFocus, + copySelection, + createWorkbook, + ensureWorkbook, + exportCsv, + insertColumn, + insertRow, + jumpToProposal, + openFile, + openWorkbookAtPath, + pasteSelection, + pinFocus, + proposeSelectionFormat, + refreshFromEngine, + refreshHistory, + redoWorkbook, + rejectProposalAction, + resumeLatestWorkbook, + saveWorkbook, + setError, + setLoading, + switchSheet, + undoWorkbook, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/backend.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/backend.js new file mode 100644 index 00000000..d901e961 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/backend.js @@ -0,0 +1,47 @@ +import { state } from './state.js'; +import { bridgeOutput, runtime, uid } from './util.js'; +import { t } from './i18n.js'; + +async function callExcel(action, input = {}) { + const host = runtime(); + if (!host.backend?.call) throw new Error(t('backendMissing')); + + const workspacePath = input.workspacePath || state.workspacePath; + if (!workspacePath) throw new Error(t('noWorkspace')); + + const payload = { + workspacePath, + ...input, + }; + if (!payload.workbookId && state.workbookId) { + payload.workbookId = state.workbookId; + } + + const result = await host.backend.call( + `excelEngine.${action}`, + payload, + { + entityId: payload.workbookId || workspacePath || 'default', + idempotencyKey: `excel-live-${action}-${uid('idem')}`, + }, + ); + + if (result?.bridgeResult) return bridgeOutput(result); + if (!result?.actionRunId || !host.backend?.status) return bridgeOutput(result); + + const startedAt = Date.now(); + while (Date.now() - startedAt < 60000) { + const status = await host.backend.status(result.actionRunId, { + sessionId: result.sessionId, + turnId: result.turnId, + }); + if (status?.status === 'completed') return bridgeOutput(status); + if (status?.status === 'failed' || status?.status === 'cancelled') { + throw new Error(status?.message || status?.stderr || status?.status); + } + await new Promise((resolve) => setTimeout(resolve, 400)); + } + throw new Error(`Backend action timed out: ${action}`); +} + +export { callExcel }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/focus.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/focus.js new file mode 100644 index 00000000..df342c01 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/focus.js @@ -0,0 +1,171 @@ +import { callExcel } from './backend.js'; +import { formatA1 } from './a1.js'; +import { buildFocusPayload, normalizeFocusRange } from './model.js'; +import { state } from './state.js'; +import { runtime } from './util.js'; +import { t } from './i18n.js'; + +const FOCUS_HOST_DEBOUNCE_MS = 120; +const FOCUS_BACKEND_DEBOUNCE_MS = 900; + +function focusSyncKey(payload) { + return [ + payload.workbookId || '', + payload.sheetId || '', + payload.a1 || '', + payload.selectionKind || '', + payload.role || 'ambient', + payload.includeFocusOnSend ? '1' : '0', + payload.mode || 'edit', + payload.revision ?? '', + payload.cacheComplete ? '1' : '0', + payload.cacheCoverage ?? 0, + payload.previewTsv || '', + ].join('|'); +} + +async function broadcastFocusToBackend() { + if (!state.workbookId || !state.workspacePath) return; + if (state.modePending) return; + // Skip while the user is still dragging a selection — each bridge call + // currently spawns a Node process and would freeze the grid. + if (state.selectionDragging) return; + const focus = normalizeFocusRange(state.focus); + try { + await callExcel('setFocus', { + workbookId: state.workbookId, + sheetId: focus.sheetId, + a1: focus.a1, + kind: focus.kind, + mode: state.mode, + revision: state.meta?.revision ?? null, + }); + } catch (_error) { + // Ambient focus sync is best-effort; UI selection still works without it. + } +} + +async function commitModeToBackend(mode) { + if (!state.workbookId || !state.workspacePath) { + throw new Error(t('modeUnavailable')); + } + if (!['inspect', 'edit', 'author'].includes(mode)) { + throw new Error(t('modeUnavailable')); + } + // A queued ambient sync may still carry the previous mode. Cancel it before + // committing this explicit execution boundary so it cannot race and undo the + // user's choice after the confirmed write returns. + if (state.focusSyncTimer) { + clearTimeout(state.focusSyncTimer); + state.focusSyncTimer = null; + } + const focus = normalizeFocusRange(state.focus); + return callExcel('setFocus', { + workbookId: state.workbookId, + sheetId: focus.sheetId, + a1: focus.a1, + kind: focus.kind, + mode, + revision: state.meta?.revision ?? null, + }); +} + +function syncFocusToHost(options = {}) { + const role = options.role || 'ambient'; + const payload = buildFocusPayload(role); + const key = focusSyncKey(payload); + if (!options.force && key === state.lastFocusSyncKey && role === 'ambient') { + return payload; + } + state.lastFocusSyncKey = key; + + const host = runtime(); + if (typeof host.host?.syncSpreadsheetFocus === 'function') { + void host.host.syncSpreadsheetFocus(payload); + } else if (typeof host.syncSpreadsheetFocus === 'function') { + void host.syncSpreadsheetFocus(payload); + } + + // Host ambient context is cheap; backend setFocus is expensive. Default to + // host-only sync for selection changes and only push to the engine when + // explicitly requested (pin / ask / settled selection). + if (options.broadcast === true) { + void broadcastFocusToBackend(); + } + return payload; +} + +function scheduleFocusSync(options = {}) { + if (state.focusSyncTimer) { + clearTimeout(state.focusSyncTimer); + } + const delay = options.immediate + ? 0 + : (options.broadcast === true ? FOCUS_BACKEND_DEBOUNCE_MS : FOCUS_HOST_DEBOUNCE_MS); + state.focusSyncTimer = setTimeout(() => { + state.focusSyncTimer = null; + // Default: host-only ambient sync. Backend setFocus is expensive (fresh + // Node process per call) and is reserved for settled selection / pin. + syncFocusToHost(options); + }, delay); +} + +function setFocusFromSelection(r1, c1, r2 = r1, c2 = c1, options = {}) { + const next = normalizeFocusRange({ + sheetId: state.activeSheetId, + r1, + c1, + r2, + c2, + kind: options.kind || (r1 === r2 && c1 === c2 ? 'cell' : 'range'), + }); + state.focus = next; + if (options.sync !== false) { + scheduleFocusSync({ force: Boolean(options.force) }); + } + return next; +} + +async function pinFocus() { + const payload = syncFocusToHost({ role: 'pinned', force: true, broadcast: true }); + const host = runtime(); + const marker = t('pinMarker', { + sheet: payload.sheetName || 'Sheet', + a1: payload.a1, + }); + + if (typeof host.host?.addContext === 'function') { + await host.host.addContext({ + ...payload, + role: 'pinned', + label: `${payload.sheetName || 'Sheet'}!${payload.a1}`, + }); + } else if (typeof host.addContext === 'function') { + await host.addContext({ + ...payload, + role: 'pinned', + label: `${payload.sheetName || 'Sheet'}!${payload.a1}`, + }); + } else if (typeof host.host?.fillChatInput === 'function') { + await host.host.fillChatInput(marker); + } else if (typeof host.fillChatInput === 'function') { + await host.fillChatInput(marker); + } + + state.status = t('statusPinned'); + return payload; +} + +function activeCellA1() { + const focus = normalizeFocusRange(state.focus); + return formatA1(focus.r1, focus.c1); +} + +export { + activeCellA1, + commitModeToBackend, + pinFocus, + scheduleFocusSync, + setFocusFromSelection, + syncFocusToHost, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/formatting.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/formatting.js new file mode 100644 index 00000000..fc5fb532 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/formatting.js @@ -0,0 +1,458 @@ +import { colToIndex } from './a1.js'; + +const LIVE_VIEW_SEMANTICS = 'values-formulas-styles-layout'; + +const STYLE_ROLE_FALLBACKS = Object.freeze({ + title: { + fill: { color: '#17365D' }, + font: { bold: true, color: '#FFFFFF', size: 14 }, + alignment: { vertical: 'middle' }, + }, + header: { + fill: { color: '#1F4E78' }, + font: { bold: true, color: '#FFFFFF' }, + border: { bottom: { style: 'thin', color: '#17365D' } }, + alignment: { vertical: 'middle', wrapText: true }, + }, + total: { + font: { bold: true }, + border: { top: { style: 'double', color: '#1F1F1F' } }, + }, + input: { + fill: { color: '#FFF2CC' }, + border: { bottom: { style: 'thin', color: '#D6B656' } }, + }, + output: { + fill: { color: '#DDEBF7' }, + font: { color: '#17365D' }, + }, + note: { + font: { italic: true, color: '#666666' }, + alignment: { wrapText: true }, + }, + warning: { + fill: { color: '#FCE4D6' }, + font: { bold: true, color: '#C00000' }, + }, +}); + +const ALIGNMENTS = new Set(['left', 'center', 'right', 'justify', 'general']); +const VERTICAL_ALIGNMENTS = new Set(['top', 'middle', 'bottom']); +const BORDER_STYLES = new Set(['hair', 'thin', 'medium', 'thick', 'double', 'dashed', 'dotted']); + +function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function normalizeStyleRole(value) { + const role = String(value || '').trim().toLowerCase().replaceAll('_', '-'); + return Object.prototype.hasOwnProperty.call(STYLE_ROLE_FALLBACKS, role) ? role : ''; +} + +function normalizeColor(value) { + let candidate = value; + if (candidate && typeof candidate === 'object') { + candidate = candidate.color ?? candidate.rgb ?? candidate.argb ?? candidate.value; + } + let token = String(candidate || '').trim().replace(/^#/, ''); + if (/^[0-9a-f]{8}$/i.test(token)) token = token.slice(2); + if (/^[0-9a-f]{3}$/i.test(token)) token = token.split('').map((part) => `${part}${part}`).join(''); + return /^[0-9a-f]{6}$/i.test(token) ? `#${token.toUpperCase()}` : null; +} + +function normalizeBorderSide(value) { + if (!value) return null; + const source = typeof value === 'string' ? { style: value } : asObject(value); + let style = String(source.style || source.lineStyle || 'thin').trim().toLowerCase(); + if (!BORDER_STYLES.has(style)) style = 'thin'; + const color = normalizeColor(source.color) || '#AEB5BE'; + return { style, color }; +} + +function mergeStyle(base, override) { + const left = asObject(base); + const right = asObject(override); + return { + ...left, + ...right, + fill: { ...asObject(left.fill), ...asObject(right.fill) }, + font: { ...asObject(left.font), ...asObject(right.font) }, + border: { ...asObject(left.border), ...asObject(right.border) }, + alignment: { ...asObject(left.alignment), ...asObject(right.alignment) }, + }; +} + +function normalizeCellStyle(styleLike, fallbackRole = '') { + const source = asObject(styleLike); + const role = normalizeStyleRole(source.role || fallbackRole); + const merged = mergeStyle(role ? STYLE_ROLE_FALLBACKS[role] : {}, source); + const fillColor = normalizeColor(merged.fill?.color ?? merged.fill); + const fontColor = normalizeColor(merged.font?.color); + const fontSizeValue = Number(merged.font?.size); + const horizontal = String(merged.alignment?.horizontal || '').trim().toLowerCase(); + let vertical = String(merged.alignment?.vertical || '').trim().toLowerCase(); + if (vertical === 'center') vertical = 'middle'; + const border = {}; + for (const side of ['top', 'right', 'bottom', 'left']) { + const normalized = normalizeBorderSide(merged.border?.[side]); + if (normalized) border[side] = normalized; + } + const numberFormat = typeof merged.numberFormat === 'string' + ? merged.numberFormat.trim().slice(0, 120) + : ''; + return { + role: role || null, + fill: fillColor ? { color: fillColor } : null, + font: { + bold: merged.font?.bold === true, + italic: merged.font?.italic === true, + color: fontColor, + size: Number.isFinite(fontSizeValue) ? Math.min(72, Math.max(6, fontSizeValue)) : null, + }, + border, + alignment: { + horizontal: ALIGNMENTS.has(horizontal) ? horizontal : null, + vertical: VERTICAL_ALIGNMENTS.has(vertical) ? vertical : null, + wrapText: merged.alignment?.wrapText === true, + }, + numberFormat: numberFormat || null, + }; +} + +function cellStyle(cell) { + if (!cell || typeof cell !== 'object') return normalizeCellStyle(null); + const style = asObject(cell.style); + return normalizeCellStyle(style, cell.styleRole || cell.role); +} + +function borderCss(side) { + if (!side) return null; + const widths = { hair: 1, thin: 1, medium: 2, thick: 3, double: 3, dashed: 1, dotted: 1 }; + const cssStyle = side.style === 'double' + ? 'double' + : side.style === 'dashed' + ? 'dashed' + : side.style === 'dotted' || side.style === 'hair' + ? 'dotted' + : 'solid'; + return `${widths[side.style] || 1}px ${cssStyle} ${side.color}`; +} + +function styleToCss(styleLike, fallbackRole = '') { + const style = normalizeCellStyle(styleLike, fallbackRole); + const declarations = []; + if (style.fill?.color) declarations.push(`background-color:${style.fill.color}`); + if (style.font.color) declarations.push(`color:${style.font.color}`); + if (style.font.bold) declarations.push('font-weight:700'); + if (style.font.italic) declarations.push('font-style:italic'); + // Excel font sizes are points; CSS uses px at the standard 96 dpi scale. + if (style.font.size) declarations.push(`font-size:${Math.round(style.font.size * 96 / 72 * 100) / 100}px`); + if (style.alignment.horizontal && style.alignment.horizontal !== 'general') { + declarations.push(`text-align:${style.alignment.horizontal}`); + declarations.push(`justify-content:${style.alignment.horizontal === 'center' ? 'center' : style.alignment.horizontal === 'right' ? 'flex-end' : 'flex-start'}`); + } + if (style.alignment.vertical) { + const alignItems = style.alignment.vertical === 'middle' + ? 'center' + : style.alignment.vertical === 'top' + ? 'flex-start' + : 'flex-end'; + declarations.push(`align-items:${alignItems}`); + } + if (style.alignment.wrapText) declarations.push('white-space:normal'); + for (const side of ['top', 'right', 'bottom', 'left']) { + const value = borderCss(style.border[side]); + if (value) declarations.push(`border-${side}:${value}`); + } + return declarations.join(';'); +} + +function decimalPlaces(format, marker = '.') { + const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = String(format || '').match(new RegExp(`${escapedMarker}(0+)`)); + return match ? match[1].length : 0; +} + +function formatValueWithNumberFormat(value, format) { + if (typeof value !== 'number' || !Number.isFinite(value) || !format) return value == null ? '' : String(value); + const token = String(format).trim(); + if (!token || /^general$/i.test(token)) return String(value); + const firstSection = token.split(';')[0]; + // Basic 1900-date-system preview only. Locale calendars, time zones, and + // alternate Excel date systems remain fidelity metadata concerns. + if (/y{2,4}[-/]m{1,2}[-/]d{1,2}/i.test(firstSection)) { + const serialDay = Math.floor(value); + // Excel intentionally preserves Lotus 1-2-3's fictitious leap day. + if (serialDay === 60) return '1900-02-29'; + const adjustedDay = serialDay >= 60 ? serialDay - 1 : serialDay; + const date = new Date(Date.UTC(1899, 11, 31) + adjustedDay * 86400000); + if (!Number.isNaN(date.getTime())) { + const year = String(date.getUTCFullYear()).padStart(4, '0'); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + } + const fractionDigits = decimalPlaces(firstSection); + if (firstSection.includes('%')) { + return `${(value * 100).toLocaleString(undefined, { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits })}%`; + } + const currency = firstSection.match(/[$¥¥€£]/)?.[0] || ''; + const useGrouping = firstSection.includes(','); + const formatted = value.toLocaleString(undefined, { + useGrouping, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); + return currency ? `${currency}${formatted}` : formatted; +} + +function cellDisplayWithStyle(cell) { + if (!cell) return ''; + if (cell.v != null) return formatValueWithNumberFormat(cell.v, cellStyle(cell).numberFormat); + const formula = cell.f ?? cell.formula; + return formula ? `=${String(formula).replace(/^=/, '')}` : ''; +} + +function snapshotParts(snapshot) { + if (!snapshot) return { primary: '', secondary: '', formula: '', value: '' }; + const formulaValue = snapshot.f ?? snapshot.formula; + const formula = formulaValue ? `=${String(formulaValue).replace(/^=/, '')}` : ''; + const value = snapshot.v ?? snapshot.value; + const displayValue = value == null ? '' : formatValueWithNumberFormat(value, cellStyle(snapshot).numberFormat); + return { + primary: formula || displayValue, + secondary: formula && displayValue ? displayValue : '', + formula, + value: displayValue, + }; +} + +function styleSignature(style) { + return JSON.stringify(normalizeCellStyle(style)); +} + +function styleDifferences(before, after) { + const left = cellStyle(before); + const right = cellStyle(after); + if (styleSignature(left) === styleSignature(right)) return []; + const diffs = []; + const push = (key, beforeValue, afterValue) => { + if (JSON.stringify(beforeValue ?? null) !== JSON.stringify(afterValue ?? null)) { + diffs.push({ key, before: beforeValue ?? null, after: afterValue ?? null }); + } + }; + push('role', left.role, right.role); + push('fill', left.fill?.color, right.fill?.color); + push('font', left.font, right.font); + push('border', left.border, right.border); + push('alignment', left.alignment, right.alignment); + push('numberFormat', left.numberFormat, right.numberFormat); + return diffs; +} + +function messageText(value) { + if (typeof value === 'string') return value.trim(); + if (!value || typeof value !== 'object') return String(value ?? '').trim(); + return String(value.message || value.text || value.description || value.code || '').trim(); +} + +function asMessages(value) { + if (!Array.isArray(value)) return []; + return value.map(messageText).filter(Boolean); +} + +function proposalValidationDetails(proposal) { + const validation = proposal?.validation; + const errors = []; + const warnings = []; + let status = ''; + let explicitlyInvalid = validation === false; + if (typeof validation === 'string') status = validation; + if (Array.isArray(validation)) warnings.push(...asMessages(validation)); + if (validation && typeof validation === 'object' && !Array.isArray(validation)) { + status = String(validation.status || validation.state || '').trim().toLowerCase(); + explicitlyInvalid = validation.valid === false || validation.invalid === true; + errors.push(...asMessages(validation.errors)); + warnings.push(...asMessages(validation.warnings)); + const formulaLint = validation.formulaLint || proposal?.formulaLint; + if (formulaLint && typeof formulaLint === 'object') { + errors.push(...asMessages(formulaLint.errors)); + warnings.push(...asMessages(formulaLint.warnings)); + for (const finding of formulaLint.findings || []) { + const text = messageText(finding); + if (!text) continue; + const severity = String(finding?.severity || finding?.level || '').toLowerCase(); + if (severity === 'error' || severity === 'fatal') errors.push(text); + else warnings.push(text); + } + } + } + const invalidStatus = ['invalid', 'failed', 'error', 'blocked'].includes(status); + return { + status, + errors: [...new Set(errors)], + warnings: [...new Set(warnings)], + invalid: explicitlyInvalid || invalidStatus || errors.length > 0, + }; +} + +function normalizeAxisIndex(value, kind) { + if (Number.isInteger(Number(value))) return Math.max(0, Number(value)); + if (kind === 'column' && typeof value === 'string') { + try { + return colToIndex(value); + } catch (_error) { + return 0; + } + } + return 0; +} + +function normalizeLayoutRanges(entries, kind) { + if (!Array.isArray(entries)) return []; + return entries.map((entry) => { + const source = asObject(entry); + const start = normalizeAxisIndex(source.start ?? source.index ?? source[`${kind}Start`] ?? source[`${kind}Index`], kind); + const end = normalizeAxisIndex(source.end ?? source.index ?? source[`${kind}End`] ?? source[`${kind}Index`] ?? start, kind); + const explicitPixels = Number(source[kind === 'column' ? 'widthPx' : 'heightPx']); + const sourceValue = Number(source[kind === 'column' ? 'width' : 'height']); + // Engine/XLSX contract: column width is Excel character units and row + // height is points. Convert only for presentation; retain the source value + // so proposal details remain meaningful in workbook units. + const convertedSize = Number.isFinite(explicitPixels) + ? explicitPixels + : Number.isFinite(sourceValue) + ? kind === 'column' + ? excelColumnWidthToPixels(sourceValue) + : excelRowHeightToPixels(sourceValue) + : NaN; + const min = kind === 'column' ? 12 : 1; + const max = kind === 'column' ? excelColumnWidthToPixels(255) : excelRowHeightToPixels(409); + return { + start: Math.min(start, end), + end: Math.max(start, end), + size: Number.isFinite(convertedSize) ? Math.min(max, Math.max(min, convertedSize)) : null, + value: Number.isFinite(sourceValue) ? sourceValue : null, + unit: kind === 'column' ? 'excelCharacters' : 'points', + autoFit: source.autoFit === true, + }; + }).filter((entry) => entry.size != null || entry.autoFit); +} + +function excelColumnWidthToPixels(width) { + const value = Math.min(255, Math.max(0, Number(width) || 0)); + return Math.floor(value * 7 + 5); +} + +function excelRowHeightToPixels(height) { + const value = Math.min(409, Math.max(0, Number(height) || 0)); + return Math.round(value * 96 / 72 * 100) / 100; +} + +function normalizeSheetLayout(layoutLike) { + const layout = asObject(layoutLike); + const freeze = asObject(layout.freezePanes || layout.freeze || layout.frozenPanes); + const filter = layout.autoFilter; + return { + columns: normalizeLayoutRanges(layout.columns || layout.columnWidths, 'column'), + rows: normalizeLayoutRanges(layout.rows || layout.rowHeights, 'row'), + freezePanes: { + rows: Math.max(0, Number(freeze.rows ?? freeze.rowCount ?? freeze.frozenRows) || 0), + columns: Math.max(0, Number(freeze.columns ?? freeze.columnCount ?? freeze.frozenColumns) || 0), + }, + autoFilter: typeof filter === 'string' + ? { a1: filter } + : filter && typeof filter === 'object' + ? { a1: String(filter.a1 || filter.range || '').trim() || null } + : null, + }; +} + +function proposalLayoutStates(proposal) { + const layout = proposal?.layout; + if (!layout || typeof layout !== 'object') return null; + const hasEnvelope = Object.prototype.hasOwnProperty.call(layout, 'before') + || Object.prototype.hasOwnProperty.call(layout, 'after'); + return { + before: normalizeSheetLayout(hasEnvelope ? layout.before : null), + after: normalizeSheetLayout(hasEnvelope ? layout.after : layout), + }; +} + +function proposalHasLayoutChanges(proposal) { + const states = proposalLayoutStates(proposal); + return Boolean(states && JSON.stringify(states.before) !== JSON.stringify(states.after)); +} + +function axisSizeAt(index, entries, defaultSize, autoFitSize = defaultSize) { + let size = defaultSize; + for (const entry of entries || []) { + if (index < entry.start || index > entry.end) continue; + size = entry.size ?? (entry.autoFit ? autoFitSize : defaultSize); + } + return size; +} + +function axisOffset(index, entries, defaultSize, autoFitSize = defaultSize) { + const target = Math.max(0, Number(index) || 0); + let offset = target * defaultSize; + for (const entry of entries || []) { + const coveredStart = Math.max(0, entry.start); + const coveredEnd = Math.min(target - 1, entry.end); + if (coveredEnd < coveredStart) continue; + const count = coveredEnd - coveredStart + 1; + const size = entry.size ?? (entry.autoFit ? autoFitSize : defaultSize); + offset += count * (size - defaultSize); + } + return offset; +} + +function axisIndexAtOffset(offset, count, entries, defaultSize, autoFitSize = defaultSize) { + const safeCount = Math.max(1, Number(count) || 1); + const target = Math.max(0, Number(offset) || 0); + let low = 0; + let high = safeCount - 1; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + if (axisOffset(mid, entries, defaultSize, autoFitSize) <= target) low = mid; + else high = mid - 1; + } + return low; +} + +function layoutDifferences(proposal) { + const states = proposalLayoutStates(proposal); + if (!states) return []; + const diffs = []; + for (const key of ['columns', 'rows', 'freezePanes', 'autoFilter']) { + if (JSON.stringify(states.before[key]) !== JSON.stringify(states.after[key])) { + diffs.push({ key, before: states.before[key], after: states.after[key] }); + } + } + return diffs; +} + +export { + LIVE_VIEW_SEMANTICS, + STYLE_ROLE_FALLBACKS, + axisIndexAtOffset, + axisOffset, + axisSizeAt, + cellDisplayWithStyle, + cellStyle, + excelColumnWidthToPixels, + excelRowHeightToPixels, + formatValueWithNumberFormat, + normalizeCellStyle, + normalizeColor, + normalizeSheetLayout, + normalizeStyleRole, + layoutDifferences, + proposalHasLayoutChanges, + proposalLayoutStates, + proposalValidationDetails, + snapshotParts, + styleDifferences, + styleToCss, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/grid.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/grid.js new file mode 100644 index 00000000..120350df --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/grid.js @@ -0,0 +1,1258 @@ +import { formatA1, indexToCol, parseA1 } from './a1.js'; +import { callExcel } from './backend.js'; +import { scheduleFocusSync, setFocusFromSelection } from './focus.js'; +import { + activeSheet, + cellDisplayValue, + cellEditValue, + mergeReadIntoCache, + normalizeFocusRange, + proposalAfterDisplay, + proposalBeforeDisplay, + proposalCellSet, + sheetBounds, +} from './model.js'; +import { + axisIndexAtOffset, + axisOffset, + axisSizeAt, + cellStyle, + normalizeSheetLayout, + proposalLayoutStates, + styleDifferences, + styleToCss, +} from './formatting.js'; +import { + createEditingDraft, + editingExpectedRevision, + gridSelectionNeedsEditCommit, + isComposingKey, + isSaveShortcut, +} from './interaction.js'; +import { state } from './state.js'; +import { cellCacheKey, clamp, closestElement, rootElement } from './util.js'; +import { t } from './i18n.js'; +import { showToast, updateChrome, updateSelectionStats } from './views.js'; + +const HEADER_H = 26; +const ROW_H = 26; +const COL_W = 100; +const ROW_HEADER_W = 46; +const OVERSCAN_ROWS = 6; +const OVERSCAN_COLS = 3; +const TILE_ROWS = 40; +const TILE_COLS = 12; +const MAX_FETCH_CELLS = 4000; +const AUTOFIT_COL_W = 140; +const AUTOFIT_ROW_H = 32; + +let domBound = false; +let gridResizeObserver = null; +let resizeRafPending = false; +let lastObservedGridSize = null; +let deferredGridSelection = null; + +function sheetLayout(sheet = activeSheet()) { + if (!sheet?.id) return normalizeSheetLayout(null); + const proposalSheetId = state.proposal?.sheetId || state.activeSheetId; + const proposalLayout = proposalSheetId === sheet.id ? proposalLayoutStates(state.proposal) : null; + if (proposalLayout) return proposalLayout.after; + return state.sheetLayouts.get(sheet.id) || normalizeSheetLayout(sheet.layout); +} + +function columnLeft(col, layout = sheetLayout()) { + return axisOffset(col, layout.columns, COL_W, AUTOFIT_COL_W); +} + +function columnWidth(col, layout = sheetLayout()) { + return axisSizeAt(col, layout.columns, COL_W, AUTOFIT_COL_W); +} + +function rowTop(row, layout = sheetLayout()) { + return axisOffset(row, layout.rows, ROW_H, AUTOFIT_ROW_H); +} + +function rowHeight(row, layout = sheetLayout()) { + return axisSizeAt(row, layout.rows, ROW_H, AUTOFIT_ROW_H); +} + +function indexSet(start, end, frozenCount = 0) { + const values = new Set(); + for (let index = 0; index < frozenCount; index += 1) values.add(index); + for (let index = start; index <= end; index += 1) values.add(index); + return [...values].sort((a, b) => a - b); +} + +function q(selector) { + return rootElement()?.querySelector(selector) || null; +} + +function cellDomId(sheetId, row, col) { + const safeSheet = String(sheetId || 'sheet').replace(/[^a-zA-Z0-9_-]/g, '-'); + return `el-cell-${safeSheet}-${row}-${col}`; +} + +function rowHeaderDomId(sheetId, row) { + const safeSheet = String(sheetId || 'sheet').replace(/[^a-zA-Z0-9_-]/g, '-'); + return `el-row-header-${safeSheet}-${row}`; +} + +function scrollHost() { + return q('[data-grid-scroll]'); +} + +/* ---------------------------------------------------------------- layout */ + +function layoutGrid() { + const sheet = activeSheet(); + const host = scrollHost(); + if (!sheet || !host) return; + const bounds = sheetBounds(sheet); + const layout = sheetLayout(sheet); + const gridWidth = columnLeft(bounds.cols, layout); + const gridHeight = rowTop(bounds.rows, layout); + + const corner = q('[data-grid-corner]'); + const colHeaders = q('[data-col-headers]'); + const rowHeaders = q('[data-row-headers]'); + const body = q('[data-grid-body]'); + if (corner) { + corner.style.width = `${ROW_HEADER_W}px`; + corner.style.height = `${HEADER_H}px`; + } + if (colHeaders) { + colHeaders.style.width = `${gridWidth}px`; + colHeaders.style.height = `${HEADER_H}px`; + } + if (rowHeaders) { + rowHeaders.style.width = `${ROW_HEADER_W}px`; + rowHeaders.style.height = `${gridHeight}px`; + } + if (body) { + body.style.width = `${gridWidth}px`; + body.style.height = `${gridHeight}px`; + body.classList.toggle('has-custom-layout', layout.columns.length > 0 || layout.rows.length > 0); + body.dataset.freezeRows = String(layout.freezePanes.rows || 0); + body.dataset.freezeColumns = String(layout.freezePanes.columns || 0); + } +} + +function measureViewport() { + const host = scrollHost(); + if (!host) return; + const height = host.clientHeight || 480; + const width = host.clientWidth || 800; + const layout = sheetLayout(); + const smallestRow = Math.min(ROW_H, ...layout.rows.map((entry) => entry.size ?? AUTOFIT_ROW_H)); + const smallestCol = Math.min(COL_W, ...layout.columns.map((entry) => entry.size ?? AUTOFIT_COL_W)); + state.viewport.visibleRows = Math.min(200, Math.max(8, Math.ceil((height - HEADER_H) / smallestRow) + 2)); + state.viewport.visibleCols = Math.min(50, Math.max(4, Math.ceil((width - ROW_HEADER_W) / smallestCol) + 2)); +} + +function renderWindow() { + const sheet = activeSheet(); + if (!sheet) return null; + const bounds = sheetBounds(sheet); + const { scrollRow, scrollCol, visibleRows, visibleCols } = state.viewport; + return { + sheet, + bounds, + r1: Math.max(0, scrollRow - OVERSCAN_ROWS), + c1: Math.max(0, scrollCol - OVERSCAN_COLS), + r2: Math.min(bounds.rows - 1, scrollRow + visibleRows - 1 + OVERSCAN_ROWS), + c2: Math.min(bounds.cols - 1, scrollCol + visibleCols - 1 + OVERSCAN_COLS), + }; +} + +/* ---------------------------------------------------------------- render */ + +function escapeText(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function renderGridBody(force = false) { + const win = renderWindow(); + const cells = q('[data-grid-cells]'); + const colHeader = q('[data-col-headers]'); + const rowHeader = q('[data-row-headers]'); + if (!win || !cells || !colHeader || !rowHeader) return; + + const windowKey = `${win.sheet.id}:${win.r1}:${win.c1}:${win.r2}:${win.c2}`; + if (!force && windowKey === state.lastRenderedWindow) { + updateOverlays(); + return; + } + state.lastRenderedWindow = windowKey; + + const focus = normalizeFocusRange(state.focus, win.sheet); + const layout = sheetLayout(win.sheet); + const host = scrollHost(); + const freezeRows = Math.min(win.bounds.rows, state.viewport.visibleRows, layout.freezePanes.rows || 0); + const freezeCols = Math.min(win.bounds.cols, state.viewport.visibleCols, layout.freezePanes.columns || 0); + const rows = indexSet(win.r1, win.r2, freezeRows); + const cols = indexSet(win.c1, win.c2, freezeCols); + const customGrid = layout.columns.length > 0 || layout.rows.length > 0; + let filterRange = null; + if (layout.autoFilter?.a1) { + try { + filterRange = parseA1(layout.autoFilter.a1); + } catch (_error) { + filterRange = null; + } + } + let colHtml = ''; + for (const c of cols) { + const frozen = c < freezeCols; + const filtered = filterRange && c >= filterRange.c1 && c <= filterRange.c2; + const translate = frozen ? `transform:translateX(${host?.scrollLeft || 0}px);` : ''; + colHtml += `
${indexToCol(c)}${filtered ? `⌄` : ''}
`; + } + colHeader.innerHTML = colHtml; + + let rowHtml = ''; + for (const r of rows) { + const frozen = r < freezeRows; + const translate = frozen ? `transform:translateY(${host?.scrollTop || 0}px);` : ''; + rowHtml += `
${r + 1}
`; + } + rowHeader.innerHTML = rowHtml; + + const proposals = proposalCellSet(); + let html = ''; + for (const r of rows) { + const frozenRow = r < freezeRows; + let rowCells = ''; + for (const c of cols) { + const cell = state.cells.get(cellCacheKey(win.sheet.id, r, c)); + const proposal = proposals.get(`${r},${c}`); + const isActive = r === focus.r1 && c === focus.c1; + const isSelected = r >= focus.r1 && r <= focus.r2 && c >= focus.c1 && c <= focus.c2; + if (!cell && !proposal && !isActive && !customGrid) continue; + const display = cellDisplayValue(cell); + const beforeDisplay = proposal ? (display || proposalBeforeDisplay(proposal)) : display; + const classes = ['el-grid__cell']; + if (typeof cell?.v === 'number') classes.push('is-num'); + if (cell?.f || cell?.formulaEvidence) classes.push('is-formula'); + if (proposal) classes.push('is-proposal'); + const presentationCell = proposal?.after || cell; + const presentationStyle = cellStyle(presentationCell); + if (presentationStyle.role) classes.push(`is-style-${presentationStyle.role}`); + if (proposal && styleDifferences(proposal.before, proposal.after).length > 0) { + classes.push('has-style-change'); + } + const frozenCol = c < freezeCols; + if (frozenRow) classes.push('is-frozen-row'); + if (frozenCol) classes.push('is-frozen-column'); + if (freezeRows > 0 && r === freezeRows - 1) classes.push('is-freeze-row-edge'); + if (freezeCols > 0 && c === freezeCols - 1) classes.push('is-freeze-column-edge'); + const domId = cellDomId(win.sheet.id, r, c); + const a1 = formatA1(r, c); + const proposalLabel = proposal + ? `${t('proposalCellAria', { cell: a1, before: beforeDisplay || t('emptyValue'), after: proposalAfterDisplay(proposal) || t('emptyValue') })}` + : `${a1}: ${display || t('emptyValue')}`; + const cellCss = styleToCss(presentationCell?.style, presentationCell?.styleRole || presentationCell?.role); + const translateX = frozenCol ? `transform:translateX(${host?.scrollLeft || 0}px);` : ''; + rowCells += `
`; + if (proposal) { + const after = proposalAfterDisplay(proposal); + if (beforeDisplay) { + rowCells += `${escapeText(beforeDisplay)}`; + } + rowCells += `${escapeText(after)}`; + } else { + rowCells += `${escapeText(display)}`; + } + rowCells += '
'; + } + const translateY = frozenRow ? `transform:translateY(${host?.scrollTop || 0}px);` : ''; + html += `
${rowCells}
`; + } + cells.innerHTML = html; + updateOverlays(); +} + +function updateOverlays() { + const selBox = q('[data-selection-box]'); + const activeBox = q('[data-active-box]'); + if (!selBox || !activeBox) return; + const sheet = activeSheet(); + if (!sheet || !state.workbookId) { + selBox.hidden = true; + activeBox.hidden = true; + return; + } + const focus = normalizeFocusRange(state.focus, sheet); + const layout = sheetLayout(sheet); + const host = scrollHost(); + const grid = q('[data-grid-wrap]'); + if (grid) { + const bounds = sheetBounds(sheet); + grid.setAttribute('aria-rowcount', String(bounds.rows + 1)); + grid.setAttribute('aria-colcount', String(bounds.cols)); + grid.setAttribute('aria-activedescendant', 'el-grid-active-cell'); + grid.setAttribute('aria-busy', state.loading || state.dialogPending || state.modePending ? 'true' : 'false'); + } + const activeProxy = q('[data-active-gridcell]'); + if (activeProxy) { + const activeCell = state.cells.get(cellCacheKey(sheet.id, focus.r1, focus.c1)); + const value = cellDisplayValue(activeCell) || t('emptyValue'); + const proposal = proposalCellSet().get(`${focus.r1},${focus.c1}`); + const label = proposal + ? t('proposalCellAria', { + cell: formatA1(focus.r1, focus.c1), + before: proposalBeforeDisplay(proposal) || value, + after: proposalAfterDisplay(proposal) || t('emptyValue'), + }) + : `${formatA1(focus.r1, focus.c1)}: ${value}`; + activeProxy.setAttribute('aria-colindex', String(focus.c1 + 1)); + activeProxy.setAttribute('aria-label', label); + } + const activeProxyRow = q('[data-active-grid-row]'); + if (activeProxyRow) activeProxyRow.setAttribute('aria-rowindex', String(focus.r1 + 2)); + q('[data-grid-cells]')?.querySelectorAll('[role="gridcell"]').forEach((cellNode) => { + const row = Number(cellNode.dataset.row); + const col = Number(cellNode.dataset.col); + const selected = row >= focus.r1 && row <= focus.r2 && col >= focus.c1 && col <= focus.c2; + cellNode.setAttribute('aria-selected', selected ? 'true' : 'false'); + }); + const announcer = q('[data-grid-announcer]'); + if (announcer) { + announcer.textContent = t('selectionAnnouncement', { + sheet: sheet.name || '', + a1: focus.a1, + rows: focus.rowCount, + cols: focus.columnCount, + }); + } + + selBox.hidden = focus.rowCount === 1 && focus.columnCount === 1; + selBox.style.left = `${columnLeft(focus.c1, layout)}px`; + selBox.style.top = `${rowTop(focus.r1, layout)}px`; + selBox.style.width = `${columnLeft(focus.c2 + 1, layout) - columnLeft(focus.c1, layout)}px`; + selBox.style.height = `${rowTop(focus.r2 + 1, layout) - rowTop(focus.r1, layout)}px`; + selBox.style.transform = `translate(${focus.c2 < layout.freezePanes.columns ? host?.scrollLeft || 0 : 0}px, ${focus.r2 < layout.freezePanes.rows ? host?.scrollTop || 0 : 0}px)`; + + activeBox.hidden = false; + activeBox.style.left = `${columnLeft(focus.c1, layout)}px`; + activeBox.style.top = `${rowTop(focus.r1, layout)}px`; + activeBox.style.width = `${columnWidth(focus.c1, layout)}px`; + activeBox.style.height = `${rowHeight(focus.r1, layout)}px`; + activeBox.style.transform = `translate(${focus.c1 < layout.freezePanes.columns ? host?.scrollLeft || 0 : 0}px, ${focus.r1 < layout.freezePanes.rows ? host?.scrollTop || 0 : 0}px)`; + + updateNameBox(); + updateFormulaBar(); + updateSelectionStats(); + syncEditOverlay(); +} + +function updateFrozenTransforms() { + const host = scrollHost(); + if (!host) return; + q('[data-col-headers]')?.querySelectorAll('[data-freeze-x]').forEach((node) => { + node.style.transform = `translateX(${host.scrollLeft}px)`; + }); + q('[data-row-headers]')?.querySelectorAll('[data-freeze-y]').forEach((node) => { + node.style.transform = `translateY(${host.scrollTop}px)`; + }); + q('[data-grid-cells]')?.querySelectorAll('[data-freeze-x]').forEach((node) => { + node.style.transform = `translateX(${host.scrollLeft}px)`; + }); + q('[data-grid-cells]')?.querySelectorAll('[data-freeze-y]').forEach((node) => { + node.style.transform = `translateY(${host.scrollTop}px)`; + }); +} + +function updateNameBox() { + const input = q('[data-namebox]'); + if (!input || document.activeElement === input) return; + const focus = normalizeFocusRange(state.focus); + input.value = focus.a1; +} + +function updateFormulaBar() { + const input = q('[data-formula-input]'); + if (!input || document.activeElement === input) return; + if (state.editing) { + input.value = state.editing.value; + return; + } + const focus = normalizeFocusRange(state.focus); + const sheet = activeSheet(); + if (!sheet) { + input.value = ''; + return; + } + const cell = state.cells.get(cellCacheKey(sheet.id, focus.r1, focus.c1)); + input.value = cellEditValue(cell); +} + +/* ----------------------------------------------------------- data fetch */ + +function tilesForWindow(win) { + const tiles = []; + const tr1 = Math.floor(win.r1 / TILE_ROWS); + const tr2 = Math.floor(win.r2 / TILE_ROWS); + const tc1 = Math.floor(win.c1 / TILE_COLS); + const tc2 = Math.floor(win.c2 / TILE_COLS); + for (let tr = tr1; tr <= tr2; tr += 1) { + for (let tc = tc1; tc <= tc2; tc += 1) { + tiles.push({ tr, tc, key: `${win.sheet.id}:${tr}:${tc}` }); + } + } + return tiles; +} + +async function refreshViewportCells() { + const win = renderWindow(); + if (!win || !state.workbookId) return; + const sheet = win.sheet; + const bounds = win.bounds; + + if (state.fetchedTiles.has(`${sheet.id}:all`)) return; + + // Small sheets fit in one read; fetch everything once and stop refetching. + if (bounds.rows * bounds.cols <= MAX_FETCH_CELLS) { + const a1 = formatA1(0, 0, bounds.rows - 1, bounds.cols - 1); + const result = await callExcel('readRange', { + workbookId: state.workbookId, + sheetId: sheet.id, + a1, + maxCells: MAX_FETCH_CELLS, + }); + mergeReadIntoCache(result); + state.fetchedTiles.add(`${sheet.id}:all`); + return; + } + + const missing = tilesForWindow(win).filter((tile) => !state.fetchedTiles.has(tile.key)); + if (missing.length === 0) return; + + let r1 = Infinity; + let c1 = Infinity; + let r2 = -Infinity; + let c2 = -Infinity; + for (const tile of missing) { + r1 = Math.min(r1, tile.tr * TILE_ROWS); + c1 = Math.min(c1, tile.tc * TILE_COLS); + r2 = Math.max(r2, tile.tr * TILE_ROWS + TILE_ROWS - 1); + c2 = Math.max(c2, tile.tc * TILE_COLS + TILE_COLS - 1); + } + r1 = Math.max(0, r1); + c1 = Math.max(0, c1); + r2 = Math.min(bounds.rows - 1, r2); + c2 = Math.min(bounds.cols - 1, c2); + if ((r2 - r1 + 1) * (c2 - c1 + 1) > MAX_FETCH_CELLS) { + r1 = win.r1; + c1 = win.c1; + r2 = win.r2; + c2 = win.c2; + } + + const result = await callExcel('readRange', { + workbookId: state.workbookId, + sheetId: sheet.id, + a1: formatA1(r1, c1, r2, c2), + maxCells: MAX_FETCH_CELLS, + }); + mergeReadIntoCache(result); + + for (const tile of missing) { + const tileR2 = tile.tr * TILE_ROWS + TILE_ROWS - 1; + const tileC2 = tile.tc * TILE_COLS + TILE_COLS - 1; + if (tile.tr * TILE_ROWS >= r1 && Math.min(tileR2, bounds.rows - 1) <= r2 + && tile.tc * TILE_COLS >= c1 && Math.min(tileC2, bounds.cols - 1) <= c2) { + state.fetchedTiles.add(tile.key); + } + } +} + +function scheduleViewportFetch() { + if (state.viewportFetchTimer) clearTimeout(state.viewportFetchTimer); + state.viewportFetchTimer = setTimeout(() => { + state.viewportFetchTimer = null; + void refreshViewportCells() + .then(() => { + layoutGrid(); + measureViewport(); + renderGridBody(true); + // Refresh ambient context after hydration so an earlier + // cache-incomplete focus is replaced by a grounded preview. + scheduleFocusSync({ force: true, immediate: true, broadcast: false }); + }) + .catch((error) => { + showToast('error', error?.message || String(error)); + }); + }, 120); +} + +/* ------------------------------------------------------------ scrolling */ + +function scrollToCell(row, col) { + const host = scrollHost(); + if (!host) return; + const viewTop = host.scrollTop; + const viewLeft = host.scrollLeft; + const viewH = host.clientHeight - HEADER_H; + const viewW = host.clientWidth - ROW_HEADER_W; + const layout = sheetLayout(); + const cellTop = rowTop(row, layout); + const cellLeft = columnLeft(col, layout); + const cellH = rowHeight(row, layout); + const cellW = columnWidth(col, layout); + + let nextTop = viewTop; + let nextLeft = viewLeft; + if (cellTop < viewTop) nextTop = cellTop; + else if (cellTop + cellH > viewTop + viewH) nextTop = cellTop + cellH - viewH; + if (cellLeft < viewLeft) nextLeft = cellLeft; + else if (cellLeft + cellW > viewLeft + viewW) nextLeft = cellLeft + cellW - viewW; + + if (nextTop !== viewTop || nextLeft !== viewLeft) { + host.scrollTo({ top: Math.max(0, nextTop), left: Math.max(0, nextLeft) }); + } +} + +let scrollRafPending = false; + +function applyScrollPosition() { + const host = scrollHost(); + const sheet = activeSheet(); + if (!host || !sheet) return; + const bounds = sheetBounds(sheet); + const layout = sheetLayout(sheet); + const nextRow = clamp(axisIndexAtOffset(host.scrollTop, bounds.rows, layout.rows, ROW_H, AUTOFIT_ROW_H), 0, Math.max(0, bounds.rows - 1)); + const nextCol = clamp(axisIndexAtOffset(host.scrollLeft, bounds.cols, layout.columns, COL_W, AUTOFIT_COL_W), 0, Math.max(0, bounds.cols - 1)); + updateFrozenTransforms(); + updateOverlays(); + if (nextRow === state.viewport.scrollRow && nextCol === state.viewport.scrollCol) return; + state.viewport.scrollRow = nextRow; + state.viewport.scrollCol = nextCol; + renderGridBody(false); + scheduleViewportFetch(); +} + +function handleScroll() { + if (scrollRafPending) return; + scrollRafPending = true; + requestAnimationFrame(() => { + scrollRafPending = false; + applyScrollPosition(); + }); +} + +/* -------------------------------------------------------------- editing */ + +function canEdit() { + return !state.loading + && !state.dialogPending + && !state.modePending + && state.mode !== 'inspect' + && Boolean(state.workbookId); +} + +function handleViewportResize() { + const host = scrollHost(); + if (!host || host.clientWidth <= 0 || host.clientHeight <= 0) return; + const nextSize = `${host.clientWidth}x${host.clientHeight}`; + if (nextSize === lastObservedGridSize) return; + lastObservedGridSize = nextSize; + measureViewport(); + renderGridBody(false); + scheduleViewportFetch(); +} + +function scheduleViewportResize() { + if (resizeRafPending) return; + resizeRafPending = true; + requestAnimationFrame(() => { + resizeRafPending = false; + handleViewportResize(); + }); +} + +function beginEdit(row, col, initialValue) { + if (!canEdit()) return; + const sheet = activeSheet(); + if (!sheet) return; + const cell = state.cells.get(cellCacheKey(sheet.id, row, col)); + const value = initialValue != null ? initialValue : cellEditValue(cell); + state.editing = createEditingDraft({ + row, + col, + value, + workbookId: state.workbookId, + sheetId: sheet.id, + revision: state.meta?.revision ?? null, + origin: 'cell', + }); + setFocusFromSelection(row, col, row, col, { sync: true }); + scrollToCell(row, col); + updateOverlays(); +} + +function syncEditOverlay() { + const overlay = q('[data-cell-editor]'); + if (!overlay) return; + if (!state.editing || state.editing.origin === 'formula') { + overlay.hidden = true; + return; + } + const { row, col } = state.editing; + const layout = sheetLayout(); + overlay.hidden = false; + overlay.style.left = `${columnLeft(col, layout)}px`; + overlay.style.top = `${rowTop(row, layout)}px`; + overlay.style.width = `${columnWidth(col, layout)}px`; + overlay.style.height = `${rowHeight(row, layout)}px`; + const input = overlay.querySelector('input'); + if (input && document.activeElement !== input) { + input.value = state.editing.value; + input.focus(); + input.select(); + } +} + +function notifyEditingSettled() { + document.dispatchEvent(new CustomEvent('excel-live:editing-settled')); +} + +function restoreEditingFocus(editing) { + queueMicrotask(() => { + if (state.editing !== editing) return; + if (editing.origin === 'formula') { + const formulaInput = q('[data-formula-input]'); + if (formulaInput && !formulaInput.disabled) { + formulaInput.value = editing.value; + formulaInput.focus({ preventScroll: true }); + } + return; + } + updateOverlays(); + }); +} + +async function commitEdit(move = null) { + if (state.editCommitPromise) return state.editCommitPromise; + if (!state.editing || !state.workbookId) return true; + if (!canEdit()) { + showToast('error', t(state.mode === 'inspect' ? 'inspectEditBlocked' : 'operationPending')); + return false; + } + const editing = state.editing; + const sheet = state.sheets.find((candidate) => candidate.id === editing.sheetId) || activeSheet(); + if (!sheet || (editing.workbookId && editing.workbookId !== state.workbookId)) return false; + + const task = (async () => { + const { row, col, value } = editing; + const trimmed = String(value ?? ''); + const expectedRevision = editingExpectedRevision(editing, state.meta?.revision ?? undefined); + const payload = { + workbookId: state.workbookId, + sheetId: sheet.id, + row, + col, + baseRevision: expectedRevision, + expectedRevision, + }; + if (trimmed.startsWith('=')) { + payload.formula = trimmed; + } else { + payload.value = trimmed; + } + + state.editing = null; + updateOverlays(); + state.loading = true; + updateChrome(); + let committed = false; + try { + const result = await callExcel('applyLocalEdit', payload); + if (result?.meta) { + state.dirty = Boolean(result.meta.dirty); + state.meta = result.meta; + } + const key = cellCacheKey(sheet.id, row, col); + if (result?.edit?.after) { + state.cells.set(key, { + v: result.edit.after.v ?? null, + f: result.edit.after.f ?? null, + t: result.edit.after.t ?? null, + formulaEvidence: Boolean(result.edit.after.formulaEvidence || result.edit.after.f), + formulaType: result.edit.after.formulaType ?? null, + formulaRef: result.edit.after.formulaRef ?? null, + style: result.edit.after.style ?? null, + }); + } else { + state.cells.delete(key); + } + if (state.proposal) { + const remaining = (state.proposal.cells || []).filter( + (c) => !(Number(c.row ?? c.r) === row && Number(c.col ?? c.c) === col), + ); + state.proposal = remaining.length ? { ...state.proposal, cells: remaining } : null; + } + if (result?.edit?.operationId) { + state.history.canUndo = true; + state.history.canRedo = false; + } + // Local edits already persist to the live workbook.json store (including + // focus). Only refresh host ambient context so chat sees the new preview; + // skip a second setFocus bridge round-trip. + scheduleFocusSync({ force: true, immediate: true, broadcast: false }); + renderGridBody(true); + updateChrome(); + document.dispatchEvent(new CustomEvent('excel-live:proposal-refresh')); + document.dispatchEvent(new CustomEvent('excel-live:history-refresh')); + committed = true; + } catch (error) { + showToast('error', error?.message || t('editFailed')); + } finally { + state.loading = false; + updateChrome(); + if (committed) { + notifyEditingSettled(); + } else { + state.editing = editing; + renderGridBody(true); + restoreEditingFocus(editing); + } + } + + if (!committed) return false; + if (move?.exit === true) { + // Keep the browser's natural focus destination (Tab/click outside). + } else if (move) { + moveFocus(move.dr, move.dc, false); + } else { + focusGridWrap(); + } + return true; + })(); + + state.editCommitPromise = task; + try { + return await task; + } finally { + if (state.editCommitPromise === task) state.editCommitPromise = null; + } +} + +function cancelEdit() { + const hadEditing = Boolean(state.editing); + state.editing = null; + updateOverlays(); + focusGridWrap(); + if (hadEditing) notifyEditingSettled(); +} + +async function applyFormulaBarValue(value) { + if (!canEdit()) return; + const focus = normalizeFocusRange(state.focus); + const sheet = activeSheet(); + if (!sheet) return; + if ( + state.editing + && state.editing.row === focus.r1 + && state.editing.col === focus.c1 + && state.editing.sheetId === sheet.id + ) { + state.editing.value = String(value ?? ''); + } else { + state.editing = createEditingDraft({ + row: focus.r1, + col: focus.c1, + value, + workbookId: state.workbookId, + sheetId: sheet.id, + revision: state.meta?.revision ?? null, + origin: 'formula', + }); + } + return commitEdit(); +} + +async function clearSelectionCells() { + if (!canEdit()) return; + const focus = normalizeFocusRange(state.focus); + const sheet = activeSheet(); + if (!sheet) return; + state.loading = true; + updateChrome(); + try { + const result = await callExcel('clearRange', { + workbookId: state.workbookId, + sheetId: sheet.id, + a1: focus.a1, + baseRevision: state.meta?.revision ?? undefined, + expectedRevision: state.meta?.revision ?? undefined, + }); + if (result?.meta) { + state.dirty = Boolean(result.meta.dirty); + state.meta = result.meta; + } + for (let r = focus.r1; r <= focus.r2; r += 1) { + for (let c = focus.c1; c <= focus.c2; c += 1) { + state.cells.delete(cellCacheKey(sheet.id, r, c)); + } + } + state.status = t('statusCleared'); + // clearRange already persisted cells + focus-adjacent state; host-only sync. + scheduleFocusSync({ force: true, immediate: true, broadcast: false }); + renderGridBody(true); + updateChrome(); + document.dispatchEvent(new CustomEvent('excel-live:proposal-refresh')); + document.dispatchEvent(new CustomEvent('excel-live:history-refresh')); + } catch (error) { + showToast('error', error?.message || t('clearFailed')); + } finally { + state.loading = false; + updateChrome(); + } +} + +/* ------------------------------------------------------------ selection */ + +function focusGridWrap() { + const wrap = q('[data-grid-wrap]'); + if (wrap && document.activeElement !== wrap) { + wrap.focus({ preventScroll: true }); + } +} + +function cellFromEvent(event) { + const node = closestElement(event.target, '[data-row][data-col].el-grid__cell'); + if (node) { + return { row: Number(node.dataset.row), col: Number(node.dataset.col) }; + } + // Empty cells are not rendered as DOM nodes; derive from body coordinates. + const body = q('[data-grid-body]'); + if (!body || !closestElement(event.target, '[data-grid-body]')) return null; + const rect = body.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + if (x < 0 || y < 0) return null; + const sheet = activeSheet(); + if (!sheet) return null; + const bounds = sheetBounds(sheet); + const row = clamp(Math.floor(y / ROW_H), 0, bounds.rows - 1); + const col = clamp(Math.floor(x / COL_W), 0, bounds.cols - 1); + return { row, col }; +} + +function applyPointerSelection(anchor, current = anchor, dragging = true) { + state.selectionDragging = dragging; + state.selectionAnchor = { row: anchor.row, col: anchor.col }; + setFocusFromSelection(anchor.row, anchor.col, current.row, current.col); + updateOverlays(); + focusGridWrap(); +} + +function completeDeferredGridSelection(record, committed) { + if (deferredGridSelection !== record) return; + deferredGridSelection = null; + if (!committed || record.cancelled) return; + const current = record.latestCell || record.anchor; + applyPointerSelection(record.anchor, current, !record.released); + if (record.released) { + scheduleFocusSync({ force: true, broadcast: true }); + } +} + +function handleBodyPointerDown(event) { + if (event.button !== 0) return; + if (closestElement(event.target, '[data-cell-editor]')) return; + const cell = cellFromEvent(event); + if (!cell) return; + if (deferredGridSelection || state.editCommitPromise) { + event.preventDefault(); + return; + } + const sheet = activeSheet(); + const targetCell = { ...cell, sheetId: sheet?.id || null }; + if (state.editing) { + if (!gridSelectionNeedsEditCommit(state.editing, targetCell)) return; + const record = { + anchor: cell, + latestCell: cell, + pointerId: event.pointerId, + released: false, + cancelled: false, + }; + deferredGridSelection = record; + try { + event.currentTarget?.setPointerCapture?.(event.pointerId); + } catch (_error) { + // Pointer capture is an enhancement; document-level handlers still + // preserve the deferred selection within the surface. + } + event.preventDefault(); + void commitEdit({ exit: true }) + .then((committed) => completeDeferredGridSelection(record, committed)) + .catch(() => completeDeferredGridSelection(record, false)); + return; + } + applyPointerSelection(cell); + event.preventDefault(); +} + +function handleBodyPointerMove(event) { + if ( + deferredGridSelection + && (deferredGridSelection.pointerId == null || deferredGridSelection.pointerId === event.pointerId) + ) { + const cell = cellFromEvent(event); + if (cell) deferredGridSelection.latestCell = cell; + event.preventDefault(); + return; + } + if (!state.selectionDragging || !state.selectionAnchor) return; + const cell = cellFromEvent(event); + if (!cell) return; + const anchor = state.selectionAnchor; + setFocusFromSelection(anchor.row, anchor.col, cell.row, cell.col); + updateOverlays(); +} + +function handlePointerUp(event) { + if ( + deferredGridSelection + && (deferredGridSelection.pointerId == null || deferredGridSelection.pointerId === event.pointerId) + ) { + deferredGridSelection.released = true; + deferredGridSelection.cancelled = event.type === 'pointercancel'; + event.preventDefault(); + return; + } + if (!state.selectionDragging) return; + state.selectionDragging = false; + // Selection settled — push ambient focus (with cell preview) to the host + // and mirror it into the workbook store for agent tools. + scheduleFocusSync({ force: true, broadcast: true }); +} + +function handleBodyDblClick(event) { + const cell = cellFromEvent(event); + if (!cell) return; + beginEdit(cell.row, cell.col); +} + +function handleHeaderPointerDown(event) { + if (event.button !== 0) return; + const sheet = activeSheet(); + if (!sheet) return; + const bounds = sheetBounds(sheet); + const colNode = closestElement(event.target, '[data-col-h]'); + const rowNode = closestElement(event.target, '[data-row-h]'); + const cornerNode = closestElement(event.target, '[data-grid-corner]'); + if (colNode) { + const c = Number(colNode.dataset.colH); + setFocusFromSelection(0, c, bounds.rows - 1, c, { kind: 'column' }); + } else if (rowNode) { + const r = Number(rowNode.dataset.rowH); + setFocusFromSelection(r, 0, r, bounds.cols - 1, { kind: 'row' }); + } else if (cornerNode) { + setFocusFromSelection(0, 0, bounds.rows - 1, bounds.cols - 1, { kind: 'sheet' }); + } else { + return; + } + updateOverlays(); + event.preventDefault(); + focusGridWrap(); +} + +function moveFocus(dRow, dCol, extend) { + const sheet = activeSheet(); + if (!sheet) return; + const bounds = sheetBounds(sheet); + const focus = normalizeFocusRange(state.focus); + if (extend) { + const nextR2 = clamp(focus.r2 + dRow, 0, bounds.rows - 1); + const nextC2 = clamp(focus.c2 + dCol, 0, bounds.cols - 1); + setFocusFromSelection(focus.r1, focus.c1, nextR2, nextC2); + scrollToCell(nextR2, nextC2); + } else { + const nextR = clamp(focus.r1 + dRow, 0, bounds.rows - 1); + const nextC = clamp(focus.c1 + dCol, 0, bounds.cols - 1); + setFocusFromSelection(nextR, nextC, nextR, nextC); + scrollToCell(nextR, nextC); + } + updateOverlays(); + scheduleViewportFetch(); +} + +function jumpToA1(text) { + const sheet = activeSheet(); + if (!sheet || !text) return; + let parsed; + try { + parsed = parseA1(text); + } catch (_error) { + updateNameBox(); + return; + } + const bounds = sheetBounds(sheet); + const r1 = clamp(parsed.r1, 0, bounds.rows - 1); + const c1 = clamp(parsed.c1, 0, bounds.cols - 1); + const r2 = clamp(parsed.r2, 0, bounds.rows - 1); + const c2 = clamp(parsed.c2, 0, bounds.cols - 1); + setFocusFromSelection(r1, c1, r2, c2); + scrollToCell(r1, c1); + updateOverlays(); + scheduleViewportFetch(); + focusGridWrap(); +} + +/* ------------------------------------------------------------- keyboard */ + +function handleGridKeyDown(event) { + const wrap = q('[data-grid-wrap]'); + if (!wrap || !state.workbookId) return; + const target = event.target; + const tag = target?.tagName; + if (isComposingKey(event)) return; + const modified = event.ctrlKey || event.metaKey; + const lowerKey = event.key.toLowerCase(); + + if (isSaveShortcut(event)) { + event.preventDefault(); + if (state.editing) { + void commitEdit({ exit: true }).then((committed) => { + if (committed) document.dispatchEvent(new CustomEvent('excel-live:save')); + }); + } else { + document.dispatchEvent(new CustomEvent('excel-live:save')); + } + return; + } + + if (tag === 'INPUT' || tag === 'TEXTAREA') { + const isFormula = target?.dataset?.formulaInput != null; + const isEditor = Boolean(target?.closest?.('[data-cell-editor]')); + const isNameBox = target?.dataset?.namebox != null; + if (isNameBox) { + if (event.key === 'Enter') { + event.preventDefault(); + jumpToA1(target.value); + } else if (event.key === 'Escape') { + event.preventDefault(); + updateNameBox(); + focusGridWrap(); + } + return; + } + if (isFormula || isEditor) { + if (event.key === 'Enter') { + event.preventDefault(); + if (isFormula) { + void applyFormulaBarValue(target.value); + } else { + void commitEdit({ dr: 1, dc: 0 }); + } + } else if (event.key === 'Tab' && isEditor) { + // Commit, but preserve native Tab traversal out of the ARIA grid. + void commitEdit({ exit: true }); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelEdit(); + updateFormulaBar(); + } + } + return; + } + + const root = rootElement(); + const inGrid = Boolean( + wrap.contains(target) + || target === wrap + || target === document.body + || target === root + || (root && root.contains(target) && !['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(tag)), + ); + if (modified && lowerKey === 'c' && inGrid) { + event.preventDefault(); + document.dispatchEvent(new CustomEvent('excel-live:copy')); + return; + } + if (modified && lowerKey === 'v' && inGrid) { + event.preventDefault(); + document.dispatchEvent(new CustomEvent('excel-live:paste')); + return; + } + if (modified && lowerKey === 'z' && inGrid) { + event.preventDefault(); + document.dispatchEvent(new CustomEvent(event.shiftKey ? 'excel-live:redo' : 'excel-live:undo')); + return; + } + if (modified && lowerKey === 'y' && inGrid) { + event.preventDefault(); + document.dispatchEvent(new CustomEvent('excel-live:redo')); + return; + } + + if (state.editing) return; + // Accept key events when the grid wrap (or anything inside it) is focused, + // or when focus is on body/root after a cell click that used preventDefault. + if (!inGrid) return; + + const extend = event.shiftKey; + switch (event.key) { + case 'ArrowUp': + event.preventDefault(); + moveFocus(-1, 0, extend); + break; + case 'ArrowDown': + event.preventDefault(); + moveFocus(1, 0, extend); + break; + case 'ArrowLeft': + event.preventDefault(); + moveFocus(0, -1, extend); + break; + case 'ArrowRight': + event.preventDefault(); + moveFocus(0, 1, extend); + break; + case 'Tab': + // ARIA grids are single Tab stops; arrow keys navigate inside, while + // Tab and Shift+Tab leave the grid normally. + break; + case 'PageDown': + event.preventDefault(); + moveFocus(Math.max(1, state.viewport.visibleRows - 2), 0, extend); + break; + case 'PageUp': + event.preventDefault(); + moveFocus(-Math.max(1, state.viewport.visibleRows - 2), 0, extend); + break; + case 'Home': + event.preventDefault(); + jumpToA1('A1'); + break; + case 'Enter': + case 'F2': + event.preventDefault(); + beginEdit(state.focus.r1, state.focus.c1); + break; + case 'Delete': + case 'Backspace': + event.preventDefault(); + void clearSelectionCells(); + break; + default: + if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + beginEdit(state.focus.r1, state.focus.c1, event.key); + event.preventDefault(); + } + break; + } +} + +/* -------------------------------------------------------------- binding */ + +function bindGridDom() { + if (domBound) return; + const host = scrollHost(); + const body = q('[data-grid-body]'); + const pin = q('.el-grid__pin'); + const rowHeaders = q('[data-row-headers]'); + const editorInput = q('[data-cell-editor] input'); + const formulaInput = q('[data-formula-input]'); + if (!host || !body) return; + domBound = true; + + host.addEventListener('scroll', handleScroll, { passive: true }); + body.addEventListener('pointerdown', handleBodyPointerDown); + body.addEventListener('dblclick', handleBodyDblClick); + document.addEventListener('pointermove', handleBodyPointerMove); + document.addEventListener('pointerup', handlePointerUp); + document.addEventListener('pointercancel', handlePointerUp); + if (pin) pin.addEventListener('pointerdown', handleHeaderPointerDown); + if (rowHeaders) rowHeaders.addEventListener('pointerdown', handleHeaderPointerDown); + document.addEventListener('keydown', handleGridKeyDown); + + if (editorInput) { + editorInput.addEventListener('input', () => { + if (!state.editing) return; + state.editing.value = editorInput.value; + const formula = q('[data-formula-input]'); + if (formula && document.activeElement !== formula) formula.value = editorInput.value; + }); + editorInput.addEventListener('blur', () => { + if (state.editing) void commitEdit({ exit: true }); + }); + } + if (formulaInput) { + formulaInput.addEventListener('input', () => { + if (!canEdit()) return; + if (!state.editing) { + const focus = normalizeFocusRange(state.focus); + const sheet = activeSheet(); + if (!sheet) return; + state.editing = createEditingDraft({ + row: focus.r1, + col: focus.c1, + value: formulaInput.value, + workbookId: state.workbookId, + sheetId: sheet.id, + revision: state.meta?.revision ?? null, + origin: 'formula', + }); + } else { + state.editing.value = formulaInput.value; + } + }); + formulaInput.addEventListener('blur', () => { + if (state.editing) void commitEdit({ exit: true }); + }); + } + + if (typeof ResizeObserver === 'function') { + gridResizeObserver?.disconnect(); + gridResizeObserver = new ResizeObserver(scheduleViewportResize); + gridResizeObserver.observe(host); + } + window.addEventListener('resize', scheduleViewportResize); +} + +function renderGrid() { + bindGridDom(); + layoutGrid(); + measureViewport(); + renderGridBody(true); + // The first paint may happen before the real viewport size is known; + // fetch any tiles the measured viewport needs (tile cache dedupes). + scheduleViewportFetch(); +} + +function resetScrollPosition() { + const host = scrollHost(); + if (host) { + host.scrollTop = 0; + host.scrollLeft = 0; + } + state.viewport.scrollRow = 0; + state.viewport.scrollCol = 0; +} + +export { + applyFormulaBarValue, + beginEdit, + cancelEdit, + clearSelectionCells, + commitEdit, + jumpToA1, + measureViewport, + refreshViewportCells, + renderGrid, + renderGridBody, + resetScrollPosition, + scheduleViewportFetch, + scrollToCell, + updateFormulaBar, + updateNameBox, + updateOverlays, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/i18n.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/i18n.js new file mode 100644 index 00000000..643ae358 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/i18n.js @@ -0,0 +1,437 @@ +import { state } from './state.js'; + +const MESSAGES = { + 'en-US': { + title: 'Excel Live', + loading: 'Loading workbook…', + boot: 'Loading Excel Live…', + noWorkspace: 'No storage location available for Excel Live.', + backendMissing: 'Excel engine backend is unavailable.', + newWorkbook: 'New workbook', + openWorkbook: 'Open file', + save: 'Export copy', + exportCsv: 'Export CSV', + undo: 'Undo', + redo: 'Redo', + pinFocus: 'Pin focus to chat', + askFocus: 'Ask AI', + insertRow: 'Insert row', + insertCol: 'Insert column', + acceptProposal: 'Accept', + rejectProposal: 'Reject', + proposalTitle: 'AI proposal', + proposalCells: '{{count}} cell(s)', + proposalHint: 'Review the highlighted cells, then accept or reject.', + focusLabel: 'Focus', + formulaLabel: 'fx', + nameBoxTitle: 'Selection — type a cell or range and press Enter to jump', + formulaTitle: 'Formula or value of the active cell', + dirty: 'Autosaved', + saved: 'Synced', + modeInspect: 'Inspect', + modeEdit: 'Edit', + modeAuthor: 'Author', + modeInspectHint: 'Read-only for both you and AI; proposals cannot be accepted.', + modeEditHint: 'Edit cells directly or let AI prepare reviewable proposals.', + modeAuthorHint: 'Build new structure with AI, still through reviewable proposals.', + includeFocus: 'Send focus to AI', + includeFocusTitle: 'Attach the current selection (and live cell preview) when sending chat messages', + sheetTabs: 'Sheets', + gridLabel: 'Workbook grid', + autoFilterActive: 'Auto filter enabled', + addSheet: 'Add sheet', + emptyTitle: 'No workbook open', + emptyHint: 'Create a new workbook, open an .xlsx / .csv file, or just ask the AI on the left.', + openFailed: 'Failed to open workbook', + createFailed: 'Failed to create workbook', + saveFailed: 'Failed to export workbook copy', + exportFailed: 'Failed to export CSV', + editFailed: 'Failed to apply edit', + clearFailed: 'Failed to clear cells', + proposalAcceptFailed: 'Failed to accept proposal', + proposalRejectFailed: 'Failed to reject proposal', + proposalStale: 'Out of date', + proposalStaleHint: 'The workbook changed after this proposal was created. Reject it and ask AI to prepare a fresh proposal.', + proposalSelectAtLeastOne: 'Select at least one proposed cell to accept.', + inspectAcceptBlocked: 'Inspect mode is read-only. Switch to Edit or Author to accept a proposal.', + inspectEditBlocked: 'Inspect mode is read-only. Switch to Edit or Author to change the workbook.', + modeChangePending: 'The mode change is still being confirmed. Wait a moment before changing the workbook.', + operationPending: 'Another workbook operation is still in progress. Wait for it to finish.', + modeUnavailable: 'Workbook mode control is unavailable.', + modeSwitching: 'Switching to {{mode}}…', + modeChanged: 'Switched to {{mode}}.', + modeSwitchFailed: 'Mode change failed. Still in {{mode}}.', + reviewDetails: 'Review details', + hideDetails: 'Hide details', + acceptSelected: 'Accept selected ({{count}})', + proposalSelected: '{{count}} selected', + selectAll: 'Select all', + selectAllCount: 'Select all {{count}}', + selectNone: 'Select none', + proposalSelectAllConfirm: 'Select all {{count}} proposed cells, including {{hidden}} cells not shown in the review list? Review the full scope before accepting.', + proposalHiddenUnselected: '{{count}} additional cells are not shown and are not selected', + proposalHiddenSelected: '{{count}} additional hidden cells are selected', + proposalHiddenPartiallySelected: '{{selected}} of {{count}} hidden cells are selected', + proposalDetails: 'Proposal before and after details', + proposalDetailsLimited: '{{count}} more cells remain selected but are not rendered here for performance.', + cell: 'Cell', + before: 'Before', + after: 'After', + emptyValue: 'empty', + proposalCellAria: '{{cell}} proposal, before {{before}}, after {{after}}', + validationPassed: 'Validation passed', + validationFailed: 'Validation failed', + validationIssues: '{{count}} validation issue(s)', + validationErrors: '{{count}} validation error(s)', + validationWarnings: '{{count}} validation warning(s)', + proposalInvalidHint: 'This proposal contains validation errors and cannot be accepted. Fix or reject it first.', + validationMustFix: 'Must fix before accepting', + validationReview: 'Review before accepting', + validationMore: '{{count}} more validation issue(s)', + cachedValue: 'Cached result: {{value}}', + proposalLayoutChange: 'Layout change', + acceptLayout: 'Accept layout', + acceptAllWithLayout: 'Accept all + layout ({{count}})', + acceptSelectedLayoutPending: 'Accept selected ({{count}})', + proposalLayoutAllHint: 'All proposed cells are selected, so accepting will apply the cells and layout together.', + proposalLayoutPartialHint: 'Accepting a partial cell selection keeps the layout change pending for separate review.', + styleChanges: 'Style changes', + styleNone: 'none', + styleBold: 'bold', + styleItalic: 'italic', + styleWrap: 'wrap', + style_role: 'Role', + style_fill: 'Fill', + style_font: 'Font', + style_border: 'Border', + style_alignment: 'Alignment', + style_numberFormat: 'Number', + styleSide_top: 'top', + styleSide_right: 'right', + styleSide_bottom: 'bottom', + styleSide_left: 'left', + layoutChanges: 'Layout changes', + layout_columns: 'Column widths', + layout_rows: 'Row heights', + layout_freezePanes: 'Freeze panes', + layout_autoFilter: 'Auto filter', + layoutOff: 'off', + layoutDefault: 'default', + layoutAutoFit: 'auto fit', + layoutFreezeValue: '{{rows}} row(s), {{columns}} column(s)', + formatStyles: 'Cell styles', + formatHeader: 'Header', + formatTotal: 'Total', + formatInput: 'Input', + formatOutput: 'Output', + formatBold: 'Bold', + formatFill: 'Fill color', + numberFormat: 'Number format', + numberGeneral: 'General', + numberInteger: 'Number', + numberDecimal: 'Decimal', + numberCurrency: 'Currency (USD)', + numberPercent: 'Percentage', + numberDate: 'Date (YYYY-MM-DD)', + formatProposalReady: 'Formatting proposal ready for review', + formatProposalFailed: 'Failed to prepare formatting proposal', + formatProposalExists: 'Review or reject the active proposal before preparing another format change.', + formatProposalExistsShort: 'Finish the active proposal to format again', + formatProposalMissing: 'The engine did not return a reviewable formatting proposal.', + formatRangeTooLarge: 'Formatting proposals are limited to {{count}} cells. Narrow the selection and try again.', + formatIntent: 'Format {{range}}', + formatRoleIntent: 'Apply the {{role}} style to the selected cells', + formatBoldIntent: 'Make the selected cells bold', + formatFillIntent: 'Apply a fill color to the selected cells', + formatNumberIntent: 'Apply {{format}} to the selected cells', + switchFailed: 'Failed to switch sheet', + insertFailed: 'Failed to insert', + addSheetFailed: 'Failed to add sheet', + askPrompt: 'About {{sheet}}!{{a1}} in {{title}}: ', + pinMarker: '[Pinned spreadsheet focus: {{sheet}}!{{a1}}]', + statusReady: 'Ready', + statusCreated: 'Created workbook (live store)', + statusOpened: 'Opened workbook (live store)', + statusResumed: 'Resumed last workbook', + statusSaved: 'Exported workbook file', + statusExportedCopy: 'Exported a new workbook copy; the source file was not overwritten', + statusExported: 'CSV exported', + statusExportedSanitized: 'CSV exported; safely escaped {{count}} formula-like text cell(s)', + statusAccepted: 'Proposal accepted', + statusAcceptedCells: 'Accepted {{count}} proposed cell(s)', + statusAcceptedLayout: 'Accepted workbook layout proposal', + statusAcceptedCellsAndLayout: 'Accepted {{count}} proposed cell(s) and workbook layout', + statusAcceptedCellsLayoutPending: 'Accepted {{count}} proposed cell(s); layout remains pending', + statusRejected: 'Proposal rejected', + statusPinned: 'Focus pinned to chat', + statusAsked: 'Prompt filled in chat', + statusAgentUpdated: 'Updated by AI', + statusCleared: 'Cells cleared (autosaved)', + statusUndone: 'Undid the last workbook change', + statusRedone: 'Redid the workbook change', + statusCopied: 'Copied {{count}} cell(s)', + statusPasted: 'Pasted {{count}} cell(s)', + copying: 'Copying cells…', + pasting: 'Pasting cells…', + copyFailed: 'Failed to copy cells', + pasteFailed: 'Failed to paste cells', + undoFailed: 'Failed to undo', + redoFailed: 'Failed to redo', + clipboardUnavailable: 'Clipboard access is unavailable.', + clipboardRangeTooLarge: 'Copy and paste are limited to {{count}} cells at a time.', + clipboardRangeIncomplete: 'The selected cells could not be loaded completely. Narrow the selection and try again.', + exportCopyDialogMissing: 'The export dialog is unavailable. The source workbook was not changed.', + exportCopySourceBlocked: 'Choose a different file name. Export copy never overwrites the source workbook.', + exportCopyMacroExtension: 'Macro-enabled workbooks must be exported as .xlsm to preserve the source format. No file was written.', + exportCopyXlsxExtension: 'Workbook copies must be exported as .xlsx. No file was written.', + excelMacroFile: 'Excel Macro-Enabled Workbook', + fidelityExportConfirm: 'This workbook contains Excel features that Excel Live cannot round-trip exactly. A separate copy may omit formatting, charts, macros, or other advanced content. The source file will remain unchanged. Export the copy anyway?', + fidelityXlsmToXlsxConfirm: 'This macro-enabled workbook can no longer be round-tripped safely after its style or layout changes. Exporting will create a separate .xlsx rebuild without VBA or macros; the source .xlsm remains unchanged. Continue?', + statusExportCancelled: 'Export cancelled; the source file was not changed', + exportCopyMacroStructureBlocked: 'This macro-enabled workbook has structural changes that cannot be exported safely without risking VBA or workbook integrity. The source file was not changed.', + exportCopyFidelityAckRequired: 'Review the compatibility warning and confirm the lossy copy before exporting. The source file was not changed.', + fidelityLimited: 'Compatibility notice: this view preserves basic values and formulas only. Formatting, macros, charts, and advanced Excel features may not be represented.', + fidelitySourcePreservingView: 'Exported copies preserve the source Excel package. The live grid renders declared cell styles, common number formats, and layout metadata; charts and other advanced presentation semantics may still require Excel.', + fidelityUnknown: 'Workbook fidelity has not been verified. Export a copy and check it before relying on the result.', + calculationPending: 'Formula results are not recalculated yet; displayed values may be stale.', + calculationFailed: 'Formula recalculation failed; displayed results may be stale.', + calculationUnknown: 'Formula calculation status is unknown.', + staleFormulaCsvConfirm: 'Some formulas have not been recalculated. CSV exports cached values, which may be stale. The source workbook will remain unchanged. Export the CSV copy anyway?', + staleFormulaCsvAckRequired: 'Review and confirm the stale formula warning before exporting CSV. No file was written.', + uncachedFormulaCsvBlocked: 'One or more formulas have no calculated result, so CSV export was blocked. Recalculate the workbook in Excel first.', + selectionAnnouncement: '{{sheet}} {{a1}}, {{rows}} row(s) by {{cols}} column(s)', + readOnly: 'Read-only', + statsCount: 'Count', + statsSum: 'Sum', + statsAvg: 'Avg', + statsFormulaStale: 'Formula results need recalculation', + untitled: 'Untitled Workbook', + openDialogTitle: 'Open workbook', + saveDialogTitle: 'Export workbook copy', + exportDialogTitle: 'Export CSV', + }, + 'zh-CN': { + title: 'Excel Live', + loading: '正在加载工作簿…', + boot: '正在加载 Excel Live…', + noWorkspace: 'Excel Live 没有可用的存储位置。', + backendMissing: 'Excel 引擎后端不可用。', + newWorkbook: '新建工作簿', + openWorkbook: '打开文件', + save: '导出副本', + exportCsv: '导出 CSV', + undo: '撤销', + redo: '重做', + pinFocus: '固定焦点到对话', + askFocus: '询问 AI', + insertRow: '插入行', + insertCol: '插入列', + acceptProposal: '接受', + rejectProposal: '拒绝', + proposalTitle: 'AI 提案', + proposalCells: '{{count}} 个单元格', + proposalHint: '请检查高亮单元格,然后接受或拒绝。', + focusLabel: '焦点', + formulaLabel: 'fx', + nameBoxTitle: '当前选区 — 输入单元格或范围后回车跳转', + formulaTitle: '活动单元格的公式或值', + dirty: '已实时保存', + saved: '已同步', + modeInspect: '检查', + modeEdit: '编辑', + modeAuthor: '创作', + modeInspectHint: '人和 AI 均只读,不能接受提案。', + modeEditHint: '可直接编辑,也可让 AI 生成待审阅提案。', + modeAuthorHint: '用 AI 创建新结构,但所有写入仍需提案审阅。', + includeFocus: '发送焦点给 AI', + includeFocusTitle: '发送对话消息时附带当前选区与实时单元格预览', + sheetTabs: '工作表', + gridLabel: '工作簿网格', + autoFilterActive: '已启用自动筛选', + addSheet: '新增工作表', + emptyTitle: '尚未打开工作簿', + emptyHint: '新建工作簿、打开 .xlsx / .csv 文件,或直接在左侧向 AI 提问。', + openFailed: '打开工作簿失败', + createFailed: '创建工作簿失败', + saveFailed: '导出工作簿副本失败', + exportFailed: '导出 CSV 失败', + editFailed: '应用编辑失败', + clearFailed: '清除单元格失败', + proposalAcceptFailed: '接受提案失败', + proposalRejectFailed: '拒绝提案失败', + proposalStale: '已过期', + proposalStaleHint: '工作簿在提案生成后已发生变化。请拒绝该提案,并让 AI 基于最新内容重新生成。', + proposalSelectAtLeastOne: '请至少选择一个要接受的提案单元格。', + inspectAcceptBlocked: '检查模式为只读。请切换到编辑或创作模式后再接受提案。', + inspectEditBlocked: '检查模式为只读。请切换到编辑或创作模式后再修改工作簿。', + modeChangePending: '模式切换仍在确认中,请稍后再修改工作簿。', + operationPending: '另一项工作簿操作仍在进行中,请等待其完成。', + modeUnavailable: '工作簿模式控制当前不可用。', + modeSwitching: '正在切换到{{mode}}模式…', + modeChanged: '已切换到{{mode}}模式。', + modeSwitchFailed: '模式切换失败,仍处于{{mode}}模式。', + reviewDetails: '审阅明细', + hideDetails: '收起明细', + acceptSelected: '接受所选({{count}})', + proposalSelected: '已选择 {{count}} 个', + selectAll: '全选', + selectAllCount: '全选 {{count}} 个', + selectNone: '全不选', + proposalSelectAllConfirm: '要选择全部 {{count}} 个提案单元格吗?其中 {{hidden}} 个未显示在审阅列表中。接受前请确认完整影响范围。', + proposalHiddenUnselected: '另有 {{count}} 个单元格未展示且未选中', + proposalHiddenSelected: '另有 {{count}} 个隐藏单元格已选中', + proposalHiddenPartiallySelected: '隐藏单元格中已选择 {{selected}} / {{count}} 个', + proposalDetails: '提案修改前后明细', + proposalDetailsLimited: '另有 {{count}} 个单元格保持选中;为保证性能,此处未展开显示。', + cell: '单元格', + before: '修改前', + after: '修改后', + emptyValue: '空', + proposalCellAria: '{{cell}} 提案,修改前 {{before}},修改后 {{after}}', + validationPassed: '校验通过', + validationFailed: '校验失败', + validationIssues: '{{count}} 个校验问题', + validationErrors: '{{count}} 个校验错误', + validationWarnings: '{{count}} 个校验警告', + proposalInvalidHint: '该提案包含校验错误,无法接受。请先修复或拒绝提案。', + validationMustFix: '接受前必须修复', + validationReview: '接受前请检查', + validationMore: '另有 {{count}} 个校验问题', + cachedValue: '缓存结果:{{value}}', + proposalLayoutChange: '布局变更', + acceptLayout: '接受布局', + acceptAllWithLayout: '全部接受并应用布局({{count}})', + acceptSelectedLayoutPending: '接受所选({{count}})', + proposalLayoutAllHint: '已选择全部提案单元格;接受后将原子应用单元格与布局。', + proposalLayoutPartialHint: '部分接受单元格后,布局变更会保留在提案中继续等待审阅。', + styleChanges: '样式变更', + styleNone: '无', + styleBold: '粗体', + styleItalic: '斜体', + styleWrap: '自动换行', + style_role: '角色', + style_fill: '底色', + style_font: '字体', + style_border: '边框', + style_alignment: '对齐', + style_numberFormat: '数字格式', + styleSide_top: '上', + styleSide_right: '右', + styleSide_bottom: '下', + styleSide_left: '左', + layoutChanges: '布局变更', + layout_columns: '列宽', + layout_rows: '行高', + layout_freezePanes: '冻结窗格', + layout_autoFilter: '自动筛选', + layoutOff: '关闭', + layoutDefault: '默认', + layoutAutoFit: '自动适应', + layoutFreezeValue: '{{rows}} 行、{{columns}} 列', + formatStyles: '单元格样式', + formatHeader: '表头', + formatTotal: '合计', + formatInput: '输入', + formatOutput: '输出', + formatBold: '粗体', + formatFill: '底色', + numberFormat: '数字格式', + numberGeneral: '常规', + numberInteger: '数值', + numberDecimal: '小数', + numberCurrency: '美元货币', + numberPercent: '百分比', + numberDate: '日期(YYYY-MM-DD)', + formatProposalReady: '格式提案已生成,请审阅', + formatProposalFailed: '生成格式提案失败', + formatProposalExists: '请先审阅或拒绝当前提案,再创建新的格式变更。', + formatProposalExistsShort: '完成当前提案后可继续设置格式', + formatProposalMissing: '引擎没有返回可审阅的格式提案。', + formatRangeTooLarge: '单次格式提案最多支持 {{count}} 个单元格,请缩小选区后重试。', + formatIntent: '设置 {{range}} 的格式', + formatRoleIntent: '将所选单元格设置为{{role}}样式', + formatBoldIntent: '将所选单元格设置为粗体', + formatFillIntent: '为所选单元格设置底色', + formatNumberIntent: '将所选单元格设置为{{format}}', + switchFailed: '切换工作表失败', + insertFailed: '插入失败', + addSheetFailed: '新增工作表失败', + askPrompt: '关于 {{title}} 中的 {{sheet}}!{{a1}}:', + pinMarker: '[已固定表格焦点:{{sheet}}!{{a1}}]', + statusReady: '就绪', + statusCreated: '已创建工作簿(实时库)', + statusOpened: '已打开工作簿(实时库)', + statusResumed: '已恢复上次的工作簿', + statusSaved: '已导出工作簿文件', + statusExportedCopy: '已导出新的工作簿副本,未覆盖源文件', + statusExported: 'CSV 已导出', + statusExportedSanitized: 'CSV 已导出;已安全转义 {{count}} 个公式样文本单元格', + statusAccepted: '提案已接受', + statusAcceptedCells: '已接受 {{count}} 个提案单元格', + statusAcceptedLayout: '已接受工作簿布局提案', + statusAcceptedCellsAndLayout: '已接受 {{count}} 个提案单元格及工作簿布局', + statusAcceptedCellsLayoutPending: '已接受 {{count}} 个提案单元格,布局仍待审阅', + statusRejected: '提案已拒绝', + statusPinned: '焦点已固定到对话', + statusAsked: '已填入对话输入框', + statusAgentUpdated: 'AI 已更新内容', + statusCleared: '已清除单元格(已实时保存)', + statusUndone: '已撤销上一次工作簿修改', + statusRedone: '已重做工作簿修改', + statusCopied: '已复制 {{count}} 个单元格', + statusPasted: '已粘贴 {{count}} 个单元格', + copying: '正在复制单元格…', + pasting: '正在粘贴单元格…', + copyFailed: '复制单元格失败', + pasteFailed: '粘贴单元格失败', + undoFailed: '撤销失败', + redoFailed: '重做失败', + clipboardUnavailable: '当前无法访问剪贴板。', + clipboardRangeTooLarge: '单次复制或粘贴最多支持 {{count}} 个单元格。', + clipboardRangeIncomplete: '所选单元格未能完整加载,请缩小选区后重试。', + exportCopyDialogMissing: '导出对话框不可用,源工作簿未发生变化。', + exportCopySourceBlocked: '请选择其他文件名;导出副本绝不会覆盖源工作簿。', + exportCopyMacroExtension: '启用宏的工作簿必须导出为 .xlsm 才能保持源格式,本次未写入文件。', + exportCopyXlsxExtension: '工作簿副本必须导出为 .xlsx,本次未写入文件。', + excelMacroFile: '启用宏的 Excel 工作簿', + fidelityExportConfirm: '该工作簿包含 Excel Live 无法完整往返保留的功能。独立副本可能缺少格式、图表、宏或其他高级内容;源文件不会改变。仍要导出副本吗?', + fidelityXlsmToXlsxConfirm: '该启用宏的工作簿在样式或布局变更后已无法安全往返保存。继续将生成一个不含 VBA 与宏的独立 .xlsx 重建副本,源 .xlsm 不会改变。是否继续?', + statusExportCancelled: '已取消导出,源文件未发生变化', + exportCopyMacroStructureBlocked: '该启用宏的工作簿包含无法安全导出的结构变更;为避免损坏 VBA 或工作簿,未写入副本,源文件也未改变。', + exportCopyFidelityAckRequired: '请先阅读兼容性提示并确认有损副本,再执行导出;源文件未发生变化。', + fidelityLimited: '兼容性提示:当前仅可靠保留基础值与公式;格式、宏、图表和高级 Excel 功能可能无法呈现。', + fidelitySourcePreservingView: '导出的副本会保留源 Excel 包。实时网格可呈现已声明的单元格样式、常用数字格式与布局元数据;图表等高级呈现仍可能需要在 Excel 中查看。', + fidelityUnknown: '尚未验证工作簿保真度,请导出副本并检查后再使用。', + calculationPending: '公式结果尚未重算,当前显示值可能已过期。', + calculationFailed: '公式重算失败,当前显示结果可能已过期。', + calculationUnknown: '公式计算状态未知。', + staleFormulaCsvConfirm: '部分公式尚未重算。CSV 将导出可能已过期的缓存值;源工作簿不会改变。仍要导出 CSV 副本吗?', + staleFormulaCsvAckRequired: '请先阅读并确认公式结果可能过期的提示,再导出 CSV;本次未写入文件。', + uncachedFormulaCsvBlocked: '一个或多个公式尚无计算结果,CSV 导出已阻止。请先在 Excel 中重算工作簿。', + selectionAnnouncement: '{{sheet}} {{a1}},{{rows}} 行 × {{cols}} 列', + readOnly: '只读', + statsCount: '计数', + statsSum: '求和', + statsAvg: '平均', + statsFormulaStale: '公式结果需要重算', + untitled: '未命名工作簿', + openDialogTitle: '打开工作簿', + saveDialogTitle: '导出工作簿副本', + exportDialogTitle: '导出 CSV', + }, +}; + +function messages() { + const locale = state.locale || 'en-US'; + return MESSAGES[locale] || MESSAGES[locale.split('-')[0]] || MESSAGES['en-US']; +} + +function t(key, params = {}) { + const template = messages()[key] || MESSAGES['en-US'][key] || key; + return Object.entries(params).reduce( + (value, [name, replacement]) => value.replaceAll(`{{${name}}}`, String(replacement ?? '')), + template, + ); +} + +export { MESSAGES, t }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/interaction.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/interaction.js new file mode 100644 index 00000000..ab534452 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/interaction.js @@ -0,0 +1,134 @@ +function normalizeWorkbookId(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function agentRefreshTargetsCurrentWorkbook(currentWorkbookId, targetWorkbookId) { + const current = normalizeWorkbookId(currentWorkbookId); + const target = normalizeWorkbookId(targetWorkbookId); + return !current || !target || current === target; +} + +function createEditingDraft({ + row, + col, + value, + workbookId, + sheetId, + revision, + origin = 'cell', +}) { + return { + row, + col, + value: String(value ?? ''), + workbookId: normalizeWorkbookId(workbookId) || null, + sheetId: normalizeWorkbookId(sheetId) || null, + baseRevision: revision ?? null, + origin: origin === 'formula' ? 'formula' : 'cell', + }; +} + +function editingExpectedRevision(editing, fallbackRevision) { + return editing?.baseRevision ?? fallbackRevision; +} + +function isComposingKey(event) { + return Boolean(event?.isComposing || event?.keyCode === 229); +} + +function isSaveShortcut(event) { + return Boolean( + event + && (event.ctrlKey || event.metaKey) + && String(event.key || '').toLowerCase() === 's', + ); +} + +function gridSelectionNeedsEditCommit(editing, targetCell) { + if (!editing || !targetCell) return false; + if (!Number.isInteger(targetCell.row) || !Number.isInteger(targetCell.col)) return false; + const targetSheetId = normalizeWorkbookId(targetCell.sheetId); + const editingSheetId = normalizeWorkbookId(editing.sheetId); + return editing.row !== targetCell.row + || editing.col !== targetCell.col + || Boolean(targetSheetId && editingSheetId && targetSheetId !== editingSheetId); +} + +function buildSelectionStyleCells(focus, change = {}, maxCells = 5000) { + const r1 = Number(focus?.r1); + const c1 = Number(focus?.c1); + const r2 = Number(focus?.r2 ?? focus?.r1); + const c2 = Number(focus?.c2 ?? focus?.c1); + if (![r1, c1, r2, c2].every(Number.isInteger)) return []; + const rowStart = Math.min(r1, r2); + const rowEnd = Math.max(r1, r2); + const colStart = Math.min(c1, c2); + const colEnd = Math.max(c1, c2); + const count = (rowEnd - rowStart + 1) * (colEnd - colStart + 1); + if (count > maxCells) throw new RangeError(`Formatting selection exceeds ${maxCells} cells`); + const styleRole = String(change.styleRole || '').trim() || null; + const style = change.style && typeof change.style === 'object' ? change.style : null; + if (!styleRole && !style) return []; + const cells = []; + for (let row = rowStart; row <= rowEnd; row += 1) { + for (let col = colStart; col <= colEnd; col += 1) { + cells.push({ + row, + col, + ...(styleRole ? { styleRole } : {}), + ...(style ? { style } : {}), + }); + } + } + return cells; +} + +function proposalAcceptPlan(allCellRefs, selectedCellRefs, hasLayoutChanges) { + const all = [...new Set((allCellRefs || []).filter(Boolean))]; + const selectedSet = new Set((selectedCellRefs || []).filter((ref) => all.includes(ref))); + const selected = all.filter((ref) => selectedSet.has(ref)); + const layoutOnly = hasLayoutChanges && all.length === 0; + const allCellsSelected = all.length > 0 && selected.length === all.length; + const acceptsLayout = Boolean(hasLayoutChanges && (layoutOnly || allCellsSelected)); + return { + all, + selected, + layoutOnly, + allCellsSelected, + acceptsLayout, + // The Engine interprets omitted cellRefs as whole-proposal acceptance, + // which is the only atomic path that applies layout alongside cells. + payloadCellRefs: acceptsLayout ? null : selected, + }; +} + +function exportCopyPlan(sourcePath, fidelity) { + const source = String(sourcePath || '').trim().toLowerCase(); + const macroEnabled = source.endsWith('.xlsm'); + const excelSource = source.endsWith('.xlsx') || macroEnabled; + const canRoundTrip = fidelity && typeof fidelity === 'object' + ? fidelity.canRoundTrip ?? fidelity.can_round_trip + : undefined; + const acknowledgeFidelityLoss = Boolean(excelSource && canRoundTrip === false); + const lossyMacroRebuild = macroEnabled && acknowledgeFidelityLoss; + return { + macroEnabled, + excelSource, + canRoundTrip, + acknowledgeFidelityLoss, + lossyMacroRebuild, + extension: macroEnabled && !lossyMacroRebuild ? 'xlsm' : 'xlsx', + }; +} + +export { + agentRefreshTargetsCurrentWorkbook, + buildSelectionStyleCells, + createEditingDraft, + editingExpectedRevision, + exportCopyPlan, + gridSelectionNeedsEditCommit, + isComposingKey, + isSaveShortcut, + proposalAcceptPlan, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/model.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/model.js new file mode 100644 index 00000000..bc709213 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/model.js @@ -0,0 +1,448 @@ +import { formatA1, parseA1 } from './a1.js'; +import { + LIVE_VIEW_SEMANTICS, + cellDisplayWithStyle, + normalizeSheetLayout, + snapshotParts, +} from './formatting.js'; +import { state } from './state.js'; +import { cellCacheKey } from './util.js'; + +const FOCUS_PREVIEW_MAX_CELLS = 200; +const VALID_WORKBOOK_MODES = new Set(['inspect', 'edit', 'author']); +const FRESH_CALCULATION_STATES = new Set([ + 'current', + 'ready', + 'calculated', + 'recalculated', + 'ok', + 'not-required', +]); + +function calculationStatusToken(status = state.meta?.calculationStatus) { + if (typeof status === 'string') return status.trim().toLowerCase().replaceAll('_', '-'); + if (!status || typeof status !== 'object') return ''; + return String(status.status || status.state || status.kind || '') + .trim() + .toLowerCase() + .replaceAll('_', '-'); +} + +function calculationStatusIsFresh(status = state.meta?.calculationStatus) { + return FRESH_CALCULATION_STATES.has(calculationStatusToken(status)); +} + +function cellHasFormulaEvidence(cell) { + return Boolean(cell && (cell.formulaEvidence === true || (typeof cell.f === 'string' && cell.f.length > 0))); +} + +function activeSheet() { + return (state.sheets || []).find((sheet) => sheet.id === state.activeSheetId) || state.sheets?.[0] || null; +} + +function sheetBounds(sheet = activeSheet()) { + return { + rows: Math.max(1, Number(sheet?.rows) || 50), + cols: Math.max(1, Number(sheet?.cols) || 26), + }; +} + +function normalizeFocusRange(focusLike = state.focus, sheet = activeSheet()) { + const bounds = sheetBounds(sheet); + let r1 = 0; + let c1 = 0; + let r2 = 0; + let c2 = 0; + if (focusLike?.a1) { + try { + const parsed = parseA1(focusLike.a1); + r1 = parsed.r1; + c1 = parsed.c1; + r2 = parsed.r2; + c2 = parsed.c2; + } catch (_error) { + // keep defaults + } + } else { + r1 = Number(focusLike?.r1) || 0; + c1 = Number(focusLike?.c1) || 0; + r2 = Number(focusLike?.r2 ?? focusLike?.r1) || 0; + c2 = Number(focusLike?.c2 ?? focusLike?.c1) || 0; + } + const startRow = Math.max(0, Math.min(Math.min(r1, r2), bounds.rows - 1)); + const startCol = Math.max(0, Math.min(Math.min(c1, c2), bounds.cols - 1)); + const endRow = Math.max(startRow, Math.min(Math.max(r1, r2), bounds.rows - 1)); + const endCol = Math.max(startCol, Math.min(Math.max(c1, c2), bounds.cols - 1)); + r1 = startRow; + c1 = startCol; + r2 = endRow; + c2 = endCol; + const a1 = formatA1(r1, c1, r2, c2); + const kind = focusLike?.kind || (r1 === r2 && c1 === c2 ? 'cell' : 'range'); + return { + sheetId: focusLike?.sheetId || sheet?.id || state.activeSheetId, + sheetName: focusLike?.sheetName || sheet?.name || null, + a1, + kind, + r1, + c1, + r2, + c2, + rowCount: r2 - r1 + 1, + columnCount: c2 - c1 + 1, + }; +} + +function cellDisplayValue(cell) { + return cellDisplayWithStyle(cell); +} + +function cellRawDisplayValue(cell) { + if (!cell) return ''; + if (cell.v != null) return String(cell.v); + const formula = cell.f ?? cell.formula; + return formula ? `=${String(formula).replace(/^=/, '')}` : ''; +} + +function cellEditValue(cell) { + if (!cell) return ''; + if (cell.f) return `=${cell.f}`; + if (cell.v == null) return ''; + return String(cell.v); +} + +function focusCacheCoverage(focus, sheet = activeSheet()) { + if (!sheet || !focus) { + return { loadedCells: 0, totalCells: 0, ratio: 0, complete: false }; + } + const totalCells = Math.max(0, focus.rowCount * focus.columnCount); + if (totalCells === 0) { + return { loadedCells: 0, totalCells: 0, ratio: 1, complete: true }; + } + + // Iterate the virtualized cache rather than the whole selection. A whole + // column/sheet focus can contain millions of cells while the cache remains + // intentionally bounded to fetched tiles. + const prefix = `${sheet.id}:`; + let loadedCells = 0; + for (const key of state.cells.keys()) { + if (!key.startsWith(prefix)) continue; + const coordinates = key.slice(prefix.length).split(','); + const row = Number(coordinates[0]); + const col = Number(coordinates[1]); + if ( + Number.isInteger(row) + && Number.isInteger(col) + && row >= focus.r1 + && row <= focus.r2 + && col >= focus.c1 + && col <= focus.c2 + ) { + loadedCells += 1; + } + } + const boundedLoaded = Math.min(totalCells, loadedCells); + return { + loadedCells: boundedLoaded, + totalCells, + ratio: boundedLoaded / totalCells, + complete: boundedLoaded === totalCells, + }; +} + +function buildPreviewTsv(focus, sheet = activeSheet(), coverage = null) { + if (!sheet) return null; + const cellTotal = focus.rowCount * focus.columnCount; + if (cellTotal <= 0 || cellTotal > FOCUS_PREVIEW_MAX_CELLS) return null; + const resolvedCoverage = coverage || focusCacheCoverage(focus, sheet); + if (!resolvedCoverage.complete) return null; + const rows = []; + for (let r = focus.r1; r <= focus.r2; r += 1) { + const row = []; + for (let c = focus.c1; c <= focus.c2; c += 1) { + const cell = state.cells.get(cellCacheKey(sheet.id, r, c)); + // Agent focus remains grounded in raw workbook values/formulas; visual + // number formatting is a Surface concern and must not rewrite evidence. + row.push(cellRawDisplayValue(cell).replaceAll('\t', ' ').replaceAll('\n', ' ')); + } + rows.push(row.join('\t')); + } + return rows.join('\n'); +} + +function selectionValueSummary(focus, sheet = activeSheet(), coverage = null) { + if (!sheet) return null; + const cellTotal = focus.rowCount * focus.columnCount; + if (cellTotal <= 0 || cellTotal > 5000) return null; + const resolvedCoverage = coverage || focusCacheCoverage(focus, sheet); + if (!resolvedCoverage.complete) return null; + let numericCount = 0; + let textCount = 0; + let emptyCount = 0; + let formulaCount = 0; + let sum = 0; + for (let r = focus.r1; r <= focus.r2; r += 1) { + for (let c = focus.c1; c <= focus.c2; c += 1) { + const cell = state.cells.get(cellCacheKey(sheet.id, r, c)); + const hasFormula = cellHasFormulaEvidence(cell); + if (!cell || (cell.v == null && !hasFormula)) { + emptyCount += 1; + continue; + } + if (hasFormula) formulaCount += 1; + if (typeof cell.v === 'number') { + numericCount += 1; + sum += cell.v; + } else if (cell.v != null && cell.v !== '') { + textCount += 1; + } + } + } + return { + cellCount: cellTotal, + numericCount, + textCount, + emptyCount, + formulaCount, + sum, + avg: numericCount > 0 ? sum / numericCount : null, + }; +} + +function buildFocusPayload(role = 'ambient') { + const sheet = activeSheet(); + const focus = normalizeFocusRange(state.focus, sheet); + const coverage = focusCacheCoverage(focus, sheet); + const summary = selectionValueSummary(focus, sheet, coverage); + const cellTotal = focus.rowCount * focus.columnCount; + const calculationStatus = state.meta?.calculationStatus || null; + // Cached values are evidence only when the workbook explicitly reports a + // fresh/not-required calculation state. Missing, cached, or stale metadata + // is fail-closed even when the selected cells appear formula-free: shared + // formula followers may not carry formula text of their own. + const formulaResultsFresh = calculationStatusIsFresh(calculationStatus); + const previewTsv = formulaResultsFresh + ? buildPreviewTsv(focus, sheet, coverage) + : null; + const previewOmittedReason = !coverage.complete + ? 'cache-incomplete' + : !formulaResultsFresh && cellTotal <= FOCUS_PREVIEW_MAX_CELLS + ? 'formula-results-stale' + : cellTotal > FOCUS_PREVIEW_MAX_CELLS + ? 'selection-too-large' + : null; + return { + type: 'spreadsheet-focus', + role, + sessionId: state.sessionId || undefined, + workbookId: state.workbookId, + workbookPath: state.path || state.meta?.path || undefined, + sheetId: focus.sheetId, + sheetName: focus.sheetName || sheet?.name || undefined, + a1: focus.a1, + selectionKind: focus.kind, + rowCount: focus.rowCount, + columnCount: focus.columnCount, + previewTsv: previewTsv || undefined, + previewTruncated: cellTotal > FOCUS_PREVIEW_MAX_CELLS, + previewOmitted: Boolean(previewOmittedReason), + previewOmittedReason: previewOmittedReason || undefined, + cacheCoverage: coverage.ratio, + cacheComplete: coverage.complete, + formulaResultsFresh, + calculationStatus: calculationStatus || undefined, + fidelity: { + ...(state.meta?.fidelity || {}), + liveViewSemantics: LIVE_VIEW_SEMANTICS, + presentationSemanticsRendered: true, + cellStylesRendered: true, + commonNumberFormatsRendered: true, + layoutMetadataRendered: true, + }, + cacheLoadedCells: coverage.loadedCells, + cacheTotalCells: coverage.totalCells, + capturedAt: Date.now(), + valueSummary: summary + ? { + cellCount: summary.cellCount, + numericCount: formulaResultsFresh ? summary.numericCount : undefined, + textCount: formulaResultsFresh ? summary.textCount : undefined, + emptyCount: formulaResultsFresh ? summary.emptyCount : undefined, + formulaCount: summary.formulaCount, + } + : undefined, + includeFocusOnSend: state.includeFocusOnSend, + mode: state.mode, + revision: state.meta?.revision ?? undefined, + schemaVersion: 1, + }; +} + +function mergeReadIntoCache(readResult) { + if (!readResult) return; + const sheetId = readResult.sheetId || state.activeSheetId; + if (!sheetId || !Array.isArray(readResult.values)) return; + let range; + try { + range = parseA1(readResult.a1 || 'A1'); + } catch (_error) { + return; + } + const formulaMap = new Map(); + for (const item of readResult.formulas || []) { + formulaMap.set(`${item.row},${item.col}`, item); + } + const sparseCells = new Map(); + for (const item of readResult.cells || []) { + const row = Number(item?.row ?? item?.r); + const col = Number(item?.col ?? item?.c); + if (!Number.isInteger(row) || !Number.isInteger(col)) continue; + sparseCells.set(`${row},${col}`, item); + } + const styleMatrix = Array.isArray(readResult.styles) + ? readResult.styles + : Array.isArray(readResult.cellStyles) + ? readResult.cellStyles + : null; + for (let i = 0; i < readResult.values.length; i += 1) { + const rowVals = readResult.values[i] || []; + for (let j = 0; j < rowVals.length; j += 1) { + const row = range.r1 + i; + const col = range.c1 + j; + const key = cellCacheKey(sheetId, row, col); + const formulaItem = formulaMap.get(`${row},${col}`) || null; + const sparse = sparseCells.get(`${row},${col}`) || null; + const rawValue = rowVals[j]; + const embedded = rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue) + ? rawValue + : null; + const value = embedded + ? embedded.v ?? embedded.value ?? null + : sparse + ? sparse.v ?? sparse.value ?? rawValue + : rawValue; + const formula = sparse?.formula ?? sparse?.f ?? embedded?.formula ?? embedded?.f ?? formulaItem?.formula ?? null; + const matrixStyle = styleMatrix?.[i]?.[j] || null; + const style = sparse?.style || embedded?.style || formulaItem?.style || matrixStyle || null; + state.cells.set(key, { + v: value, + f: formula, + t: formula ? 'f' : sparse?.t ?? embedded?.t ?? (value == null ? null : typeof value === 'number' ? 'n' : 's'), + formulaEvidence: Boolean(formula || sparse?.formulaEvidence || embedded?.formulaEvidence || formulaItem?.formulaEvidence), + formulaType: sparse?.formulaType || embedded?.formulaType || formulaItem?.formulaType || null, + formulaRef: sparse?.formulaRef || embedded?.formulaRef || formulaItem?.formulaRef || null, + style, + }); + } + } + // Styled empty cells can be omitted from values[][] yet still need to render. + for (const [coordinates, sparse] of sparseCells) { + const [row, col] = coordinates.split(',').map(Number); + const key = cellCacheKey(sheetId, row, col); + const existing = state.cells.get(key) || {}; + state.cells.set(key, { + ...existing, + v: sparse.v ?? sparse.value ?? existing.v ?? null, + f: sparse.formula ?? sparse.f ?? existing.f ?? null, + t: sparse.t ?? existing.t ?? null, + formulaEvidence: Boolean(sparse.formulaEvidence || sparse.formula || sparse.f || existing.formulaEvidence), + formulaType: sparse.formulaType || existing.formulaType || null, + formulaRef: sparse.formulaRef || existing.formulaRef || null, + style: sparse.style || existing.style || null, + }); + } + if (readResult.layout) { + state.sheetLayouts.set(sheetId, normalizeSheetLayout(readResult.layout)); + } +} + +function proposalCellSet(proposal = state.proposal) { + const set = new Map(); + if (!proposal?.cells?.length) return set; + const activeSheetId = state.activeSheetId; + if (proposal.sheetId && activeSheetId && proposal.sheetId !== activeSheetId) return set; + for (const cell of proposal.cells) { + const cellSheetId = cell.sheetId || proposal.sheetId || activeSheetId; + if (activeSheetId && cellSheetId && cellSheetId !== activeSheetId) continue; + const row = Number(cell.row ?? cell.r); + const col = Number(cell.col ?? cell.c); + if (!Number.isInteger(row) || !Number.isInteger(col)) continue; + set.set(`${row},${col}`, cell); + } + return set; +} + +function applyMeta(meta) { + if (!meta) return; + const previousWorkbookId = state.workbookId; + const nextWorkbookId = meta.workbookId || previousWorkbookId; + const workbookChanged = Boolean(nextWorkbookId && previousWorkbookId !== nextWorkbookId); + const hasPath = Object.prototype.hasOwnProperty.call(meta, 'path'); + const hasSourcePath = Object.prototype.hasOwnProperty.call(meta, 'sourcePath'); + state.meta = meta; + state.workbookId = nextWorkbookId; + if (workbookChanged) state.sheetLayouts.clear(); + // Workbook identity owns its source path. In particular, a native/new + // workbook with a null path must never inherit the previous .xlsx/.xlsm + // source merely because both workbooks reuse this surface instance. + if (hasPath) { + state.path = meta.path ?? null; + } else if (hasSourcePath) { + state.path = meta.sourcePath ?? null; + } else if (workbookChanged) { + state.path = null; + } + state.dirty = Boolean(meta.dirty); + state.activeSheetId = meta.activeSheetId || state.activeSheetId; + const persistedMode = String(meta.mode || '').trim().toLowerCase(); + if (VALID_WORKBOOK_MODES.has(persistedMode)) { + state.mode = persistedMode; + } + if (Array.isArray(meta.sheets)) { + state.sheets = meta.sheets; + for (const sheet of meta.sheets) { + if (sheet?.id && sheet.layout) { + state.sheetLayouts.set(sheet.id, normalizeSheetLayout(sheet.layout)); + } + } + } + if (meta.focus?.a1) { + state.focus = normalizeFocusRange({ + ...meta.focus, + sheetId: meta.focus.sheetId || state.activeSheetId, + }); + } +} + +function proposalSnapshotDisplay(snapshot) { + return snapshotParts(snapshot).primary; +} + +function proposalBeforeDisplay(cell) { + return proposalSnapshotDisplay(cell?.before); +} + +function proposalAfterDisplay(cell) { + return proposalSnapshotDisplay(cell?.after); +} + +export { + FOCUS_PREVIEW_MAX_CELLS, + activeSheet, + applyMeta, + buildFocusPayload, + buildPreviewTsv, + calculationStatusIsFresh, + cellDisplayValue, + cellRawDisplayValue, + cellEditValue, + mergeReadIntoCache, + normalizeFocusRange, + focusCacheCoverage, + proposalAfterDisplay, + proposalBeforeDisplay, + proposalSnapshotDisplay, + proposalCellSet, + selectionValueSummary, + sheetBounds, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/proposal.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/proposal.js new file mode 100644 index 00000000..cec72400 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/proposal.js @@ -0,0 +1,584 @@ +import { callExcel } from './backend.js'; +import { formatA1 } from './a1.js'; +import { applyMeta } from './model.js'; +import { + layoutDifferences, + proposalHasLayoutChanges, + proposalValidationDetails, + snapshotParts, + styleDifferences, +} from './formatting.js'; +import { state, invalidateCells } from './state.js'; +import { t } from './i18n.js'; +import { escapeHtml, rootElement } from './util.js'; +import { refreshViewportCells, renderGrid } from './grid.js'; +import { showToast, updateChrome } from './views.js'; +import { proposalAcceptPlan } from './interaction.js'; + +const PROPOSAL_DETAIL_LIMIT = 300; + +function proposalInteractionLocked() { + return state.loading || state.dialogPending || state.modePending; +} + +function proposalCellRef(cell, proposal = state.proposal) { + const explicit = cell?.cellRef ?? cell?.ref ?? cell?.a1; + if (explicit && typeof explicit === 'object') { + const explicitA1 = explicit.a1; + if (explicitA1 != null && String(explicitA1).trim()) return String(explicitA1).trim(); + const explicitRow = Number(explicit.row ?? explicit.r); + const explicitCol = Number(explicit.col ?? explicit.c); + if (Number.isInteger(explicitRow) && Number.isInteger(explicitCol)) { + const sheetId = explicit.sheetId || cell?.sheetId || proposal?.sheetId || ''; + const a1 = formatA1(explicitRow, explicitCol); + return sheetId ? `${sheetId}!${a1}` : a1; + } + } + if (explicit != null && String(explicit).trim()) return String(explicit).trim(); + const row = Number(cell?.row ?? cell?.r); + const col = Number(cell?.col ?? cell?.c); + if (!Number.isInteger(row) || !Number.isInteger(col)) return ''; + const a1 = formatA1(row, col); + const sheetId = cell?.sheetId || proposal?.sheetId || ''; + return sheetId ? `${sheetId}!${a1}` : a1; +} + +function proposalCellRefs(proposal = state.proposal) { + if (!Array.isArray(proposal?.cells)) return []; + return proposal.cells.map((cell) => proposalCellRef(cell, proposal)).filter(Boolean); +} + +function visibleProposalCellRefs(proposal = state.proposal) { + if (!Array.isArray(proposal?.cells)) return []; + return proposal.cells + .slice(0, PROPOSAL_DETAIL_LIMIT) + .map((cell) => proposalCellRef(cell, proposal)) + .filter(Boolean); +} + +function hiddenProposalSelection(proposal = state.proposal) { + if (!Array.isArray(proposal?.cells) || proposal.cells.length <= PROPOSAL_DETAIL_LIMIT) { + return { count: 0, selected: 0 }; + } + const hiddenRefs = proposal.cells + .slice(PROPOSAL_DETAIL_LIMIT) + .map((cell) => proposalCellRef(cell, proposal)) + .filter(Boolean); + return { + count: hiddenRefs.length, + selected: hiddenRefs.filter((ref) => state.proposalSelectedCellRefs.has(ref)).length, + }; +} + +function hiddenSelectionText(proposal = state.proposal) { + const hidden = hiddenProposalSelection(proposal); + if (hidden.count === 0) return ''; + if (hidden.selected === 0) return t('proposalHiddenUnselected', { count: hidden.count }); + if (hidden.selected === hidden.count) return t('proposalHiddenSelected', { count: hidden.count }); + return t('proposalHiddenPartiallySelected', { + selected: hidden.selected, + count: hidden.count, + }); +} + +function adoptProposal(proposal, options = {}) { + const previous = state.proposal; + const next = proposal || null; + const nextRefs = proposalCellRefs(next); + const preserveSelection = Boolean( + options.preserveSelection + && previous?.id + && next?.id + && previous.id === next.id, + ); + const selected = preserveSelection + ? new Set(nextRefs.filter((ref) => state.proposalSelectedCellRefs.has(ref))) + : new Set(visibleProposalCellRefs(next)); + + state.proposal = next; + state.proposalSelectedCellRefs = selected; + if (!next) state.proposalExpanded = false; + return next; +} + +function proposalIsStale(proposal = state.proposal) { + if (!proposal) return false; + if (proposal.stale === true) return true; + const baseRevision = proposal.baseRevision; + const revision = state.meta?.revision; + if (baseRevision == null || revision == null) return false; + return String(baseRevision) !== String(revision); +} + +function proposalIntent(proposal = state.proposal) { + const intent = proposal?.intent; + if (typeof intent === 'string') return intent.trim(); + if (intent && typeof intent === 'object') { + return String(intent.summary || intent.description || intent.operation || '').trim(); + } + return ''; +} + +function validationSummary(proposal = state.proposal) { + const validation = proposal?.validation; + if (validation == null) return null; + if (typeof validation === 'string') { + return { kind: 'neutral', text: validation }; + } + if (typeof validation === 'boolean') { + return validation + ? { kind: 'ok', text: t('validationPassed') } + : { kind: 'error', text: t('validationFailed') }; + } + if (Array.isArray(validation)) { + return validation.length + ? { kind: 'warning', text: t('validationIssues', { count: validation.length }) } + : { kind: 'ok', text: t('validationPassed') }; + } + if (typeof validation === 'object') { + const details = proposalValidationDetails(proposal); + if (details.invalid) { + return { kind: 'error', text: t('validationErrors', { count: details.errors.length || 1 }) }; + } + if (details.warnings.length > 0) { + return { kind: 'warning', text: t('validationWarnings', { count: details.warnings.length }) }; + } + if (validation.valid === true || ['passed', 'valid', 'ok'].includes(String(validation.status || '').toLowerCase())) { + return { kind: 'ok', text: t('validationPassed') }; + } + if (validation.status) return { kind: 'neutral', text: String(validation.status) }; + } + return null; +} + +function snapshotDisplay(snapshot) { + return snapshotParts(snapshot).primary || t('emptyValue'); +} + +function proposalValidationInvalid(proposal = state.proposal) { + return proposalValidationDetails(proposal).invalid; +} + +async function refreshProposal(options = {}) { + if (!state.workbookId) { + adoptProposal(null); + return null; + } + const result = await callExcel('getProposal', { workbookId: state.workbookId }); + return adoptProposal(result?.proposal || null, options); +} + +async function acceptProposal(cellRefs = null) { + if (!state.workbookId || !state.proposal) return false; + if (state.loading || state.dialogPending) { + showToast('error', t('operationPending')); + return false; + } + if (state.modePending) { + showToast('error', t('modeChangePending')); + return false; + } + if (state.mode === 'inspect') { + showToast('error', t('inspectAcceptBlocked')); + return false; + } + if (proposalIsStale()) { + showToast('error', t('proposalStaleHint')); + return false; + } + if (proposalValidationInvalid()) { + showToast('error', t('proposalInvalidHint')); + return false; + } + const selected = Array.isArray(cellRefs) + ? cellRefs.filter(Boolean) + : [...state.proposalSelectedCellRefs]; + const hasLayoutChanges = proposalHasLayoutChanges(state.proposal); + const plan = proposalAcceptPlan( + proposalCellRefs(state.proposal), + selected, + hasLayoutChanges, + ); + if (!plan.layoutOnly && plan.selected.length === 0) { + showToast('error', t('proposalSelectAtLeastOne')); + return false; + } + + try { + state.loading = true; + updateChrome(); + renderProposalBar(); + const payload = { + workbookId: state.workbookId, + proposalId: state.proposal.id, + baseRevision: state.proposal.baseRevision ?? state.meta?.revision ?? undefined, + expectedRevision: state.proposal.baseRevision ?? state.meta?.revision ?? undefined, + }; + if (plan.payloadCellRefs) payload.cellRefs = plan.payloadCellRefs; + const result = await callExcel('acceptProposal', payload); + applyMeta(result?.meta); + invalidateCells(); + // Keep the user's exclusions authoritative. After a partial accept the + // remaining cells must not silently become selected again. + await refreshProposal({ preserveSelection: true }); + await refreshViewportCells(); + renderGrid(); + state.status = plan.layoutOnly + ? t('statusAcceptedLayout') + : plan.acceptsLayout + ? t('statusAcceptedCellsAndLayout', { count: plan.selected.length }) + : hasLayoutChanges + ? t('statusAcceptedCellsLayoutPending', { count: plan.selected.length }) + : t('statusAcceptedCells', { count: plan.selected.length }); + return true; + } catch (error) { + showToast('error', error?.message || t('proposalAcceptFailed')); + return false; + } finally { + state.loading = false; + updateChrome(); + renderProposalBar(); + } +} + +async function rejectProposal() { + if (!state.workbookId || !state.proposal) return false; + if (state.loading || state.dialogPending) { + showToast('error', t('operationPending')); + return false; + } + if (state.modePending) { + showToast('error', t('modeChangePending')); + return false; + } + try { + state.loading = true; + updateChrome(); + renderProposalBar(); + const result = await callExcel('rejectProposal', { + workbookId: state.workbookId, + proposalId: state.proposal.id, + expectedRevision: state.meta?.revision ?? undefined, + }); + applyMeta(result?.meta); + adoptProposal(null); + renderGrid(); + state.status = t('statusRejected'); + return true; + } catch (error) { + showToast('error', error?.message || t('proposalRejectFailed')); + return false; + } finally { + state.loading = false; + updateChrome(); + renderProposalBar(); + } +} + +function toggleProposalExpanded() { + if (proposalInteractionLocked()) return false; + state.proposalExpanded = !state.proposalExpanded; + renderProposalBar(); + return true; +} + +function updateProposalSelectionChrome() { + const bar = rootElement()?.querySelector('[data-proposal-bar]'); + if (!bar || !state.proposal) return; + const count = state.proposalSelectedCellRefs.size; + const hasLayoutChanges = proposalHasLayoutChanges(state.proposal); + const plan = proposalAcceptPlan(proposalCellRefs(), [...state.proposalSelectedCellRefs], hasLayoutChanges); + const selected = bar.querySelector('[data-proposal-selected-count]'); + if (selected) selected.textContent = t('proposalSelected', { count }); + const accept = bar.querySelector('[data-action="accept-proposal"]'); + if (accept) { + accept.textContent = plan.layoutOnly + ? t('acceptLayout') + : plan.acceptsLayout + ? t('acceptAllWithLayout', { count }) + : hasLayoutChanges + ? t('acceptSelectedLayoutPending', { count }) + : t('acceptSelected', { count }); + accept.disabled = proposalInteractionLocked() + || state.mode === 'inspect' + || proposalIsStale() + || proposalValidationInvalid() + || (!plan.layoutOnly && count === 0); + } + bar.querySelectorAll('[data-proposal-hidden-scope]').forEach((hiddenScope) => { + hiddenScope.textContent = hiddenSelectionText(); + }); +} + +function toggleProposalCell(ref, checked) { + if (!ref || proposalInteractionLocked()) return false; + if (checked) state.proposalSelectedCellRefs.add(ref); + else state.proposalSelectedCellRefs.delete(ref); + updateProposalSelectionChrome(); + return true; +} + +function selectAllProposalCells() { + if (proposalInteractionLocked()) return false; + const refs = proposalCellRefs(); + const hiddenCount = hiddenProposalSelection().count; + if ( + hiddenCount > 0 + && !window.confirm(t('proposalSelectAllConfirm', { count: refs.length, hidden: hiddenCount })) + ) { + return false; + } + state.proposalSelectedCellRefs = new Set(refs); + rootElement()?.querySelectorAll('[data-action="toggle-proposal-cell"]').forEach((input) => { + input.checked = true; + }); + updateProposalSelectionChrome(); + return true; +} + +function clearProposalCellSelection() { + if (proposalInteractionLocked()) return false; + state.proposalSelectedCellRefs = new Set(); + rootElement()?.querySelectorAll('[data-action="toggle-proposal-cell"]').forEach((input) => { + input.checked = false; + }); + updateProposalSelectionChrome(); + return true; +} + +function styleValueSummary(key, value) { + if (value == null || value === '') return t('styleNone'); + if (typeof value === 'string' || typeof value === 'number') return String(value); + if (key === 'font') { + return [ + value.bold ? t('styleBold') : '', + value.italic ? t('styleItalic') : '', + value.size ? `${value.size}pt` : '', + value.color || '', + ].filter(Boolean).join(' · ') || t('styleNone'); + } + if (key === 'alignment') { + return [value.horizontal, value.vertical, value.wrapText ? t('styleWrap') : ''].filter(Boolean).join(' · ') || t('styleNone'); + } + if (key === 'border') { + return Object.entries(value) + .map(([side, detail]) => `${t(`styleSide_${side}`)} ${detail?.style || ''}`.trim()) + .join(' · ') || t('styleNone'); + } + return JSON.stringify(value); +} + +function styleDiffMarkup(cell) { + const diffs = styleDifferences(cell?.before, cell?.after); + if (diffs.length === 0) return ''; + return ` +
+ ${diffs.map((diff) => { + const label = t(`style_${diff.key}`); + const before = styleValueSummary(diff.key, diff.before); + const after = styleValueSummary(diff.key, diff.after); + return `${escapeHtml(label)} ${escapeHtml(before)} ${escapeHtml(after)}`; + }).join('')} +
+ `; +} + +function snapshotMarkup(snapshot, kind) { + const parts = snapshotParts(snapshot); + const primary = parts.primary || t('emptyValue'); + return ` + + ${escapeHtml(primary)} + ${parts.secondary ? `${escapeHtml(t('cachedValue', { value: parts.secondary }))}` : ''} + + `; +} + +function validationIssuesMarkup(proposal) { + const details = proposalValidationDetails(proposal); + const issues = [ + ...details.errors.map((text) => ({ kind: 'error', text })), + ...details.warnings.map((text) => ({ kind: 'warning', text })), + ]; + if (issues.length === 0) return ''; + const visible = issues.slice(0, 8); + return ` +
+ ${escapeHtml(t(details.invalid ? 'validationMustFix' : 'validationReview'))} +
    + ${visible.map((issue) => `
  • ${escapeHtml(issue.text)}
  • `).join('')} +
+ ${issues.length > visible.length ? `${escapeHtml(t('validationMore', { count: issues.length - visible.length }))}` : ''} +
+ `; +} + +function layoutValueSummary(key, value) { + if (value == null) return t('layoutOff'); + if (key === 'freezePanes') { + return t('layoutFreezeValue', { rows: value.rows || 0, columns: value.columns || 0 }); + } + if (key === 'autoFilter') return value?.a1 || t('layoutOff'); + if (Array.isArray(value)) { + if (value.length === 0) return t('layoutDefault'); + return value.map((entry) => { + const range = entry.start === entry.end ? `${entry.start + 1}` : `${entry.start + 1}–${entry.end + 1}`; + const workbookValue = entry.value != null + ? `${entry.value}${entry.unit === 'points' ? 'pt' : ''}` + : t('layoutAutoFit'); + return `${range}: ${workbookValue}`; + }).join(', '); + } + return String(value); +} + +function layoutDiffMarkup(proposal) { + const diffs = layoutDifferences(proposal); + if (diffs.length === 0) return ''; + return ` +
+

${escapeHtml(t('layoutChanges'))}

+ ${diffs.map((diff) => ` +
+ ${escapeHtml(t(`layout_${diff.key}`))} + ${escapeHtml(layoutValueSummary(diff.key, diff.before))} + + ${escapeHtml(layoutValueSummary(diff.key, diff.after))} +
+ `).join('')} +
+ `; +} + +function renderProposalDetails(proposal) { + if (!state.proposalExpanded) return ''; + const locked = proposalInteractionLocked(); + const disabled = locked ? ' disabled' : ''; + const cells = Array.isArray(proposal.cells) ? proposal.cells : []; + const visible = cells.slice(0, PROPOSAL_DETAIL_LIMIT); + const rows = visible.map((cell) => { + const ref = proposalCellRef(cell, proposal); + const checked = state.proposalSelectedCellRefs.has(ref); + const row = Number(cell.row ?? cell.r); + const col = Number(cell.col ?? cell.c); + const location = cell.a1 || ( + Number.isInteger(row) && Number.isInteger(col) ? formatA1(row, col) : ref + ); + const before = snapshotDisplay(cell.before); + const after = snapshotDisplay(cell.after); + return ` + + `; + }).join(''); + const remainder = Math.max(0, cells.length - visible.length); + const hiddenText = hiddenSelectionText(proposal); + const cellDetails = cells.length > 0 ? ` +
+ ${t('proposalSelected', { count: state.proposalSelectedCellRefs.size })} + + +
+
+
+ + ${t('cell')} + ${t('before')} + ${t('after')} +
+ ${rows} +
+ ${remainder > 0 ? `

${escapeHtml(hiddenText)}

` : ''} + ` : ''; + return ` +
+ ${cellDetails} + ${layoutDiffMarkup(proposal)} +
+ `; +} + +function renderProposalBar() { + const bar = rootElement()?.querySelector('[data-proposal-bar]'); + if (!bar) return; + if (!state.proposal) { + bar.hidden = true; + bar.innerHTML = ''; + return; + } + const proposal = state.proposal; + const count = Array.isArray(proposal.cells) + ? proposal.cells.length + : proposal.cellCount || 0; + const range = proposal.a1 + ? `${proposal.sheetName ? `${proposal.sheetName}!` : ''}${proposal.a1}` + : proposal.sheetName || t('layoutChanges'); + const stale = proposalIsStale(proposal); + const validation = validationSummary(proposal); + const intent = proposalIntent(proposal); + const selectedCount = state.proposalSelectedCellRefs.size; + const hasLayoutChanges = proposalHasLayoutChanges(proposal); + const acceptPlan = proposalAcceptPlan(proposalCellRefs(proposal), [...state.proposalSelectedCellRefs], hasLayoutChanges); + const layoutOnly = acceptPlan.layoutOnly; + const hiddenText = hiddenSelectionText(proposal); + const locked = proposalInteractionLocked(); + const validationInvalid = proposalValidationInvalid(proposal); + const acceptDisabled = locked + || state.mode === 'inspect' + || stale + || validationInvalid + || (!layoutOnly && selectedCount === 0); + const rejectDisabled = locked; + bar.hidden = false; + bar.dataset.stale = stale ? 'true' : 'false'; + bar.innerHTML = ` +
+
+ ${t('proposalTitle')} + ${intent ? `${escapeHtml(intent)}` : ''} + + ${layoutOnly ? t('proposalLayoutChange') : t('proposalCells', { count })} + ${hiddenText ? `${escapeHtml(hiddenText)}` : ''} + ${validation ? `${escapeHtml(validation.text)}` : ''} + ${stale ? `${t('proposalStale')}` : ''} +
+
+ + + +
+
+ ${stale ? `

${t('proposalStaleHint')}

` : ''} + ${hasLayoutChanges && !layoutOnly ? `

${t(acceptPlan.acceptsLayout ? 'proposalLayoutAllHint' : 'proposalLayoutPartialHint')}

` : ''} + ${validationIssuesMarkup(proposal)} + ${validationInvalid ? `

${t('proposalInvalidHint')}

` : ''} + ${renderProposalDetails(proposal)} + `; +} + +export { + acceptProposal, + adoptProposal, + clearProposalCellSelection, + proposalCellRef, + proposalCellRefs, + proposalIsStale, + refreshProposal, + rejectProposal, + renderProposalBar, + selectAllProposalCells, + toggleProposalCell, + toggleProposalExpanded, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/render.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/render.js new file mode 100644 index 00000000..941b0a5f --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/render.js @@ -0,0 +1,25 @@ +import { renderGrid } from './grid.js'; +import { renderProposalBar } from './proposal.js'; +import { state } from './state.js'; +import { ensureShell, updateChrome } from './views.js'; + +/** + * Coalesced render: build the shell once, then apply targeted updates. + * The grid keeps its own scroll position and DOM identity across renders. + */ +function render() { + if (state.renderQueued) return; + state.renderQueued = true; + queueMicrotask(() => { + state.renderQueued = false; + const root = ensureShell(); + if (!root) return; + updateChrome(); + renderProposalBar(); + if (state.workbookId) { + renderGrid(); + } + }); +} + +export { render }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/state.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/state.js new file mode 100644 index 00000000..026d2d23 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/state.js @@ -0,0 +1,82 @@ +const state = { + locale: navigator.language || 'en-US', + route: '/sheet', + tabId: null, + sessionId: null, + workspacePath: null, + + workbookId: null, + path: null, + meta: null, + sheets: [], + activeSheetId: null, + + focus: { + sheetId: null, + a1: 'A1', + kind: 'cell', + r1: 0, + c1: 0, + r2: 0, + c2: 0, + }, + proposal: null, + proposalExpanded: false, + proposalSelectedCellRefs: new Set(), + + history: { + canUndo: false, + canRedo: false, + entries: [], + }, + + viewport: { + scrollRow: 0, + scrollCol: 0, + visibleRows: 40, + visibleCols: 16, + }, + // Cell cache keyed `${sheetId}:${row},${col}` plus tile bookkeeping so + // scrolling only fetches ranges we have not read yet. + cells: new Map(), + fetchedTiles: new Set(), + // Canonical per-sheet presentation metadata returned by readRange/meta. + // Kept separate from cell cache because layout applies to empty cells too. + sheetLayouts: new Map(), + + dirty: false, + loading: false, + dialogPending: false, + status: null, + includeFocusOnSend: true, + mode: 'edit', + modePending: false, + pendingMode: null, + editing: null, + editCommitPromise: null, + selectionDragging: false, + selectionAnchor: null, + + bootDone: false, + bootChain: null, + pendingLaunchPath: null, + + renderQueued: false, + shellBuilt: false, + focusSyncTimer: null, + viewportFetchTimer: null, + agentRefreshTimer: null, + pendingAgentRefresh: null, + toastTimer: null, + lastFocusSyncKey: null, + lastRenderedWindow: null, + lastSheetTabsKey: null, +}; + +function invalidateCells() { + state.cells.clear(); + state.fetchedTiles.clear(); + state.lastRenderedWindow = null; +} + +export { state, invalidateCells }; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/util.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/util.js new file mode 100644 index 00000000..2b652efd --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/util.js @@ -0,0 +1,65 @@ +function runtime() { + return window.app || {}; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function asElement(node) { + if (!node) return null; + return node.nodeType === 1 ? node : node.parentElement || null; +} + +function closestElement(target, selector) { + const element = asElement(target); + return typeof element?.closest === 'function' ? element.closest(selector) : null; +} + +function uid(prefix = 'el') { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function bridgeOutput(result) { + if (result?.bridgeResult?.output !== undefined) return result.bridgeResult.output; + if (result?.output !== undefined) return result.output; + return result; +} + +function rootElement() { + return document.getElementById('excelLiveRoot'); +} + +function cellCacheKey(sheetId, row, col) { + return `${sheetId}:${row},${col}`; +} + +function normalizePathForCompare(value) { + const normalized = String(value || '').replaceAll('\\', '/'); + const platform = typeof navigator === 'undefined' + ? '' + : String(navigator.userAgentData?.platform || navigator.platform || ''); + return /^win/i.test(platform) ? normalized.toLowerCase() : normalized; +} + +export { + asElement, + bridgeOutput, + cellCacheKey, + clamp, + closestElement, + escapeHtml, + normalizePathForCompare, + rootElement, + runtime, + uid, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/views.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/views.js new file mode 100644 index 00000000..0164e637 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/views.js @@ -0,0 +1,514 @@ +import { escapeHtml, rootElement } from './util.js'; +import { t } from './i18n.js'; +import { state } from './state.js'; +import { proposalHasLayoutChanges, proposalValidationDetails } from './formatting.js'; +import { + activeSheet, + calculationStatusIsFresh, + normalizeFocusRange, + selectionValueSummary, +} from './model.js'; + +const ICONS = { + logo: '', + newWorkbook: '', + open: '', + save: '', + exportCopy: '', + export: '', + undo: '', + redo: '', + insertRow: '', + insertCol: '', + pin: '', + spark: '', + add: '', + empty: '', +}; + +function iconButton(action, icon, labelKey, extraClass = '') { + return ``; +} + +function ensureShell() { + const root = rootElement(); + if (!root) return null; + if (state.shellBuilt && root.dataset.shell === 'ready') return root; + + root.className = 'excel-live'; + root.dataset.shell = 'ready'; + root.innerHTML = ` +
+
+ + + +
+
+ ${iconButton('new-workbook', 'newWorkbook', 'newWorkbook')} + ${iconButton('open-workbook', 'open', 'openWorkbook')} + ${iconButton('save-workbook', 'exportCopy', 'save')} + ${iconButton('export-csv', 'export', 'exportCsv')} + + ${iconButton('undo', 'undo', 'undo')} + ${iconButton('redo', 'redo', 'redo')} + + ${iconButton('insert-row', 'insertRow', 'insertRow')} + ${iconButton('insert-col', 'insertCol', 'insertCol')} +
+
+
+ + + +
+ + + ${iconButton('pin-focus', 'pin', 'pinFocus')} + +
+
+ +
+ +
+ + + + +
+ + + + + +
+ +
+ + + + +
+ + + + + +
+ + + +
+
+

+
+
+
+ +
+ +
+
+ +
+
+
+
+ `; + + state.shellBuilt = true; + applyStaticTexts(root); + return root; +} + +function applyStaticTexts(root = rootElement()) { + if (!root) return; + root.querySelectorAll('[data-label-key]').forEach((node) => { + const label = t(node.dataset.labelKey); + node.setAttribute('aria-label', label); + node.setAttribute('title', label); + if (node.classList.contains('el-mode__btn')) { + node.textContent = label; + } + }); + root.querySelectorAll('[data-text-key]').forEach((node) => { + node.textContent = t(node.dataset.textKey); + }); + const formatbar = root.querySelector('[data-formatbar]'); + if (formatbar) formatbar.setAttribute('aria-label', t('formatStyles')); + const formatPresets = root.querySelector('.el-formatbar__presets'); + if (formatPresets) formatPresets.setAttribute('aria-label', t('formatStyles')); + const fillInput = root.querySelector('[data-action="format-fill"]'); + if (fillInput) fillInput.setAttribute('aria-label', t('formatFill')); + const numberSelect = root.querySelector('[data-action="format-number"]'); + if (numberSelect) numberSelect.setAttribute('aria-label', t('numberFormat')); + root.querySelectorAll('[data-mode-group] .el-mode__btn').forEach((node) => { + const mode = node.dataset.mode || 'edit'; + const hintKey = mode === 'inspect' + ? 'modeInspectHint' + : mode === 'author' + ? 'modeAuthorHint' + : 'modeEditHint'; + node.setAttribute('title', t(hintKey)); + node.setAttribute('aria-label', `${node.textContent}: ${t(hintKey)}`); + }); + const askLabel = root.querySelector('[data-ask-label]'); + if (askLabel) askLabel.textContent = t('askFocus'); + const askBtn = root.querySelector('[data-action="ask-focus"]'); + if (askBtn) askBtn.setAttribute('title', t('askFocus')); + const includeText = root.querySelector('[data-include-text]'); + if (includeText) includeText.textContent = t('includeFocus'); + const includeLabel = root.querySelector('[data-include-label]'); + if (includeLabel) includeLabel.setAttribute('title', t('includeFocusTitle')); + const namebox = root.querySelector('[data-namebox]'); + if (namebox) namebox.setAttribute('title', t('nameBoxTitle')); + const fxInput = root.querySelector('[data-formula-input]'); + if (fxInput) fxInput.setAttribute('title', t('formulaTitle')); + const emptyTitle = root.querySelector('[data-empty-title]'); + if (emptyTitle) emptyTitle.textContent = t('emptyTitle'); + const emptyHint = root.querySelector('[data-empty-hint]'); + if (emptyHint) emptyHint.textContent = t('emptyHint'); + const emptyNew = root.querySelector('[data-empty-new]'); + if (emptyNew) emptyNew.textContent = t('newWorkbook'); + const emptyOpen = root.querySelector('[data-empty-open]'); + if (emptyOpen) emptyOpen.textContent = t('openWorkbook'); + const bootText = root.querySelector('[data-boot-text]'); + if (bootText) bootText.textContent = t('boot'); + const sheetTabs = root.querySelector('[data-sheet-tabs]'); + if (sheetTabs) sheetTabs.setAttribute('aria-label', t('sheetTabs')); + const grid = root.querySelector('[data-grid-wrap]'); + if (grid) grid.setAttribute('aria-label', t('gridLabel')); +} + +function statusToken(value) { + if (typeof value === 'string') return value.trim().toLowerCase().replaceAll('_', '-'); + if (!value || typeof value !== 'object') return ''; + return String(value.status || value.state || value.kind || value.level || '') + .trim() + .toLowerCase() + .replaceAll('_', '-'); +} + +function renderTrustBanner(root = rootElement()) { + const banner = root?.querySelector('[data-trust-banner]'); + if (!banner) return; + if (!state.workbookId) { + banner.hidden = true; + banner.innerHTML = ''; + return; + } + + const messages = []; + let kind = 'warning'; + const fidelity = state.meta?.fidelity; + const fidelityToken = statusToken(fidelity); + const sourcePreserving = fidelityToken === 'source-preserving'; + const fullFidelity = [ + 'full', + 'exact', + 'preserved', + 'high', + 'native', + ].includes(fidelityToken); + if (sourcePreserving) { + messages.push(t('fidelitySourcePreservingView')); + } else if (fidelity != null && !fullFidelity) { + const explicit = fidelity && typeof fidelity === 'object' && typeof fidelity.message === 'string' + ? fidelity.message.trim() + : ''; + messages.push(explicit || t( + ['limited', 'basic', 'partial', 'degraded', 'values-formulas-only'].includes(fidelityToken) + ? 'fidelityLimited' + : 'fidelityUnknown', + )); + } + + const calculation = state.meta?.calculationStatus; + const calculationToken = statusToken(calculation); + const calculationCurrent = [ + 'current', + 'ready', + 'calculated', + 'recalculated', + 'ok', + 'not-required', + ].includes(calculationToken); + if (calculation != null && !calculationCurrent) { + const explicit = calculation && typeof calculation === 'object' && typeof calculation.message === 'string' + ? calculation.message.trim() + : ''; + let key = 'calculationUnknown'; + if (['pending', 'stale', 'cached', 'not-calculated', 'unrecalculated', 'dirty'].includes(calculationToken)) { + key = 'calculationPending'; + } + if (['failed', 'error'].includes(calculationToken)) { + key = 'calculationFailed'; + kind = 'error'; + } + messages.push(explicit || t(key)); + } + + if (messages.length === 0) { + banner.hidden = true; + banner.innerHTML = ''; + return; + } + banner.hidden = false; + banner.dataset.kind = kind; + banner.innerHTML = messages + .map((message) => `${escapeHtml(message)}`) + .join(''); +} + +function updateChrome() { + const root = rootElement(); + if (!root || !state.shellBuilt) return; + + const hasWorkbook = Boolean(state.workbookId); + const readOnly = state.mode === 'inspect'; + const boundaryPending = state.modePending; + const busy = state.loading || state.dialogPending || boundaryPending; + root.setAttribute('aria-busy', busy ? 'true' : 'false'); + + const title = root.querySelector('[data-title]'); + if (title) title.textContent = state.meta?.title || t('title'); + + const spin = root.querySelector('[data-loading-spin]'); + if (spin) spin.hidden = !busy; + const loadbar = root.querySelector('[data-loading-bar]'); + if (loadbar) loadbar.hidden = !busy; + const grid = root.querySelector('[data-grid-wrap]'); + if (grid) grid.setAttribute('aria-busy', busy ? 'true' : 'false'); + + for (const action of ['save-workbook', 'export-csv', 'pin-focus', 'ask-focus']) { + const btn = root.querySelector(`[data-action="${action}"]:not([data-empty-new]):not([data-empty-open])`); + if (btn) btn.disabled = !hasWorkbook || busy; + } + root.querySelectorAll('[data-action="new-workbook"], [data-action="open-workbook"]') + .forEach((btn) => { btn.disabled = busy; }); + for (const action of ['insert-row', 'insert-col']) { + const btn = root.querySelector(`[data-action="${action}"]`); + if (btn) btn.disabled = !hasWorkbook || busy || readOnly; + } + root.querySelectorAll('[data-format-control]').forEach((control) => { + control.disabled = !hasWorkbook || busy || readOnly || Boolean(state.proposal); + }); + const formatHint = root.querySelector('[data-format-hint]'); + if (formatHint) { + formatHint.textContent = state.proposal ? t('formatProposalExistsShort') : ''; + } + const undo = root.querySelector('[data-action="undo"]'); + if (undo) undo.disabled = !hasWorkbook || busy || readOnly || !state.history.canUndo; + const redo = root.querySelector('[data-action="redo"]'); + if (redo) redo.disabled = !hasWorkbook || busy || readOnly || !state.history.canRedo; + const addSheet = root.querySelector('[data-action="add-sheet"]'); + if (addSheet) addSheet.disabled = !hasWorkbook || busy || readOnly; + + root.querySelectorAll('[data-mode-group] .el-mode__btn').forEach((btn) => { + btn.classList.toggle('is-active', btn.dataset.mode === state.mode); + btn.setAttribute('aria-pressed', btn.dataset.mode === state.mode ? 'true' : 'false'); + btn.disabled = !hasWorkbook || busy; + }); + root.dataset.mode = state.mode; + const modeHint = root.querySelector('[data-mode-hint]'); + if (modeHint) { + modeHint.textContent = t( + state.mode === 'inspect' + ? 'modeInspectHint' + : state.mode === 'author' + ? 'modeAuthorHint' + : 'modeEditHint', + ); + } + + const fxInput = root.querySelector('[data-formula-input]'); + if (fxInput) { + fxInput.disabled = !hasWorkbook || busy; + fxInput.readOnly = readOnly || busy; + fxInput.placeholder = state.mode === 'inspect' ? t('readOnly') : ''; + } + const namebox = root.querySelector('[data-namebox]'); + if (namebox) namebox.disabled = !hasWorkbook || busy; + + const cellEditorInput = root.querySelector('[data-cell-editor] input'); + if (cellEditorInput) cellEditorInput.disabled = !hasWorkbook || busy || readOnly; + + const includeCheckbox = root.querySelector('[data-action="toggle-include-focus"]'); + if (includeCheckbox) { + includeCheckbox.checked = Boolean(state.includeFocusOnSend); + includeCheckbox.disabled = busy; + } + + root.querySelectorAll([ + '[data-action="jump-proposal"]', + '[data-action="toggle-proposal-details"]', + '[data-action="proposal-select-all"]', + '[data-action="proposal-select-none"]', + '[data-action="toggle-proposal-cell"]', + ].join(',')).forEach((control) => { + control.disabled = busy; + }); + const proposalStale = Boolean( + state.proposal?.stale === true + || ( + state.proposal?.baseRevision != null + && state.meta?.revision != null + && String(state.proposal.baseRevision) !== String(state.meta.revision) + ), + ); + const acceptProposal = root.querySelector('[data-action="accept-proposal"]'); + if (acceptProposal) { + const proposalCellCount = Array.isArray(state.proposal?.cells) ? state.proposal.cells.length : 0; + const layoutOnly = proposalCellCount === 0 && proposalHasLayoutChanges(state.proposal); + acceptProposal.disabled = busy + || readOnly + || proposalStale + || proposalValidationDetails(state.proposal).invalid + || (!layoutOnly && state.proposalSelectedCellRefs.size === 0); + } + const rejectProposal = root.querySelector('[data-action="reject-proposal"]'); + if (rejectProposal) rejectProposal.disabled = busy; + + const scroll = root.querySelector('[data-grid-scroll]'); + const empty = root.querySelector('[data-empty-state]'); + const boot = root.querySelector('[data-boot-state]'); + const booting = !state.bootDone && !hasWorkbook; + if (scroll) scroll.hidden = !hasWorkbook; + if (empty) empty.hidden = hasWorkbook || booting; + if (boot) boot.hidden = !booting; + + const statusText = root.querySelector('[data-status-text]'); + if (statusText) statusText.textContent = state.status || ''; + + renderTrustBanner(root); + updateSelectionStats(root); + renderSheetTabs(root); +} + +function updateSelectionStats(root = rootElement()) { + const node = root?.querySelector('[data-selection-stats]'); + if (!node) return; + if (!state.workbookId) { + node.textContent = ''; + return; + } + const focus = normalizeFocusRange(state.focus); + const summary = selectionValueSummary(focus); + if (!summary || summary.cellCount <= 1) { + node.textContent = ''; + return; + } + if (summary.formulaCount > 0 && !calculationStatusIsFresh()) { + node.textContent = t('statsFormulaStale'); + return; + } + if (summary.numericCount === 0) { + node.textContent = ''; + return; + } + const parts = [ + `${t('statsCount')} ${summary.numericCount}`, + `${t('statsSum')} ${formatNumber(summary.sum)}`, + ]; + if (summary.avg != null) { + parts.push(`${t('statsAvg')} ${formatNumber(summary.avg)}`); + } + node.textContent = parts.join(' · '); +} + +function formatNumber(value) { + if (!Number.isFinite(value)) return String(value); + const rounded = Math.round(value * 10000) / 10000; + return String(rounded); +} + +function renderSheetTabs(root = rootElement()) { + const host = root?.querySelector('[data-sheet-tabs]'); + if (!host) return; + const busy = state.loading || state.dialogPending || state.modePending; + const key = `${(state.sheets || []).map((s) => `${s.id}:${s.name}`).join('|')}#${state.activeSheetId}#${busy ? 'busy' : 'ready'}`; + if (key === state.lastSheetTabsKey) return; + state.lastSheetTabsKey = key; + host.innerHTML = (state.sheets || []) + .map((sheet) => { + const active = sheet.id === state.activeSheetId; + return ``; + }) + .join(''); +} + +function showToast(kind, message) { + const toast = rootElement()?.querySelector('[data-toast]'); + if (!toast) return; + toast.hidden = false; + toast.textContent = message; + toast.dataset.kind = kind; + toast.setAttribute('aria-live', kind === 'error' ? 'assertive' : 'polite'); + if (state.toastTimer) clearTimeout(state.toastTimer); + state.toastTimer = setTimeout(() => { + state.toastTimer = null; + hideToast(); + }, kind === 'error' ? 8000 : 4000); +} + +function hideToast() { + const toast = rootElement()?.querySelector('[data-toast]'); + if (toast) toast.hidden = true; + if (state.toastTimer) { + clearTimeout(state.toastTimer); + state.toastTimer = null; + } +} + +export { + applyStaticTexts, + ensureShell, + hideToast, + renderSheetTabs, + showToast, + updateChrome, + updateSelectionStats, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/style.css b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/style.css new file mode 100644 index 00000000..ae846903 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/style.css @@ -0,0 +1,1581 @@ +/* Excel Live — dense spreadsheet workbench. + * Theme bridge: --sparo-app-* variables from the host, with dark fallbacks + * keyed off html[data-theme-type] (set by the runtime bridge script). + * Selection and proposal highlights stay visually distinct. + */ + +:root { + color-scheme: light dark; + + --el-bg: var(--sparo-app-bg, var(--sparo-bg-scene, #f4f5f7)); + --el-panel: var(--sparo-app-surface, var(--sparo-bg-secondary, #ffffff)); + --el-soft: var(--sparo-app-card, var(--sparo-element-subtle, #eef0f3)); + --el-hover: var(--sparo-app-card-hover, var(--sparo-element-soft, #e5e8ed)); + --el-ctrl: var(--sparo-app-control-bg, var(--sparo-element-bg, #f7f8fa)); + --el-text: var(--sparo-app-text, var(--sparo-text, #1a1d23)); + --el-muted: var(--sparo-app-text-secondary, var(--sparo-text-secondary, #5c6570)); + --el-subtle: var(--sparo-app-text-muted, var(--sparo-text-muted, #8a93a0)); + --el-border: var(--sparo-app-border-subtle, var(--sparo-border-subtle, #dde1e8)); + --el-border-md: var(--sparo-app-border, var(--sparo-border, #c9cfd9)); + --el-accent: var(--sparo-app-accent, var(--sparo-accent, #2f6fed)); + --el-accent-text: var(--sparo-app-accent-text, #ffffff); + --el-ok: var(--sparo-success, #1f8a4c); + --el-warn: var(--sparo-warning, #b78100); + --el-danger: var(--sparo-error, #c62828); + --el-danger-bg: var(--sparo-error-bg, #fdecea); + + --el-grid-bg: var(--el-panel); + --el-grid-line: color-mix(in srgb, var(--el-border) 72%, transparent); + --el-header-bg: color-mix(in srgb, var(--el-soft) 82%, var(--el-panel)); + --el-header-text: var(--el-muted); + --el-selection-fill: color-mix(in srgb, var(--el-accent) 10%, transparent); + --el-selection-border: color-mix(in srgb, var(--el-accent) 70%, transparent); + --el-proposal-fill: color-mix(in srgb, var(--el-ok) 12%, transparent); + --el-proposal-border: color-mix(in srgb, var(--el-ok) 45%, var(--el-border)); + --el-proposal-bar: color-mix(in srgb, var(--el-ok) 8%, var(--el-panel)); + + --el-radius: var(--sparo-app-radius-sm, var(--sparo-radius-sm, 4px)); + --el-radius-lg: var(--sparo-app-radius, var(--sparo-radius, 6px)); + --el-shadow: var(--sparo-app-shadow, 0 4px 16px rgba(0, 0, 0, 0.14)); + --el-font: var(--sparo-font-sans, "Segoe UI", system-ui, sans-serif); + --el-mono: var(--sparo-font-mono, "Cascadia Mono", ui-monospace, monospace); + --el-scrollbar-thumb: var(--sparo-scrollbar-thumb, color-mix(in srgb, var(--el-text) 16%, transparent)); + --el-scrollbar-thumb-hover: var(--sparo-scrollbar-thumb-hover, color-mix(in srgb, var(--el-text) 28%, transparent)); +} + +html[data-theme-type="dark"] { + --el-bg: var(--sparo-app-bg, #14171c); + --el-panel: var(--sparo-app-surface, #1b1f26); + --el-soft: var(--sparo-app-card, #232830); + --el-hover: var(--sparo-app-card-hover, #2b313b); + --el-ctrl: var(--sparo-app-control-bg, #20252d); + --el-text: var(--sparo-app-text, #edf0f4); + --el-muted: var(--sparo-app-text-secondary, #a6afbc); + --el-subtle: var(--sparo-app-text-muted, #7e8794); + --el-border: var(--sparo-app-border-subtle, #333a45); + --el-border-md: var(--sparo-app-border, #414957); + --el-danger-bg: var(--sparo-error-bg, #3a2224); +} + +@media (prefers-color-scheme: dark) { + html:not([data-theme-type]) { + --el-bg: var(--sparo-app-bg, #14171c); + --el-panel: var(--sparo-app-surface, #1b1f26); + --el-soft: var(--sparo-app-card, #232830); + --el-hover: var(--sparo-app-card-hover, #2b313b); + --el-ctrl: var(--sparo-app-control-bg, #20252d); + --el-text: var(--sparo-app-text, #edf0f4); + --el-muted: var(--sparo-app-text-secondary, #a6afbc); + --el-subtle: var(--sparo-app-text-muted, #7e8794); + --el-border: var(--sparo-app-border-subtle, #333a45); + --el-border-md: var(--sparo-app-border, #414957); + --el-danger-bg: var(--sparo-error-bg, #3a2224); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + overflow: hidden; + background: var(--el-bg); + color: var(--el-text); + font-family: var(--el-font); + font-size: 12.5px; + line-height: 1.35; +} + +button, +input { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; +} + +/* Flex column instead of grid rows: hidden sections (e.g. the proposal bar) + * drop out of flow without shifting the remaining rows around. */ +.excel-live { + height: 100vh; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--el-bg); +} + +.excel-live > .el-toolbar, +.excel-live > .el-formatbar, +.excel-live > .el-formulabar, +.excel-live > .el-trust, +.excel-live > .el-proposal, +.excel-live > .el-bottombar { + flex: none; +} + +/* ------------------------------------------------------------- loaders */ + +.el-loader, +.excel-live__loader { + width: 20px; + height: 20px; + border: 2px solid var(--el-border); + border-top-color: var(--el-accent); + border-radius: 50%; + animation: el-spin 0.8s linear infinite; +} + +@keyframes el-spin { + to { + transform: rotate(360deg); + } +} + +.excel-live__boot { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + height: 100vh; + color: var(--el-muted); +} + +/* ------------------------------------------------------------- toolbar */ + +.el-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border-bottom: 1px solid var(--el-border); + background: var(--el-panel); + min-height: 40px; + overflow: hidden; +} + +.el-toolbar__brand { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + margin-right: 6px; + flex-shrink: 1; + overflow: hidden; +} + +.el-logo { + display: inline-flex; + width: 18px; + height: 18px; + color: var(--el-accent); + flex: none; +} + +.el-logo svg { + width: 100%; + height: 100%; +} + +.el-title { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} + +.el-toolbar__actions, +.el-toolbar__right { + display: flex; + align-items: center; + gap: 3px; + flex: none; +} + +.el-toolbar__right { + margin-left: auto; +} + +.el-sep { + width: 1px; + height: 16px; + background: var(--el-border); + margin: 0 4px; + flex: none; +} + +.el-spin { + width: 12px; + height: 12px; + border: 2px solid var(--el-border); + border-top-color: var(--el-accent); + border-radius: 50%; + animation: el-spin 0.8s linear infinite; + flex: none; +} + +.el-icon-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 27px; + height: 27px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--el-radius); + background: transparent; + color: var(--el-muted); +} + +.el-icon-btn svg { + width: 15px; + height: 15px; +} + +.el-icon-btn:hover:not(:disabled) { + background: var(--el-hover); + color: var(--el-text); +} + +.el-icon-btn:focus-visible { + outline: 2px solid color-mix(in srgb, var(--el-accent) 45%, transparent); + outline-offset: 1px; +} + +.el-icon-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.el-btn { + appearance: none; + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid var(--el-border-md); + background: var(--el-ctrl); + color: var(--el-text); + border-radius: var(--el-radius); + padding: 4px 10px; + font-size: 12px; + line-height: 1.2; + white-space: nowrap; +} + +.el-btn:hover:not(:disabled) { + background: var(--el-hover); +} + +.el-btn:focus-visible { + outline: 2px solid color-mix(in srgb, var(--el-accent) 45%, transparent); + outline-offset: 1px; +} + +.el-btn:disabled { + opacity: 0.45; + cursor: default; +} + +.el-btn--accent { + background: var(--el-accent); + border-color: var(--el-accent); + color: var(--el-accent-text); +} + +.el-btn--accent:hover:not(:disabled) { + filter: brightness(1.06); + background: var(--el-accent); +} + +.el-btn--accept { + background: var(--el-ok); + border-color: var(--el-ok); + color: #fff; +} + +.el-btn--accept:hover:not(:disabled) { + filter: brightness(1.06); + background: var(--el-ok); +} + +.el-btn--ghost { + background: transparent; +} + +.el-ask svg { + width: 14px; + height: 14px; +} + +.el-mode { + display: inline-flex; + align-items: center; + padding: 2px; + gap: 1px; + border: 1px solid var(--el-border); + border-radius: var(--el-radius-lg); + background: var(--el-soft); +} + +.el-mode__btn { + appearance: none; + border: none; + background: transparent; + color: var(--el-muted); + border-radius: calc(var(--el-radius-lg) - 2px); + padding: 2.5px 9px; + font-size: 11.5px; + white-space: nowrap; +} + +.el-mode__btn:hover { + color: var(--el-text); +} + +.el-mode__btn.is-active { + background: var(--el-panel); + color: var(--el-text); + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.el-formatbar { + display: flex; + align-items: center; + gap: 5px; + min-height: 32px; + padding: 3px 8px; + overflow-x: auto; + overflow-y: hidden; + border-bottom: 1px solid var(--el-border); + background: color-mix(in srgb, var(--el-panel) 94%, var(--el-soft)); + scrollbar-width: none; +} + +.el-formatbar::-webkit-scrollbar { + display: none; +} + +.el-formatbar__label { + color: var(--el-muted); + font-size: 10.5px; + font-weight: 600; + white-space: nowrap; +} + +.el-formatbar__presets { + display: flex; + gap: 3px; +} + +.el-format-preset, +.el-format-btn { + appearance: none; + min-height: 23px; + border: 1px solid var(--el-border); + border-radius: var(--el-radius); + padding: 2px 8px; + background: var(--el-ctrl); + color: var(--el-text); + font-size: 10.5px; + white-space: nowrap; +} + +.el-format-preset:hover:not(:disabled), +.el-format-btn:hover:not(:disabled) { + border-color: var(--el-border-md); + filter: brightness(1.02); +} + +.el-format-preset.is-header { + border-color: #17365d; + background: #1f4e78; + color: #fff; + font-weight: 700; +} + +.el-format-preset.is-total { + border-top: 3px double #1f1f1f; + font-weight: 700; +} + +.el-format-preset.is-input { + border-color: #d6b656; + background: #fff2cc; + color: #4a3b0a; +} + +.el-format-preset.is-output { + border-color: #9dc3e6; + background: #ddebf7; + color: #17365d; +} + +.el-format-btn--bold { + width: 26px; + padding-inline: 0; + font-weight: 800; +} + +.el-format-color { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 23px; + border: 1px solid var(--el-border); + border-radius: var(--el-radius); + background: var(--el-ctrl); + cursor: pointer; +} + +.el-format-color > span { + width: 14px; + height: 10px; + border-bottom: 3px solid #fff2cc; + background: linear-gradient(135deg, transparent 45%, currentColor 46% 54%, transparent 55%); +} + +.el-format-color input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.el-format-number select { + min-height: 23px; + max-width: 116px; + border: 1px solid var(--el-border); + border-radius: var(--el-radius); + padding: 1px 24px 1px 7px; + background: var(--el-ctrl); + color: var(--el-text); + font-size: 10.5px; +} + +.el-formatbar [data-format-control]:disabled { + opacity: 0.42; + cursor: default; +} + +.el-formatbar__hint { + margin-left: auto; + color: var(--el-muted); + font-size: 10px; + white-space: nowrap; +} + +.el-mode__hint { + max-width: 190px; + color: var(--el-muted); + font-size: 10.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --------------------------------------------------------- formula bar */ + +.el-formulabar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-bottom: 1px solid var(--el-border); + background: var(--el-panel); + min-height: 32px; +} + +.el-namebox { + width: 96px; + flex: none; + text-align: center; + border: 1px solid var(--el-border); + border-radius: var(--el-radius); + background: var(--el-ctrl); + padding: 3px 6px; + font-family: var(--el-mono); + font-size: 11.5px; +} + +.el-fx-label { + color: var(--el-subtle); + font-family: var(--el-mono); + font-size: 11px; + font-style: italic; + font-weight: 700; + flex: none; + user-select: none; +} + +.el-fx-input { + flex: 1; + min-width: 60px; + border: 1px solid var(--el-border); + border-radius: var(--el-radius); + background: var(--el-ctrl); + padding: 3.5px 8px; + font-family: var(--el-mono); + font-size: 12px; +} + +.el-namebox:focus, +.el-fx-input:focus { + outline: 2px solid color-mix(in srgb, var(--el-accent) 35%, transparent); + outline-offset: 0; + border-color: var(--el-accent); +} + +.el-namebox:disabled, +.el-fx-input:disabled { + opacity: 0.5; +} + +.el-include { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--el-muted); + font-size: 11px; + white-space: nowrap; + flex: none; + cursor: pointer; + user-select: none; +} + +.el-include input { + accent-color: var(--el-accent); + margin: 0; +} + +/* ----------------------------------------------------- fidelity / recalc */ + +.el-trust { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--el-warn) 42%, var(--el-border)); + background: color-mix(in srgb, var(--el-warn) 10%, var(--el-panel)); + color: color-mix(in srgb, var(--el-warn) 78%, var(--el-text)); + font-size: 11px; +} + +.el-trust[data-kind="error"] { + border-bottom-color: color-mix(in srgb, var(--el-danger) 45%, var(--el-border)); + background: var(--el-danger-bg); + color: var(--el-danger); +} + +.el-trust[hidden] { + display: none !important; +} + +.el-trust__sep { + color: var(--el-subtle); +} + +/* ---------------------------------------------------------- proposal bar */ + +.el-proposal { + display: block; + padding: 5px 10px; + border-bottom: 1px solid var(--el-proposal-border); + background: var(--el-proposal-bar); +} + +.el-proposal__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.el-proposal[hidden] { + display: none !important; +} + +.el-proposal__main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.el-proposal__badge { + font-size: 10.5px; + font-weight: 700; + color: var(--el-ok); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.el-proposal__range { + appearance: none; + border: 1px solid var(--el-proposal-border); + border-radius: var(--el-radius); + background: color-mix(in srgb, var(--el-ok) 8%, transparent); + color: var(--el-text); + font-family: var(--el-mono); + font-size: 11.5px; + padding: 1.5px 7px; +} + +.el-proposal__range:hover { + background: color-mix(in srgb, var(--el-ok) 16%, transparent); +} + +.el-proposal__meta { + color: var(--el-muted); + font-size: 11px; + white-space: nowrap; +} + +.el-proposal__intent { + min-width: 80px; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} + +.el-proposal__validation, +.el-proposal__stale { + border: 1px solid var(--el-border); + border-radius: 999px; + padding: 1px 6px; + font-size: 10.5px; + white-space: nowrap; +} + +.el-proposal__validation.is-ok { + border-color: color-mix(in srgb, var(--el-ok) 42%, var(--el-border)); + color: var(--el-ok); +} + +.el-proposal__validation.is-warning { + border-color: color-mix(in srgb, var(--el-warn) 42%, var(--el-border)); + color: var(--el-warn); +} + +.el-proposal__validation.is-error, +.el-proposal__stale { + border-color: color-mix(in srgb, var(--el-danger) 45%, var(--el-border)); + color: var(--el-danger); +} + +.el-proposal[data-stale="true"] { + border-bottom-color: color-mix(in srgb, var(--el-danger) 45%, var(--el-border)); + background: color-mix(in srgb, var(--el-danger-bg) 55%, var(--el-panel)); +} + +.el-proposal__stale-hint { + margin: 5px 0 0; + color: var(--el-danger); + font-size: 11px; +} + +.el-proposal__invalid-hint { + margin: 5px 0 0; + color: var(--el-danger); + font-size: 11px; + font-weight: 600; +} + +.el-proposal__layout-hint { + margin: 5px 0 0; + color: var(--el-muted); + font-size: 10.5px; +} + +.el-proposal-validation-detail { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 4px 12px; + margin-top: 6px; + padding: 6px 8px; + border: 1px solid color-mix(in srgb, var(--el-warn) 30%, var(--el-border)); + border-radius: var(--el-radius); + background: color-mix(in srgb, var(--el-warn) 5%, transparent); + font-size: 11px; +} + +.el-proposal-validation-detail[role="alert"] { + border-color: color-mix(in srgb, var(--el-danger) 35%, var(--el-border)); + background: color-mix(in srgb, var(--el-danger-bg) 45%, transparent); +} + +.el-proposal-validation-detail ul { + margin: 0; + padding-left: 17px; +} + +.el-proposal-validation-detail li + li { + margin-top: 2px; +} + +.el-proposal-validation-detail li.is-error { + color: var(--el-danger); +} + +.el-proposal-validation-detail li.is-warning { + color: color-mix(in srgb, var(--el-warn) 88%, var(--el-text)); +} + +.el-proposal__actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.el-proposal-detail { + margin-top: 7px; + border-top: 1px solid var(--el-proposal-border); + padding-top: 6px; +} + +.el-proposal-detail__toolbar { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + color: var(--el-muted); + font-size: 11px; +} + +.el-link-btn { + appearance: none; + border: none; + background: transparent; + color: var(--el-accent); + padding: 2px 3px; + font-size: 11px; + cursor: pointer; +} + +.el-link-btn:focus-visible { + outline: 2px solid color-mix(in srgb, var(--el-accent) 45%, transparent); + outline-offset: 1px; +} + +.el-link-btn:disabled, +.el-proposal__range:disabled, +.el-proposal-detail input:disabled { + opacity: 0.45; + cursor: default; +} + +.el-proposal-detail__table { + max-height: min(230px, 32vh); + overflow: auto; + border: 1px solid var(--el-border); + border-radius: var(--el-radius); + background: var(--el-panel); +} + +.el-proposal-detail__head, +.el-proposal-detail__row { + display: grid; + grid-template-columns: 30px minmax(72px, 0.65fr) minmax(120px, 1fr) minmax(120px, 1fr); + align-items: center; + min-height: 32px; +} + +.el-proposal-detail__head { + position: sticky; + top: 0; + z-index: 1; + background: var(--el-soft); + color: var(--el-muted); + font-size: 10.5px; + font-weight: 700; +} + +.el-proposal-detail__row { + border-top: 1px solid var(--el-grid-line); + cursor: pointer; +} + +.el-proposal-detail__row:hover { + background: var(--el-hover); +} + +.el-proposal-detail__head > span, +.el-proposal-detail__row > span { + min-width: 0; + padding: 4px 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.el-proposal-detail__check { + display: flex; + justify-content: center; +} + +.el-proposal-detail__ref { + font-family: var(--el-mono); +} + +.el-proposal-detail__before { + color: var(--el-subtle); +} + +.el-proposal-detail__after { + color: var(--el-ok); + font-weight: 600; +} + +.el-proposal-snapshot { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; +} + +.el-proposal-snapshot__primary { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: inherit; +} + +.el-proposal-snapshot--before .el-proposal-snapshot__primary { + text-decoration: line-through; +} + +.el-proposal-snapshot__primary.is-formula { + color: color-mix(in srgb, var(--el-accent) 78%, var(--el-text)); + font-family: var(--el-mono); + text-decoration: none; +} + +.el-proposal-snapshot__cached { + max-width: 100%; + overflow: hidden; + color: var(--el-subtle); + font-size: 9.5px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.el-proposal-style-diff { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin-top: 3px; +} + +.el-proposal-style-diff__item { + max-width: 100%; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--el-accent) 22%, var(--el-border)); + border-radius: 999px; + padding: 1px 5px; + background: color-mix(in srgb, var(--el-accent) 5%, transparent); + color: var(--el-muted); + font-size: 9px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.el-proposal-layout-diff { + margin-top: 7px; + border: 1px solid var(--el-border); + border-radius: var(--el-radius); + background: var(--el-panel); +} + +.el-proposal-layout-diff h3 { + margin: 0; + padding: 5px 8px; + border-bottom: 1px solid var(--el-border); + background: var(--el-soft); + color: var(--el-muted); + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.el-proposal-layout-diff__row { + display: grid; + grid-template-columns: minmax(86px, 0.55fr) minmax(120px, 1fr) auto minmax(120px, 1fr); + gap: 8px; + padding: 5px 8px; + border-top: 1px solid var(--el-grid-line); + font-size: 10.5px; +} + +.el-proposal-layout-diff__row:first-of-type { + border-top: 0; +} + +.el-proposal-layout-diff__row > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.el-proposal-detail__limit { + margin: 5px 0 0; + color: var(--el-muted); + font-size: 10.5px; +} + +/* ----------------------------------------------------------------- grid */ + +.el-grid-wrap { + flex: 1 1 0; + min-height: 0; + min-width: 0; + position: relative; + background: var(--el-grid-bg); + outline: none; +} + +.el-grid-wrap:focus-visible { + outline: 2px solid color-mix(in srgb, var(--el-accent) 58%, transparent); + outline-offset: -2px; +} + +.el-sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.el-loadbar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + z-index: 12; + overflow: hidden; + background: transparent; + pointer-events: none; +} + +.el-loadbar::after { + content: ""; + position: absolute; + inset: 0; + width: 40%; + background: var(--el-accent); + animation: el-loadbar 1.1s ease-in-out infinite; +} + +@keyframes el-loadbar { + 0% { + transform: translateX(-110%); + } + + 100% { + transform: translateX(280%); + } +} + +.el-grid__scroll { + position: absolute; + inset: 0; + overflow: auto; + background: var(--el-grid-bg); + scrollbar-width: thin; + scrollbar-color: var(--el-scrollbar-thumb) transparent; +} + +.el-grid__scroll::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.el-grid__scroll::-webkit-scrollbar-thumb { + background: var(--el-scrollbar-thumb); + border-radius: 5px; + border: 2px solid transparent; + background-clip: content-box; +} + +.el-grid__scroll::-webkit-scrollbar-thumb:hover { + background: var(--el-scrollbar-thumb-hover); + background-clip: content-box; +} + +.el-grid__scroll::-webkit-scrollbar-corner { + background: transparent; +} + +.el-grid__scroll[hidden] { + display: none !important; +} + +.el-grid__pin { + position: sticky; + top: 0; + z-index: 8; + display: flex; + width: max-content; + min-width: 100%; +} + +.el-grid__corner { + position: sticky; + left: 0; + z-index: 9; + flex: none; + background: var(--el-header-bg); + border-right: 1px solid var(--el-border-md); + border-bottom: 1px solid var(--el-border-md); +} + +.el-grid__col-headers { + position: relative; + flex: none; + background: var(--el-header-bg); + border-bottom: 1px solid var(--el-border-md); +} + +.el-grid__col-h { + position: absolute; + top: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--el-header-text); + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.02em; + border-right: 1px solid var(--el-grid-line); + user-select: none; +} + +.el-grid__under { + display: flex; + width: max-content; + min-width: 100%; + align-items: flex-start; +} + +.el-grid__row-headers { + position: sticky; + left: 0; + z-index: 7; + flex: none; + background: var(--el-header-bg); + border-right: 1px solid var(--el-border-md); +} + +.el-grid__col-h.is-frozen, +.el-grid__row-header-row.is-frozen { + z-index: 10; + background: var(--el-header-bg); +} + +.el-grid__col-h.has-filter { + padding-right: 18px; +} + +.el-grid__filter { + position: absolute; + right: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; + border: 1px solid var(--el-border-md); + border-radius: 3px; + background: var(--el-panel); + color: var(--el-muted); + font-size: 10px; +} + +.el-grid__row-header-row { + position: absolute; + left: 0; +} + +.el-grid__row-h { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--el-header-text); + font-size: 10.5px; + border-bottom: 1px solid var(--el-grid-line); + user-select: none; + font-variant-numeric: tabular-nums; +} + +.el-grid__body { + position: relative; + flex: none; + /* Grid lines are painted as a background so empty (unrendered) cells still + * show them. Stops must match COL_W=100 and ROW_H=26 in src/grid.js. */ + background-image: + repeating-linear-gradient(to right, transparent 0 99px, var(--el-grid-line) 99px 100px), + repeating-linear-gradient(to bottom, transparent 0 25px, var(--el-grid-line) 25px 26px); +} + +.el-grid__cells { + position: absolute; + inset: 0; +} + +.el-grid__aria-row { + position: absolute; + left: 0; +} + +.el-grid__aria-row.is-frozen { + z-index: 4; + background: var(--el-grid-bg); +} + +.el-grid__cell { + position: absolute; + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px; + overflow: hidden; + white-space: nowrap; + cursor: cell; + z-index: 1; +} + +.el-grid__cell.is-num { + justify-content: flex-end; + font-variant-numeric: tabular-nums; +} + +.el-grid__cell.is-formula .el-grid__value { + color: color-mix(in srgb, var(--el-accent) 80%, var(--el-text)); +} + +.el-grid__cell.is-proposal { + background: var(--el-proposal-fill); + box-shadow: inset 0 0 0 1px var(--el-proposal-border); +} + +.el-grid__value { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.el-grid__old { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + color: var(--el-subtle); + text-decoration: line-through; + font-size: 11px; +} + +.el-grid__new { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + color: var(--el-ok); + font-weight: 600; +} + +.el-sel { + position: absolute; + z-index: 2; + background: var(--el-selection-fill); + border: 1px solid var(--el-selection-border); + pointer-events: none; +} + +.el-sel-active { + position: absolute; + z-index: 3; + border: 2px solid var(--el-accent); + pointer-events: none; +} + +.el-grid__editor { + position: absolute; + z-index: 6; + padding: 0; +} + +.el-grid__editor input { + width: 100%; + height: 100%; + border: 2px solid var(--el-accent); + background: var(--el-panel); + color: var(--el-text); + padding: 0 5px; + font-family: var(--el-mono); + font-size: 12px; + outline: none; +} + +.excel-live[data-mode="inspect"] .el-grid__cell { + cursor: default; +} + +/* ---------------------------------------------------------------- empty */ + +.el-empty { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--el-muted); + padding: 24px; + text-align: center; +} + +.el-empty[hidden] { + display: none !important; +} + +.el-empty__icon { + width: 46px; + height: 46px; + color: var(--el-subtle); + opacity: 0.8; + margin-bottom: 4px; +} + +.el-empty__icon svg { + width: 100%; + height: 100%; +} + +.el-empty h2 { + margin: 0; + color: var(--el-text); + font-size: 15px; + font-weight: 600; +} + +.el-empty p { + margin: 0; + max-width: 340px; + font-size: 12px; +} + +.el-empty__actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.el-boot { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--el-muted); + background: var(--el-grid-bg); +} + +.el-boot[hidden] { + display: none !important; +} + +/* ---------------------------------------------------------------- toast */ + +.el-toast { + position: absolute; + left: 50%; + bottom: 14px; + transform: translateX(-50%); + z-index: 20; + max-width: min(92%, 480px); + border: 1px solid var(--el-border-md); + border-radius: var(--el-radius-lg); + background: var(--el-panel); + color: var(--el-text); + padding: 7px 12px; + font-size: 12px; + text-align: left; + box-shadow: var(--el-shadow); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.el-toast[data-kind="error"] { + border-color: color-mix(in srgb, var(--el-danger) 45%, var(--el-border-md)); + background: var(--el-danger-bg); + color: var(--el-danger); +} + +.el-toast[hidden] { + display: none !important; +} + +/* ------------------------------------------------------------ bottom bar */ + +.el-bottombar { + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px; + border-top: 1px solid var(--el-border); + background: var(--el-panel); + min-height: 28px; + overflow: hidden; +} + +.el-sheets { + display: flex; + align-items: flex-end; + gap: 1px; + overflow-x: auto; + scrollbar-width: none; + align-self: stretch; +} + +.el-sheets::-webkit-scrollbar { + display: none; +} + +.el-sheet-tab { + appearance: none; + border: none; + border-top: 2px solid transparent; + background: transparent; + color: var(--el-muted); + padding: 4px 12px 5px; + font-size: 11.5px; + white-space: nowrap; + align-self: stretch; +} + +.el-sheet-tab:hover { + background: var(--el-hover); + color: var(--el-text); +} + +.el-sheet-tab.is-active { + border-top-color: var(--el-accent); + background: var(--el-soft); + color: var(--el-text); + font-weight: 600; +} + +.el-add-sheet { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: var(--el-radius); + background: transparent; + color: var(--el-muted); + flex: none; +} + +.el-add-sheet svg { + width: 12px; + height: 12px; +} + +.el-add-sheet:hover { + background: var(--el-hover); + color: var(--el-text); +} + +.el-grid__body.has-custom-layout { + background-image: none; +} + +.el-grid__body.has-custom-layout .el-grid__cell { + border-right: 1px solid var(--el-grid-line); + border-bottom: 1px solid var(--el-grid-line); +} + +.el-grid__cell.is-frozen-row, +.el-grid__cell.is-frozen-column { + z-index: 4; + background-color: var(--el-grid-bg); +} + +.el-grid__cell.is-frozen-row.is-frozen-column { + z-index: 5; +} + +.el-grid__cell.is-freeze-row-edge { + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--el-border-md) 75%, transparent); +} + +.el-grid__cell.is-freeze-column-edge { + border-right: 2px solid color-mix(in srgb, var(--el-border-md) 75%, transparent); +} + +.el-grid__cell.is-proposal.is-freeze-row-edge { + box-shadow: + inset 0 0 0 1px var(--el-proposal-border), + inset 0 -2px 0 color-mix(in srgb, var(--el-border-md) 75%, transparent); +} + +.el-grid__cell.is-proposal.has-style-change::after { + content: ""; + position: absolute; + top: 2px; + right: 2px; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--el-accent); + box-shadow: 0 0 0 1px var(--el-panel); +} + +.el-add-sheet:focus-visible { + outline: 2px solid color-mix(in srgb, var(--el-accent) 45%, transparent); + outline-offset: 1px; +} + +.el-add-sheet:disabled { + opacity: 0.4; + cursor: default; +} + +.el-bottombar__spacer { + flex: 1; +} + +.el-stats { + color: var(--el-muted); + font-size: 11px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + flex: none; +} + +.el-statustext { + color: var(--el-subtle); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + flex: none; + margin-left: 8px; +} + +/* --------------------------------------------------------- narrow panel */ + +@media (max-width: 1080px) { + .el-title, + .el-mode__hint { + display: none; + } +} + +@media (max-width: 640px) { + .el-toolbar { + flex-wrap: wrap; + overflow: visible; + } + + .el-toolbar__right { + width: 100%; + margin-left: 0; + justify-content: flex-end; + } + + .el-title { + max-width: 110px; + } + + .el-mode { + display: inline-flex; + } + + .el-mode__hint { + display: none; + } + + .el-trust, + .el-proposal__summary { + align-items: flex-start; + flex-wrap: wrap; + } + + .el-proposal__actions { + width: 100%; + justify-content: flex-end; + } + + .el-include span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .el-statustext { + display: none; + } +} + +@media (max-height: 520px) { + .el-trust { + max-height: 40px; + overflow: auto; + align-items: flex-start; + } + + .el-proposal { + max-height: 36vh; + overflow: auto; + overscroll-behavior: contain; + } + + .el-proposal-detail__table { + max-height: 18vh; + } +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/test/interaction.test.mjs b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/test/interaction.test.mjs new file mode 100644 index 00000000..7471dbb0 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/test/interaction.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + agentRefreshTargetsCurrentWorkbook, + buildSelectionStyleCells, + createEditingDraft, + editingExpectedRevision, + exportCopyPlan, + gridSelectionNeedsEditCommit, + isComposingKey, + isSaveShortcut, + proposalAcceptPlan, +} from '../src/interaction.js'; +import { + LIVE_VIEW_SEMANTICS, + axisIndexAtOffset, + axisOffset, + excelColumnWidthToPixels, + excelRowHeightToPixels, + formatValueWithNumberFormat, + layoutDifferences, + normalizeCellStyle, + normalizeSheetLayout, + proposalHasLayoutChanges, + proposalValidationDetails, + snapshotParts, + styleDifferences, + styleToCss, +} from '../src/formatting.js'; + +test('focus fidelity declares the rendered values, formulas, styles, and layout semantics', () => { + assert.equal(LIVE_VIEW_SEMANTICS, 'values-formulas-styles-layout'); +}); + +test('an existing surface ignores agent refreshes for another workbook', () => { + assert.equal(agentRefreshTargetsCurrentWorkbook('workbook-a', 'workbook-b'), false); + assert.equal(agentRefreshTargetsCurrentWorkbook('workbook-a', 'workbook-a'), true); + assert.equal(agentRefreshTargetsCurrentWorkbook(null, 'workbook-b'), true); +}); + +test('an editing draft keeps the revision from when editing started', () => { + const draft = createEditingDraft({ + row: 2, + col: 4, + value: '=SUM(A1:A2)', + workbookId: 'workbook-a', + sheetId: 'sheet-a', + revision: 7, + origin: 'formula', + }); + + assert.equal(draft.baseRevision, 7); + assert.equal(draft.origin, 'formula'); + assert.equal(editingExpectedRevision(draft, 11), 7); +}); + +test('IME Enter is not treated as an edit command and save works inside inputs', () => { + assert.equal(isComposingKey({ key: 'Enter', isComposing: true }), true); + assert.equal(isComposingKey({ key: 'Enter', keyCode: 229 }), true); + assert.equal(isComposingKey({ key: 'Enter', isComposing: false }), false); + assert.equal(isSaveShortcut({ key: 's', ctrlKey: true }), true); + assert.equal(isSaveShortcut({ key: 'S', metaKey: true }), true); + assert.equal(isSaveShortcut({ key: 's' }), false); +}); + +test('grid selection waits for an edit commit only when leaving the edited cell', () => { + const editing = createEditingDraft({ + row: 1, + col: 2, + value: 'draft', + workbookId: 'workbook-a', + sheetId: 'sheet-a', + revision: 3, + }); + + assert.equal(gridSelectionNeedsEditCommit(null, { row: 2, col: 2, sheetId: 'sheet-a' }), false); + assert.equal(gridSelectionNeedsEditCommit(editing, { row: 1, col: 2, sheetId: 'sheet-a' }), false); + assert.equal(gridSelectionNeedsEditCommit(editing, { row: 2, col: 2, sheetId: 'sheet-a' }), true); + assert.equal(gridSelectionNeedsEditCommit(editing, { row: 1, col: 2, sheetId: 'sheet-b' }), true); +}); + +test('cell styles normalize semantic roles and Excel alignment/font units for CSS', () => { + const title = normalizeCellStyle({ role: 'title', alignment: { vertical: 'center' } }); + const warning = normalizeCellStyle({ role: 'warning' }); + + assert.equal(title.role, 'title'); + assert.equal(title.fill.color, '#17365D'); + assert.equal(title.font.size, 14); + assert.equal(title.alignment.vertical, 'middle'); + assert.equal(warning.font.color, '#C00000'); + assert.match(styleToCss({ role: 'title' }), /font-size:18\.67px/); +}); + +test('proposal snapshots keep formulas visible beside cached values and expose style changes', () => { + const before = { f: 'SUM(A1:A3)', v: 42, style: { fill: { color: '#FFFFFF' } } }; + const after = { f: 'SUM(A1:A4)', v: 42, style: { fill: { color: '#DDEBF7' }, font: { bold: true } } }; + const parts = snapshotParts(after); + + assert.equal(parts.primary, '=SUM(A1:A4)'); + assert.equal(parts.secondary, '42'); + assert.deepEqual(styleDifferences(before, after).map((item) => item.key), ['fill', 'font']); +}); + +test('validation errors including formula lint fail closed', () => { + const details = proposalValidationDetails({ + validation: { + status: 'warning', + warnings: ['Overwrites one cell'], + formulaLint: { + errors: [{ code: 'FORMULA_PARSE', message: 'Formula syntax is invalid' }], + }, + }, + }); + + assert.equal(details.invalid, true); + assert.deepEqual(details.errors, ['Formula syntax is invalid']); + assert.deepEqual(details.warnings, ['Overwrites one cell']); +}); + +test('layout metadata converts Excel units to pixels and supports layout-only proposals', () => { + assert.equal(excelColumnWidthToPixels(16), 117); + assert.equal(excelRowHeightToPixels(18), 24); + const layout = normalizeSheetLayout({ + units: { columnWidth: 'excelCharacters', rowHeight: 'points' }, + columns: [{ start: 0, end: 0, width: 16 }], + rows: [{ start: 0, end: 0, height: 18 }], + freezePanes: { rows: 1, columns: 1 }, + autoFilter: { a1: 'A1:D20' }, + }); + assert.equal(layout.columns[0].size, 117); + assert.equal(layout.rows[0].size, 24); + assert.equal(axisOffset(2, layout.columns, 100, 140), 217); + assert.equal(axisIndexAtOffset(118, 10, layout.columns, 100, 140), 1); + + const proposal = { layout: { before: null, after: { freezePanes: { rows: 1 }, autoFilter: { a1: 'A1:D20' } } } }; + assert.equal(proposalHasLayoutChanges(proposal), true); + assert.deepEqual(layoutDifferences(proposal).map((item) => item.key), ['freezePanes', 'autoFilter']); +}); + +test('format controls build reviewable style-only proposal cells without changing values', () => { + const cells = buildSelectionStyleCells( + { r1: 1, c1: 2, r2: 2, c2: 3 }, + { style: { numberFormat: '0.00%' } }, + ); + assert.equal(cells.length, 4); + assert.deepEqual(cells[0], { row: 1, col: 2, style: { numberFormat: '0.00%' } }); + assert.equal(Object.hasOwn(cells[0], 'value'), false); + assert.throws( + () => buildSelectionStyleCells({ r1: 0, c1: 0, r2: 100, c2: 100 }, { styleRole: 'header' }, 5000), + RangeError, + ); +}); + +test('common number formats render percentages, dates, and grouped numbers', () => { + assert.equal(formatValueWithNumberFormat(0.125, '0.00%'), '12.50%'); + assert.equal(formatValueWithNumberFormat(45292, 'yyyy-mm-dd'), '2024-01-01'); + assert.equal(formatValueWithNumberFormat(1, 'yyyy-mm-dd'), '1900-01-01'); + assert.equal(formatValueWithNumberFormat(59, 'yyyy-mm-dd'), '1900-02-28'); + assert.equal(formatValueWithNumberFormat(60, 'yyyy-mm-dd'), '1900-02-29'); + assert.equal(formatValueWithNumberFormat(61, 'yyyy-mm-dd'), '1900-03-01'); + assert.match(formatValueWithNumberFormat(1234.5, '#,##0.00'), /1[,.]234[,.]50/); +}); + +test('mixed cell and layout proposals become atomic only when every cell is selected', () => { + const partial = proposalAcceptPlan(['A1', 'A2'], ['A1'], true); + assert.deepEqual(partial.payloadCellRefs, ['A1']); + assert.equal(partial.acceptsLayout, false); + + const complete = proposalAcceptPlan(['A1', 'A2'], ['A2', 'A1'], true); + assert.equal(complete.payloadCellRefs, null); + assert.equal(complete.acceptsLayout, true); + + const layoutOnly = proposalAcceptPlan([], [], true); + assert.equal(layoutOnly.layoutOnly, true); + assert.equal(layoutOnly.payloadCellRefs, null); +}); + +test('lossy macro workbooks export through an explicit xlsx copy path', () => { + const sourcePreserving = exportCopyPlan('models/report.xlsm', { canRoundTrip: true }); + assert.equal(sourcePreserving.extension, 'xlsm'); + assert.equal(sourcePreserving.lossyMacroRebuild, false); + + const lossy = exportCopyPlan('models/report.xlsm', { canRoundTrip: false }); + assert.equal(lossy.extension, 'xlsx'); + assert.equal(lossy.acknowledgeFidelityLoss, true); + assert.equal(lossy.lossyMacroRebuild, true); + + const standard = exportCopyPlan('models/report.xlsx', { canRoundTrip: false }); + assert.equal(standard.extension, 'xlsx'); + assert.equal(standard.acknowledgeFidelityLoss, true); +}); diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/ui.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/ui.js new file mode 100644 index 00000000..b0117b33 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/ui.js @@ -0,0 +1,489 @@ +import { + acceptProposalAction, + addSheet, + askAboutFocus, + copySelection, + createWorkbook, + ensureWorkbook, + exportCsv, + insertColumn, + insertRow, + jumpToProposal, + openFile, + pasteSelection, + pinFocus, + proposeSelectionFormat, + refreshFromEngine, + refreshHistory, + redoWorkbook, + rejectProposalAction, + saveWorkbook, + switchSheet, + undoWorkbook, +} from './src/actions.js'; +import { commitModeToBackend, scheduleFocusSync, syncFocusToHost } from './src/focus.js'; +import { + clearProposalCellSelection, + refreshProposal, + renderProposalBar, + selectAllProposalCells, + toggleProposalCell, + toggleProposalExpanded, +} from './src/proposal.js'; +import { commitEdit } from './src/grid.js'; +import { agentRefreshTargetsCurrentWorkbook } from './src/interaction.js'; +import { t } from './src/i18n.js'; +import { render } from './src/render.js'; +import { state, invalidateCells } from './src/state.js'; +import { closestElement, normalizePathForCompare, runtime } from './src/util.js'; +import { applyStaticTexts, hideToast, showToast, updateChrome } from './src/views.js'; + +const AGENT_MUTATING_TOOLS = new Set([ + 'propose_patch', + 'accept_proposal', + 'reject_proposal', + 'create_workbook', + 'open_workbook', + 'save_workbook', + 'switch_sheet', + 'undo_workbook', + 'redo_workbook', + 'undo', + 'redo', + 'apply_local_patch', +]); + +let routeEventSeen = false; +let deferredPointerAction = null; + +const EDIT_COMMIT_ACTIONS = new Set([ + 'new-workbook', + 'open-workbook', + 'save-workbook', + 'export-csv', + 'pin-focus', + 'ask-focus', + 'undo', + 'redo', + 'accept-proposal', + 'reject-proposal', + 'jump-proposal', + 'toggle-proposal-details', + 'proposal-select-all', + 'proposal-select-none', + 'insert-row', + 'insert-col', + 'switch-sheet', + 'add-sheet', + 'set-mode', + 'format-role', + 'format-bold', +]); + +function normalizeRoute(route) { + const value = String(route || '/sheet').trim(); + if (!value || value === '/') return '/sheet'; + return value.startsWith('/') ? value : `/${value}`; +} + +function queueBoot(payload) { + const chain = state.bootChain || Promise.resolve(); + state.bootChain = chain + .then(() => ensureWorkbook(payload)) + .catch(() => {}); +} + +function resolveWorkspacePath(payload) { + const host = runtime(); + return ( + payload.workspacePath || + payload.workbench?.workspacePath || + state.workspacePath || + host.workspaceDir || + host.appDataDir || + null + ); +} + +function resolveLaunchPath(payload) { + return ( + payload.path || + payload.workbookPath || + payload.filePath || + payload.launchPath || + payload.workbench?.path || + null + ); +} + +function handleRouteEvent(payload = {}) { + routeEventSeen = true; + state.route = normalizeRoute(payload.route || state.route); + state.tabId = payload.tabId || state.tabId; + state.sessionId = payload.sessionId || state.sessionId; + + const nextWorkspace = resolveWorkspacePath(payload); + const workspaceChanged = Boolean( + nextWorkspace + && state.workspacePath + && normalizePathForCompare(nextWorkspace) !== normalizePathForCompare(state.workspacePath), + ); + state.workspacePath = nextWorkspace || state.workspacePath; + + let launchPath = resolveLaunchPath(payload); + if ( + launchPath && + state.workbookId && + normalizePathForCompare(state.path) === normalizePathForCompare(launchPath) + ) { + launchPath = null; + } + if (launchPath) state.pendingLaunchPath = launchPath; + + if (workspaceChanged) { + state.workbookId = null; + state.path = null; + state.meta = null; + state.sheets = []; + state.activeSheetId = null; + state.proposal = null; + invalidateCells(); + state.sheetLayouts.clear(); + state.dirty = false; + state.bootDone = false; + state.viewport.scrollRow = 0; + state.viewport.scrollCol = 0; + } + + render(); + + if (state.workspacePath && (launchPath || workspaceChanged || !state.workbookId)) { + queueBoot(payload); + } +} + +function handleActivate(payload = {}) { + handleRouteEvent(payload || {}); +} + +function shortToolName(toolName) { + const raw = String(toolName || ''); + // Host emits fully-qualified names like + // agentcomponent__excel-live-agent__propose_patch. + const parts = raw.split('__'); + return (parts[parts.length - 1] || raw).trim(); +} + +function handleAgentToolEvent(payload = {}) { + const toolName = shortToolName(payload.toolName); + const eventType = String(payload.eventType || '').toLowerCase(); + if (eventType !== 'completed' || !AGENT_MUTATING_TOOLS.has(toolName)) return; + const targetWorkbookId = typeof payload.workbookId === 'string' + ? payload.workbookId.trim() + : ''; + // A chat session can own more than one Excel Live surface. Once a surface is + // bound, even open/create completions for workbook B must not rebind the + // surface already showing workbook A. + if (!agentRefreshTargetsCurrentWorkbook(state.workbookId, targetWorkbookId)) return; + if (state.agentRefreshTimer) clearTimeout(state.agentRefreshTimer); + state.agentRefreshTimer = setTimeout(() => { + state.agentRefreshTimer = null; + void refreshFromEngine(targetWorkbookId || null); + }, 350); +} + +function modeLabel(mode) { + if (mode === 'inspect') return t('modeInspect'); + if (mode === 'author') return t('modeAuthor'); + return t('modeEdit'); +} + +async function applyMode(mode) { + const nextMode = ['inspect', 'edit', 'author'].includes(mode) ? mode : 'edit'; + if ( + !state.workbookId + || state.loading + || state.dialogPending + || state.modePending + || nextMode === state.mode + ) return false; + const previousMode = state.mode; + const previousStatus = state.status; + state.modePending = true; + state.pendingMode = nextMode; + state.status = t('modeSwitching', { mode: modeLabel(nextMode) }); + updateChrome(); + renderProposalBar(); + try { + // Explicit mode changes are a confirmed execution boundary. Ambient focus + // sync may be best-effort, but this backend write must complete before the + // UI exposes the new permissions to either the user or AI context. + await commitModeToBackend(nextMode); + state.mode = nextMode; + state.status = t('modeChanged', { mode: modeLabel(nextMode) }); + return true; + } catch (_error) { + state.mode = previousMode; + state.status = previousStatus; + showToast('error', t('modeSwitchFailed', { mode: modeLabel(previousMode) })); + return false; + } finally { + state.modePending = false; + state.pendingMode = null; + updateChrome(); + renderProposalBar(); + // Selection can continue moving while the mode request is in flight. + // Re-publish the latest focus after the boundary settles so host context + // and engine focus cannot retain the pre-switch cell. + syncFocusToHost({ force: true, broadcast: true }); + } +} + +function actionDescriptor(actionNode) { + return { + action: actionNode?.dataset?.action || '', + mode: actionNode?.dataset?.mode || null, + sheetId: actionNode?.dataset?.sheetId || null, + styleRole: actionNode?.dataset?.styleRole || null, + }; +} + +function performAction(descriptor) { + const action = descriptor.action; + if (action === 'new-workbook') void createWorkbook(); + if (action === 'open-workbook') void openFile(); + if (action === 'save-workbook') void saveWorkbook(); + if (action === 'export-csv') void exportCsv(); + if (action === 'pin-focus') void pinFocus(); + if (action === 'ask-focus') void askAboutFocus(); + if (action === 'undo') void undoWorkbook(); + if (action === 'redo') void redoWorkbook(); + if (action === 'accept-proposal') void acceptProposalAction(); + if (action === 'reject-proposal') void rejectProposalAction(); + if (action === 'jump-proposal') void jumpToProposal(); + if (action === 'toggle-proposal-details') toggleProposalExpanded(); + if (action === 'proposal-select-all') selectAllProposalCells(); + if (action === 'proposal-select-none') clearProposalCellSelection(); + if (action === 'insert-row') void insertRow(); + if (action === 'insert-col') void insertColumn(); + if (action === 'switch-sheet') void switchSheet(descriptor.sheetId); + if (action === 'add-sheet') void addSheet(); + if (action === 'dismiss-toast') hideToast(); + if (action === 'set-mode') void applyMode(descriptor.mode || 'edit'); + if (action === 'format-role') { + void proposeSelectionFormat({ + styleRole: descriptor.styleRole, + intent: t('formatRoleIntent', { role: t(`format${String(descriptor.styleRole || '').replace(/^./, (value) => value.toUpperCase())}`) }), + }); + } + if (action === 'format-bold') { + void proposeSelectionFormat({ style: { font: { bold: true } }, intent: t('formatBoldIntent') }); + } +} + +async function proposeFormatAfterEdit(change) { + if (state.editing && !(await commitEdit({ exit: true }))) return; + await proposeSelectionFormat(change); +} + +async function performActionAfterEdit(descriptor) { + if (descriptor.action === 'accept-proposal' || descriptor.action === 'reject-proposal') { + try { + await refreshProposal({ preserveSelection: true }); + renderProposalBar(); + } catch (error) { + showToast('error', error?.message || t('operationPending')); + return; + } + } + performAction(descriptor); +} + +function finishDeferredPointerAction(record) { + if (deferredPointerAction !== record || !record.released || !record.settled) return; + if (record.finishScheduled) return; + record.finishScheduled = true; + setTimeout(() => { + if (deferredPointerAction !== record) return; + deferredPointerAction = null; + if (record.confirmed && record.committed) void performActionAfterEdit(record.descriptor); + }, 0); +} + +// Pointer activation must cross the edit boundary before the requested action. +// Otherwise blur starts an async commit, disables the pressed control, and the +// browser drops the click. A failed commit restores the draft and stops here. +document.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || !state.editing) return; + const actionNode = closestElement(event.target, '[data-action]'); + if (!actionNode || actionNode.disabled || !EDIT_COMMIT_ACTIONS.has(actionNode.dataset.action)) return; + const record = { + node: actionNode, + descriptor: actionDescriptor(actionNode), + released: false, + confirmed: false, + settled: false, + committed: false, + finishScheduled: false, + }; + deferredPointerAction = record; + event.preventDefault(); + event.stopPropagation(); + void commitEdit({ exit: true }).then((committed) => { + record.committed = committed; + record.settled = true; + finishDeferredPointerAction(record); + }); +}, true); + +document.addEventListener('pointerup', (event) => { + const record = deferredPointerAction; + if (!record) return; + const releasedNode = closestElement(event.target, '[data-action]'); + const hitNode = typeof document.elementFromPoint === 'function' + ? closestElement(document.elementFromPoint(event.clientX, event.clientY), '[data-action]') + : null; + record.released = true; + record.confirmed = releasedNode === record.node || hitNode === record.node; + finishDeferredPointerAction(record); +}, true); + +document.addEventListener('pointercancel', () => { + const record = deferredPointerAction; + if (!record) return; + record.released = true; + record.confirmed = false; + finishDeferredPointerAction(record); +}, true); + +document.addEventListener('click', (event) => { + const actionNode = closestElement(event.target, '[data-action]'); + if (!actionNode) return; + if (actionNode === deferredPointerAction?.node) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + performAction(actionDescriptor(actionNode)); +}); + +document.addEventListener('change', (event) => { + const node = event.target; + if (node?.dataset?.action === 'toggle-include-focus') { + state.includeFocusOnSend = Boolean(node.checked); + scheduleFocusSync({ force: true, immediate: true }); + } + if (node?.dataset?.action === 'toggle-proposal-cell') { + toggleProposalCell(node.dataset.cellRef || '', Boolean(node.checked)); + } + if (node?.dataset?.action === 'format-fill') { + const swatch = node.closest?.('.el-format-color')?.querySelector('span'); + if (swatch) swatch.style.borderBottomColor = node.value; + void proposeFormatAfterEdit({ + style: { fill: { color: node.value } }, + intent: t('formatFillIntent'), + }); + } + if (node?.dataset?.action === 'format-number') { + void proposeFormatAfterEdit({ + style: { numberFormat: node.value }, + intent: t('formatNumberIntent', { format: node.options?.[node.selectedIndex]?.textContent || node.value }), + }); + } +}); + +document.addEventListener('excel-live:save', () => { + void saveWorkbook(); +}); + +document.addEventListener('excel-live:undo', () => { + void undoWorkbook(); +}); + +document.addEventListener('excel-live:redo', () => { + void redoWorkbook(); +}); + +document.addEventListener('excel-live:copy', () => { + void copySelection(); +}); + +document.addEventListener('excel-live:paste', () => { + void pasteSelection(); +}); + +document.addEventListener('excel-live:history-refresh', () => { + void refreshHistory(); +}); + +document.addEventListener('excel-live:proposal-render', () => { + renderProposalBar(); +}); + +document.addEventListener('excel-live:proposal-refresh', () => { + void refreshProposal({ preserveSelection: true }).then(() => { + renderProposalBar(); + }); +}); + +document.addEventListener('excel-live:editing-settled', () => { + const pending = state.pendingAgentRefresh; + if (!pending) return; + state.pendingAgentRefresh = null; + if (state.agentRefreshTimer) clearTimeout(state.agentRefreshTimer); + state.agentRefreshTimer = setTimeout(() => { + state.agentRefreshTimer = null; + void refreshFromEngine(pending.targetWorkbookId || null); + }, 0); +}); + +window.addEventListener('message', (event) => { + const message = event.data; + if (message?.type !== 'sparo:event') return; + if (message.event === 'localeChange') { + state.locale = message.payload?.locale || state.locale; + applyStaticTexts(); + state.lastSheetTabsKey = null; + render(); + } + if (message.event === 'productAppRuntimeRouteChange') { + handleRouteEvent(message.payload || {}); + } + if (message.event === 'productAppRuntimeActivate') { + handleActivate(message.payload || {}); + } + if (message.event === 'productAppRuntimeAgentToolEvent') { + handleAgentToolEvent(message.payload || {}); + } + if (message.event === 'spreadsheetFocusPreferenceChange') { + const includeOnSend = message.payload?.includeOnSend; + if (typeof includeOnSend === 'boolean') { + state.includeFocusOnSend = includeOnSend; + // Host-originated preference update: reflect it without syncing back, + // otherwise host and iframe would form an event loop. + updateChrome(); + } + } +}); + +runtime().onLocaleChange?.((locale) => { + state.locale = locale || state.locale; + applyStaticTexts(); + state.lastSheetTabsKey = null; + render(); +}); + +runtime().onActivate?.(handleActivate); + +window.addEventListener('DOMContentLoaded', () => { + const host = runtime(); + state.locale = host.locale || state.locale; + render(); + + // The host pushes route context after load; boot from bridge-provided + // directories only if no route event arrives (standalone preview). + setTimeout(() => { + if (routeEventSeen || state.bootDone || state.bootChain) return; + handleRouteEvent({}); + }, 2500); +}); diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/worker.js b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/worker.js new file mode 100644 index 00000000..7a65ed27 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/worker.js @@ -0,0 +1,13 @@ +'use strict'; + +async function status() { + return { + ok: true, + mode: 'composite', + runtime: 'host-panel', + }; +} + +module.exports = { + status, +}; diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/config/data-schema.json b/bundles/product-apps/builtin-excel-live/1.0.0/config/data-schema.json new file mode 100644 index 00000000..1587a669 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/config/data-schema.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/config/default.json b/bundles/product-apps/builtin-excel-live/1.0.0/config/default.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/config/default.json @@ -0,0 +1 @@ +{} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/tests/eval.json b/bundles/product-apps/builtin-excel-live/1.0.0/tests/eval.json new file mode 100644 index 00000000..17984415 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/tests/eval.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cases": [ + { + "id": "primary-behavior", + "title": "Excel Live responds from its declared app context", + "description": "Proves that the immutable Release can invoke its intelligent behavior.", + "input": { + "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with Excel Live." + }, + "expectations": [ + { + "kind": "text-contains", + "value": "SPARO_APP_READY" + } + ], + "evidenceKind": "behavior", + "tags": [ + "release-gate" + ], + "required": true + } + ] +} diff --git a/bundles/product-apps/builtin-excel-live/1.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-excel-live/1.0.0/tests/rehearsal.json new file mode 100644 index 00000000..1f3c0833 --- /dev/null +++ b/bundles/product-apps/builtin-excel-live/1.0.0/tests/rehearsal.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "critical-user-path", + "title": "Open Excel Live and complete its first useful action", + "description": "A revisioned spreadsheet workbench where people and agents share grounded focus, review proposals on the grid, undo safely, and export source-preserving workbook copies.", + "kind": "user-path", + "steps": [ + { + "id": "open-app", + "action": "open", + "target": "primary", + "expect": [ + "Excel Live opens" + ] + }, + { + "id": "observe-ready-state", + "action": "observe", + "target": "work-outcome", + "expect": [ + "A usable ready state is visible" + ] + } + ], + "expected": [ + "The app opens without a fatal runtime issue", + "The primary workflow exposes an observable path" + ] + } + ] +} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/app.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/app.json index fb90c07a..481dcd9c 100644 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/app.json +++ b/bundles/product-apps/builtin-harmony-dev/13.0.0/app.json @@ -14,12 +14,24 @@ "en-US": { "name": "HarmonyOS Dev", "description": "An integrated HarmonyOS app development environment that brings ArkTS, ArkUI, building, packaging, debugging, and device runtime into one workspace.", - "tags": ["built-in", "harmonyos", "arkui", "device", "build"] + "tags": [ + "built-in", + "harmonyos", + "arkui", + "device", + "build" + ] }, "zh-CN": { "name": "HarmonyOS Dev", "description": "一体化鸿蒙应用开发环境,把 ArkTS、ArkUI、构建、打包、调试和设备运行集中到一个工作空间。", - "tags": ["内置", "harmonyos", "arkui", "设备", "构建"] + "tags": [ + "内置", + "harmonyos", + "arkui", + "设备", + "构建" + ] } } }, @@ -294,5 +306,11 @@ "workspace.modify", "model.invoke", "tool.invoke" - ] + ], + "dataLifecycle": { + "retention": "userManaged", + "deletion": "deleteOnUserRequest", + "migration": "schemaVersioned", + "share": "declaredWorkObjectsOnly" + } } diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/compatibility.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/builtin-harmony-dev/13.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/worker.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/worker.js index 96e90a30..6b41fd5f 100644 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/worker.js +++ b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/worker.js @@ -1,5 +1,6 @@ -const { emit, readRequest } = require("./src/protocol"); -const { workspacePathOf, normalizeWorkspace } = require("./src/paths"); +const readline = require("node:readline"); +const { emit } = require("./src/protocol"); +const { normalizeWorkspace } = require("./src/paths"); const { detectProject } = require("./src/project"); const { detectToolchain } = require("./src/harmony-env"); const { listEmulators, startEmulator, stopEmulator } = require("./src/emulator"); @@ -9,87 +10,75 @@ const { readDiagnostics } = require("./src/diagnostics"); const { readRuntimeState } = require("./src/runtime-state"); const { redactValue } = require("./src/redact"); -async function main() { - const request = await readRequest(); - const action = request.action; +async function dispatchAction(action, input) { + switch (action) { + case "detectProject": return detectProject(input); + case "detectToolchain": return detectToolchain(input); + case "listEmulators": return listEmulators(input); + case "startEmulator": return startEmulator(input); + case "stopEmulator": return stopEmulator(input); + case "listTargets": return listTargets(input); + case "runUnitTests": return runUnitTests(input); + case "assembleApp": return assembleApp(input); + case "buildProject": return buildProject(input); + case "hotReload": return hotReload(input); + case "installApp": return installApp(input); + case "launchAbility": return launchAbility(input); + case "captureScreen": return captureScreen(input); + case "readScreenshot": return readScreenshot(input); + case "dumpHierarchy": return dumpHierarchy(input); + case "readLogs": return readLogs(input); + case "readDiagnostics": return readDiagnostics(input); + case "getRuntimeState": return { ok: true, runtimeState: readRuntimeState(input.workspacePath) }; + default: throw new Error(`Unsupported HarmonyOS Dev Runtime action: ${action}`); + } +} + +async function handleRequest(request) { + const runId = request.runId || request.run_id || `harmony-${Date.now()}`; + const bridgeId = request.bridgeId || request.bridge_id || "builtin-harmony-runtime"; + const emitEvent = (event) => emit({ bridgeId, runId, event }); + const workspacePath = request.workspacePath || request.workspace_path || null; const input = { - ...(request.input || {}), + ...(request.input && typeof request.input === "object" ? request.input : {}), + workspacePath: workspacePath ? normalizeWorkspace(workspacePath) : null, }; - if (!input.workspacePath) input.workspacePath = workspacePathOf(request); - if (input.workspacePath) input.workspacePath = normalizeWorkspace(input.workspacePath); - - emit({ type: "run.started", run_id: request.runId || request.run_id || `harmony-${Date.now()}` }); - - let output; - switch (action) { - case "detectProject": - output = detectProject(input); - break; - case "detectToolchain": - output = detectToolchain(input); - break; - case "listEmulators": - output = listEmulators(input); - break; - case "startEmulator": - output = await startEmulator(input); - break; - case "stopEmulator": - output = await stopEmulator(input); - break; - case "listTargets": - output = listTargets(input); - break; - case "runUnitTests": - output = runUnitTests(input); - break; - case "assembleApp": - output = assembleApp(input); - break; - case "buildProject": - output = buildProject(input); - break; - case "hotReload": - output = hotReload(input); - break; - case "installApp": - output = installApp(input); - break; - case "launchAbility": - output = launchAbility(input); - break; - case "captureScreen": - output = captureScreen(input); - break; - case "readScreenshot": - output = readScreenshot(input); - break; - case "dumpHierarchy": - output = dumpHierarchy(input); - break; - case "readLogs": - output = readLogs(input); - break; - case "readDiagnostics": - output = readDiagnostics(input); - break; - case "getRuntimeState": - output = { ok: true, runtimeState: readRuntimeState(input.workspacePath) }; - break; - default: - throw new Error(`Unsupported HarmonyOS Dev Runtime action: ${action}`); + emitEvent({ type: "run.started", run_id: runId }); + try { + const output = await dispatchAction(request.action, input); + emitEvent({ type: "run.completed", output: redactValue(output) }); + } catch (error) { + emitEvent({ + type: "run.failed", + error: { message: error instanceof Error ? error.message : String(error) }, + }); } +} - emit({ type: "run.completed", output: redactValue(output) }); +async function main() { + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + let sawRequest = false; + for await (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + sawRequest = true; + let request; + try { + request = JSON.parse(line); + } catch (error) { + process.stderr.write(`Invalid Bridge worker request: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + return; + } + await handleRequest(request); + } + if (!sawRequest) { + process.stderr.write("No Bridge worker request received on stdin\n"); + process.exitCode = 1; + } } main().catch((error) => { - emit({ - type: "run.failed", - error: { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }, - }); + process.stderr.write(`Bridge worker failed: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/config/data-schema.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/config/data-schema.json new file mode 100644 index 00000000..1587a669 --- /dev/null +++ b/bundles/product-apps/builtin-harmony-dev/13.0.0/config/data-schema.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/config/default.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/config/default.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/bundles/product-apps/builtin-harmony-dev/13.0.0/config/default.json @@ -0,0 +1 @@ +{} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/tests/eval.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/tests/eval.json new file mode 100644 index 00000000..0cb5e7bd --- /dev/null +++ b/bundles/product-apps/builtin-harmony-dev/13.0.0/tests/eval.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cases": [ + { + "id": "primary-behavior", + "title": "HarmonyOS Dev responds from its declared app context", + "description": "Proves that the immutable Release can invoke its intelligent behavior.", + "input": { + "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with HarmonyOS Dev." + }, + "expectations": [ + { + "kind": "text-contains", + "value": "SPARO_APP_READY" + } + ], + "evidenceKind": "behavior", + "tags": [ + "release-gate" + ], + "required": true + } + ] +} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/tests/rehearsal.json new file mode 100644 index 00000000..ecdfb11e --- /dev/null +++ b/bundles/product-apps/builtin-harmony-dev/13.0.0/tests/rehearsal.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "critical-user-path", + "title": "Open HarmonyOS Dev and complete its first useful action", + "description": "An integrated HarmonyOS app development environment that brings ArkTS, ArkUI, building, packaging, debugging, and device runtime into one workspace.", + "kind": "user-path", + "steps": [ + { + "id": "open-app", + "action": "open", + "target": "primary", + "expect": [ + "HarmonyOS Dev opens" + ] + }, + { + "id": "observe-ready-state", + "action": "observe", + "target": "work-outcome", + "expect": [ + "A usable ready state is visible" + ] + } + ], + "expected": [ + "The app opens without a fatal runtime issue", + "The primary workflow exposes an observable path" + ] + } + ] +} diff --git a/bundles/product-apps/builtin-ppt-live/150.0.0/app.json b/bundles/product-apps/builtin-ppt-live/150.0.0/app.json index f828d083..933b3319 100644 --- a/bundles/product-apps/builtin-ppt-live/150.0.0/app.json +++ b/bundles/product-apps/builtin-ppt-live/150.0.0/app.json @@ -14,12 +14,22 @@ "en-US": { "name": "PPT Live", "description": "A presentation creation workspace that turns a topic into structure, slides, and speaking flow, producing an editable deck.", - "tags": ["built-in", "presentation", "deck", "export"] + "tags": [ + "built-in", + "presentation", + "deck", + "export" + ] }, "zh-CN": { "name": "PPT Live", "description": "专注演示稿创作,从主题到结构、页面和讲述节奏,生成可以继续编辑和展示的 PPT。", - "tags": ["内置", "演示文稿", "幻灯片", "导出"] + "tags": [ + "内置", + "演示文稿", + "幻灯片", + "导出" + ] } } }, @@ -200,5 +210,11 @@ "component.run", "model.invoke", "tool.invoke" - ] + ], + "dataLifecycle": { + "retention": "externalSystem", + "deletion": "externalSystem", + "migration": "externalSystem", + "share": "externalReferenceOnly" + } } diff --git a/bundles/product-apps/builtin-ppt-live/150.0.0/compatibility.json b/bundles/product-apps/builtin-ppt-live/150.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/builtin-ppt-live/150.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/builtin-ppt-live/150.0.0/config/data-schema.json b/bundles/product-apps/builtin-ppt-live/150.0.0/config/data-schema.json new file mode 100644 index 00000000..1587a669 --- /dev/null +++ b/bundles/product-apps/builtin-ppt-live/150.0.0/config/data-schema.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/bundles/product-apps/builtin-ppt-live/150.0.0/config/default.json b/bundles/product-apps/builtin-ppt-live/150.0.0/config/default.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/bundles/product-apps/builtin-ppt-live/150.0.0/config/default.json @@ -0,0 +1 @@ +{} diff --git a/bundles/product-apps/builtin-ppt-live/150.0.0/tests/eval.json b/bundles/product-apps/builtin-ppt-live/150.0.0/tests/eval.json new file mode 100644 index 00000000..386f02da --- /dev/null +++ b/bundles/product-apps/builtin-ppt-live/150.0.0/tests/eval.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cases": [ + { + "id": "primary-behavior", + "title": "PPT Live responds from its declared app context", + "description": "Proves that the immutable Release can invoke its intelligent behavior.", + "input": { + "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with PPT Live." + }, + "expectations": [ + { + "kind": "text-contains", + "value": "SPARO_APP_READY" + } + ], + "evidenceKind": "behavior", + "tags": [ + "release-gate" + ], + "required": true + } + ] +} diff --git a/bundles/product-apps/builtin-ppt-live/150.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-ppt-live/150.0.0/tests/rehearsal.json new file mode 100644 index 00000000..10dd506c --- /dev/null +++ b/bundles/product-apps/builtin-ppt-live/150.0.0/tests/rehearsal.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "critical-user-path", + "title": "Open PPT Live and complete its first useful action", + "description": "A presentation creation workspace that turns a topic into structure, slides, and speaking flow, producing an editable deck.", + "kind": "user-path", + "steps": [ + { + "id": "open-app", + "action": "open", + "target": "primary", + "expect": [ + "PPT Live opens" + ] + }, + { + "id": "observe-ready-state", + "action": "observe", + "target": "work-outcome", + "expect": [ + "A usable ready state is visible" + ] + } + ], + "expected": [ + "The app opens without a fatal runtime issue", + "The primary workflow exposes an observable path" + ] + } + ] +} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/app.json b/bundles/product-apps/builtin-remotion-live/19.0.0/app.json index 1d15f98a..9de4934f 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/app.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/app.json @@ -14,12 +14,22 @@ "en-US": { "name": "Remotion Live", "description": "A Remotion video workbench for previewing, adjusting, fixing, and exporting motion projects.", - "tags": ["built-in", "remotion", "video", "render"] + "tags": [ + "built-in", + "remotion", + "video", + "render" + ] }, "zh-CN": { "name": "Remotion Live", "description": "面向 Remotion 视频项目的创作工作台,用来预览、调整、修复和导出动态作品。", - "tags": ["内置", "remotion", "视频", "渲染"] + "tags": [ + "内置", + "remotion", + "视频", + "渲染" + ] } } }, @@ -207,5 +217,11 @@ "workspace.modify", "model.invoke", "tool.invoke" - ] + ], + "dataLifecycle": { + "retention": "userManaged", + "deletion": "deleteOnUserRequest", + "migration": "exportImport", + "share": "declaredWorkObjectsOnly" + } } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/compatibility.json b/bundles/product-apps/builtin-remotion-live/19.0.0/compatibility.json new file mode 100644 index 00000000..7ea934b0 --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/compatibility.json @@ -0,0 +1,3 @@ +{ + "runtimeCompatibility": ">=0.1.0" +} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/component.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/component.json index af80b3fa..cbba0a51 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/component.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/component.json @@ -2,7 +2,7 @@ "id": "remotion-video-agent", "kind": "agent", "name": "Remotion Video Agent", - "description": "A Remotion-native video development agent for inspecting compositions, revising React video code, refreshing preview context, rendering stills, and starting exports through the Sparo Video Engine.", + "description": "A Remotion-native video development agent for inspecting compositions, revising React video code, refreshing preview context, rendering stills, and managing exports through the Sparo Video Engine.", "packageSource": "appPrivate", "ownerApp": { "appId": "builtin-remotion-live", @@ -23,17 +23,19 @@ "title": "Revise video composition", "description": "Make scoped React and Remotion source edits while preserving preview semantics.", "actions": [ - "getFrameContext", + "getFrameDescriptor", "refreshPreview" ] }, { "id": "renderReviewArtifact", "title": "Render review artifact", - "description": "Render stills or export candidates through the workspace Remotion toolchain.", + "description": "Render stills and start, monitor, or cancel exports through the workspace Remotion toolchain.", "actions": [ "renderStill", - "startExport" + "startExport", + "getExportStatus", + "cancelExport" ] } ], diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/agent.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/agent.md index 34ed7b3c..4662e5b9 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/agent.md +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/agent.md @@ -3,27 +3,32 @@ You are Remotion Video Agent, a Sparo OS Agent Component specialized in React-ba Core identity: - Think like a video developer who writes React, not like a generic file editor. - Treat the Remotion composition as the product surface: timeline, frame, fps, duration, aspect ratio, visual hierarchy, motion, assets, typography, and export reliability matter together. -- Use the live preview context when it is provided, especially composition id, frame, selected visual element, bounding box, and nearby layer metadata. Treat that context as a precise pointer, not as the whole truth. +- Use live preview context only when it identifies a committed project revision and frame. Treat composition id, committed frame, selected visual element, bounding box, and nearby layer metadata as a precise pointer, not as the whole truth. Tool boundaries: - Use the existing workspace tools for source work: LS, Glob, Grep, Read, Edit, Write, GetFileDiff, and Bash. - Use your Agent Component runtime tools only for Remotion-specific bridge work: - agentcomponent__remotion-video-agent__detect_project - agentcomponent__remotion-video-agent__get_composition_manifest - - agentcomponent__remotion-video-agent__get_frame_context + - agentcomponent__remotion-video-agent__get_frame_descriptor - agentcomponent__remotion-video-agent__refresh_preview - agentcomponent__remotion-video-agent__render_still - agentcomponent__remotion-video-agent__start_export + - agentcomponent__remotion-video-agent__get_export_status + - agentcomponent__remotion-video-agent__cancel_export - Do not invent separate search, read, write, or workspace tools. Use the base tools. - Do not call the generic bridge tool directly when one of your runtime tools covers the action. Working principles: - Start from the user intent and the open Remotion Live context. If the preview identifies a selected element, use it to locate likely source code, timing, style, and props. - Before editing, get enough project context to avoid blind changes: detect the project, read the composition manifest, and inspect the relevant source files. +- Bind every composition-scoped call (frame descriptor, preview, still, and export) to that manifest: pass its `projectRevision` as `expectedProjectRevision` and the selected composition's `descriptorRevision` as `expectedDescriptorRevision`. If the runtime reports a stale snapshot, resolve a new manifest instead of retrying against mixed revisions. +- If preview context revision differs from the current manifest, refresh preview and wait for a committed frame before using the selection. - Prefer minimal, composition-aware edits. Keep fps, duration, layout, and export behavior stable unless the user asks to change them. - When adding animation, keep it deterministic and frame-driven. Use Remotion primitives and interpolation carefully. - Avoid decorative overwork. A good video edit improves legibility, rhythm, hierarchy, or narrative clarity. -- After editing, summarize the changed source files and the preview implication. Use GetFileDiff when explaining source changes. +- After editing, refresh the authoritative Player, return to the target frame, and verify the committed revision before summarizing the changed source files and preview implication. Use GetFileDiff when explaining source changes. +- After starting an export, retain its run id, read its status until it reaches a terminal state, and use the cancellation tool when the user asks to stop it. Never describe an accepted export request as a completed file. Remotion development standards: - Keep the root composition registration clear and stable. diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/bundle.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/bundle.json index 55d506ba..8b568606 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/bundle.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/bundle.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, "id": "remotion-video-agent", - "version": 2 + "version": 5 } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/evals/cases.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/evals/cases.json index 8514bc14..67275f3c 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/evals/cases.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/evals/cases.json @@ -3,7 +3,7 @@ "name": "selected_text_revision", "intent": "Revise selected title text in the active Remotion Live preview.", "expectedTools": [ - "agentcomponent__remotion-video-agent__get_frame_context", + "agentcomponent__remotion-video-agent__get_frame_descriptor", "Grep", "Read", "Edit" diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/examples.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/examples.json index 22622aca..ea212487 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/examples.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/examples.json @@ -9,6 +9,6 @@ }, { "title": "Prepare an export", - "prompt": "Review the current composition, render a still for the key frame, then start an MP4 export when the source looks ready." + "prompt": "Review the current composition, render a still for the key frame, then export the MP4 and monitor the job until it reaches a terminal state." } ] diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/manifest.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/manifest.json index 34c2f121..88dd55cc 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/manifest.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "remotion-video-agent", "name": "Remotion Video Agent", - "description": "A Remotion-native video development agent for inspecting compositions, revising React video code, refreshing preview context, rendering stills, and starting exports through the Sparo Video Engine.", + "description": "A Remotion-native video development agent for inspecting compositions, revising React video code, refreshing committed preview context, rendering stills, and managing exports through the Sparo Video Engine.", "icon": "film", "category": "video", "tags": [ @@ -26,10 +26,12 @@ "Bash", "agentcomponent__remotion-video-agent__detect_project", "agentcomponent__remotion-video-agent__get_composition_manifest", - "agentcomponent__remotion-video-agent__get_frame_context", + "agentcomponent__remotion-video-agent__get_frame_descriptor", "agentcomponent__remotion-video-agent__refresh_preview", "agentcomponent__remotion-video-agent__render_still", - "agentcomponent__remotion-video-agent__start_export" + "agentcomponent__remotion-video-agent__start_export", + "agentcomponent__remotion-video-agent__get_export_status", + "agentcomponent__remotion-video-agent__cancel_export" ], "skills": [ "remotion-fundamentals", @@ -77,16 +79,18 @@ "Edit", "Write", "GetFileDiff", - "agentcomponent__remotion-video-agent__get_frame_context", + "agentcomponent__remotion-video-agent__get_frame_descriptor", "agentcomponent__remotion-video-agent__refresh_preview" ] }, { "name": "renderReviewArtifact", - "description": "Render a still or export candidate through the workspace Remotion toolchain for review.", + "description": "Render a still or start, monitor, and cancel an export through the workspace Remotion toolchain.", "toolPolicy": [ "agentcomponent__remotion-video-agent__render_still", - "agentcomponent__remotion-video-agent__start_export" + "agentcomponent__remotion-video-agent__start_export", + "agentcomponent__remotion-video-agent__get_export_status", + "agentcomponent__remotion-video-agent__cancel_export" ] } ], @@ -101,7 +105,7 @@ }, { "title": "Prepare an export", - "prompt": "Review the current composition, render a still for the key frame, then start an MP4 export when the source looks ready." + "prompt": "Review the current composition, render a still for the key frame, then export the MP4 and monitor the job until it reaches a terminal state." } ] } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/agent-action-input.schema.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/agent-action-input.schema.json index fba3c639..c63a203f 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/agent-action-input.schema.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/agent-action-input.schema.json @@ -11,8 +11,12 @@ "workspacePath": { "type": "string" }, "projectName": { "type": "string" }, "entryPoint": { "type": "string" }, + "projectRevision": { "type": "string" }, + "descriptorRevision": { "type": "string" }, "compositionId": { "type": "string" }, "frame": { "type": "integer", "minimum": 0 }, + "frameState": { "type": "string", "const": "committed" }, + "playbackState": { "type": "string", "enum": ["paused", "playing", "buffering", "seeking", "ended", "error"] }, "fps": { "type": "number" }, "durationInFrames": { "type": "integer" }, "size": { @@ -22,7 +26,6 @@ "height": { "type": "number" } } }, - "previewMode": { "type": "string", "enum": ["player", "studio", "still"] }, "selectedVideoContext": { "$ref": "./selected-video-context.schema.json" }, "sourceHints": { "type": "array", diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/live-preview-context.schema.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/live-preview-context.schema.json index dba7b1ee..cc79f9f2 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/live-preview-context.schema.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/live-preview-context.schema.json @@ -1,32 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Remotion Live Preview Context", - "type": "object", - "additionalProperties": true, - "properties": { - "workspacePath": { "type": "string" }, - "compositionId": { "type": "string" }, - "frame": { "type": "integer", "minimum": 0 }, - "fps": { "type": "number" }, - "durationInFrames": { "type": "integer" }, - "selectedElement": { - "type": "object", - "additionalProperties": true, - "properties": { - "id": { "type": "string" }, - "type": { "type": "string" }, - "label": { "type": "string" }, - "bbox": { - "type": "object", - "properties": { - "x": { "type": "number" }, - "y": { "type": "number" }, - "width": { "type": "number" }, - "height": { "type": "number" } - } - }, - "sourceHint": { "type": "string" } - } - } - } + "allOf": [ + { "$ref": "./selected-video-context.schema.json" } + ] } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/selected-video-context.schema.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/selected-video-context.schema.json index e0a1bb2b..0b540082 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/selected-video-context.schema.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/selected-video-context.schema.json @@ -3,16 +3,19 @@ "title": "Remotion Selected Video Context", "type": "object", "additionalProperties": true, - "required": ["workspacePath", "compositionId", "frame"], + "required": ["workspacePath", "projectRevision", "descriptorRevision", "compositionId", "frame", "frameState"], "properties": { "workspacePath": { "type": "string" }, + "projectRevision": { "type": "string" }, + "descriptorRevision": { "type": "string" }, "entryPoint": { "type": "string" }, "compositionId": { "type": "string" }, "frame": { "type": "integer", "minimum": 0 }, + "frameState": { "type": "string", "const": "committed" }, + "playbackState": { "type": "string", "enum": ["paused", "playing", "buffering", "seeking", "ended", "error"] }, "timeSeconds": { "type": "number" }, "fps": { "type": "number" }, "durationInFrames": { "type": "integer" }, - "previewMode": { "type": "string" }, "selection": { "type": "object", "additionalProperties": true, diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/selected-context.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/selected-context.md index c732b964..8183a6d9 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/selected-context.md +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/selected-context.md @@ -2,6 +2,10 @@ Use selected context as a precise pointer to the user's intent. -Selection may provide composition id, frame, element id, label, type, source hint, and bounding box in percent coordinates. It is strong evidence about "what the user means", but it is not sufficient proof of where to edit. Pair it with source inspection before changing code. +Selection provides a project revision, descriptor revision, composition id, committed frame, element id, label, type, source hint, and bounding box in percent coordinates. It is strong evidence about "what the user means", but it is not sufficient proof of where to edit. Pair it with the matching current manifest and source inspection before changing code. + +Do not use selection captured while the Player is seeking, buffering, or on a different project revision. Refresh the preview and wait for a committed paused frame first. + +For snapshot-bound runtime calls, pass the current manifest `projectRevision` and selected composition `descriptorRevision` as the expected revisions. Never substitute revisions from an older selection. When confidence is low, say what is known from the selection and inspect nearby composition files before editing. diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/validation-export.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/validation-export.md index b06369f9..5c378645 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/validation-export.md +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/validation-export.md @@ -5,6 +5,9 @@ Use this knowledge after meaningful source changes or before export. - Prefer refreshing the embedded Player preview for interaction and frame sync. - Render a still when a single key frame is enough to validate visual hierarchy. - Start export only when the composition, frame range, and source stability are clear. +- Pass the current manifest and composition revisions through `expectedProjectRevision` and `expectedDescriptorRevision` for every composition-scoped frame descriptor, preview, still, and export operation. +- Keep the returned run id, monitor progress until a terminal status, and use explicit cancellation when the user stops the job. +- Treat `queued`, `running`, and `cancelling` as non-terminal; only `completed`, `failed`, or `cancelled` closes the job. - Never claim visual validation unless preview, still, or export evidence was obtained. If render fails, report the concrete Remotion error and make the smallest reasoned recovery. diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_context.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.js similarity index 70% rename from bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_context.js rename to bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.js index 3827e321..95c30a93 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_context.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.js @@ -2,10 +2,10 @@ const { callVideoEngine } = require("./remotion_bridge"); async function run(input, context) { return callVideoEngine( - "getFrameContext", + "cancelExport", input, context, - "Evaluated the active Remotion frame context.", + "Requested cancellation of the Remotion export.", ); } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.tool.json new file mode 100644 index 00000000..2037d14f --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.tool.json @@ -0,0 +1,19 @@ +{ + "name": "cancel_export", + "description": "Cancel a running Remotion export job and preserve its terminal cancellation status.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["runId"], + "properties": { + "workspacePath": { "type": "string" }, + "runId": { "type": "string", "minLength": 1 } + } + }, + "runtime": "javascript", + "entry": "tools/cancel_export.js", + "readonly": false, + "permissions": {}, + "timeoutMs": 60000, + "maxOutputBytes": 60000 +} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.js index 0bc0daf6..b538201f 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.js @@ -2,9 +2,10 @@ const { callVideoEngine } = require("./remotion_bridge"); async function run(input, context) { const action = input && input.forceCompile ? "compileProject" : "getCompositionManifest"; + const { forceCompile, ...request } = input || {}; return callVideoEngine( action, - input, + forceCompile ? { ...request, force: true } : request, context, "Loaded the Remotion composition manifest.", ); diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.tool.json index 394b7f37..d8ff99d0 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.tool.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.tool.json @@ -1,6 +1,6 @@ { "name": "get_composition_manifest", - "description": "Read the current Remotion composition manifest, including composition ids, dimensions, fps, duration, sequence hints, source paths, and diagnostics.", + "description": "Read the authoritative Remotion composition manifest, including project and descriptor revisions, resolved props, dimensions, fps, duration, source paths, and diagnostics.", "inputSchema": { "type": "object", "additionalProperties": false, diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_export_status.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_export_status.js new file mode 100644 index 00000000..af92f007 --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_export_status.js @@ -0,0 +1,12 @@ +const { callVideoEngine } = require("./remotion_bridge"); + +async function run(input, context) { + return callVideoEngine( + "getExportStatus", + input, + context, + "Read the current Remotion export status.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_export_status.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_export_status.tool.json new file mode 100644 index 00000000..b58b16f3 --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_export_status.tool.json @@ -0,0 +1,19 @@ +{ + "name": "get_export_status", + "description": "Read one Remotion export job by its run id.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["runId"], + "properties": { + "workspacePath": { "type": "string" }, + "runId": { "type": "string", "minLength": 1 } + } + }, + "runtime": "javascript", + "entry": "tools/get_export_status.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 60000, + "maxOutputBytes": 60000 +} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_context.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_context.tool.json deleted file mode 100644 index 4594f8cf..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_context.tool.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "get_frame_context", - "description": "Evaluate a Remotion composition frame into preview-layer context for reasoning about selected elements, active sequences, timing, and nearby visual structure.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workspacePath": { "type": "string" }, - "entryPoint": { "type": "string" }, - "compositionId": { "type": "string" }, - "composition": { "type": "string" }, - "frame": { "type": "integer" }, - "inputProps": { "type": "object" }, - "selection": { "type": "object" }, - "selectedElement": { "type": "object" } - } - }, - "runtime": "javascript", - "entry": "tools/get_frame_context.js", - "readonly": true, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 80000 -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.js new file mode 100644 index 00000000..a4930cac --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.js @@ -0,0 +1,12 @@ +const { callVideoEngine } = require("./remotion_bridge"); + +async function run(input, context) { + return callVideoEngine( + "getFrameDescriptor", + input, + context, + "Read the non-visual Remotion frame descriptor.", + ); +} + +module.exports = { run }; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.tool.json new file mode 100644 index 00000000..13ad8c2f --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.tool.json @@ -0,0 +1,24 @@ +{ + "name": "get_frame_descriptor", + "description": "Read the non-visual composition descriptor at an integer frame. Committed visual selection context comes from the open Remotion Live Player.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["compositionId", "frame", "expectedProjectRevision", "expectedDescriptorRevision"], + "properties": { + "workspacePath": { "type": "string" }, + "entryPoint": { "type": "string" }, + "compositionId": { "type": "string" }, + "frame": { "type": "integer" }, + "expectedProjectRevision": { "type": "string", "minLength": 1 }, + "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, + "inputProps": { "type": "object" } + } + }, + "runtime": "javascript", + "entry": "tools/get_frame_descriptor.js", + "readonly": true, + "permissions": {}, + "timeoutMs": 30000, + "maxOutputBytes": 80000 +} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.js index 26e4f0fe..43871035 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.js @@ -1,14 +1,11 @@ const { callVideoEngine } = require("./remotion_bridge"); async function run(input = {}, context) { - const action = input.mode === "studio" ? "ensurePreviewServer" : "ensurePlayerPreviewHost"; return callVideoEngine( - action, + "ensurePlayerPreviewHost", input, context, - input.mode === "studio" - ? "Refreshed the Remotion Studio preview server." - : "Refreshed the embedded Remotion Player preview host.", + "Refreshed the embedded Remotion Player preview host.", ); } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.tool.json index 021e82a7..3fd90b85 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.tool.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.tool.json @@ -1,16 +1,17 @@ { "name": "refresh_preview", - "description": "Start or refresh the Remotion preview host for the current workspace. Use player mode for the embedded Remotion Live preview and studio mode only when Studio is explicitly needed.", + "description": "Start or refresh the authoritative Remotion Player preview for the current workspace.", "inputSchema": { "type": "object", "additionalProperties": false, + "required": ["compositionId", "expectedProjectRevision", "expectedDescriptorRevision"], "properties": { "workspacePath": { "type": "string" }, "entryPoint": { "type": "string" }, "compositionId": { "type": "string" }, - "composition": { "type": "string" }, + "expectedProjectRevision": { "type": "string", "minLength": 1 }, + "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, "frame": { "type": "integer" }, - "mode": { "type": "string", "enum": ["player", "studio"] }, "force": { "type": "boolean" }, "port": { "type": "integer" }, "waitMs": { "type": "integer" }, @@ -21,6 +22,6 @@ "entry": "tools/refresh_preview.js", "readonly": true, "permissions": {}, - "timeoutMs": 90000, + "timeoutMs": 180000, "maxOutputBytes": 60000 } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/remotion_bridge.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/remotion_bridge.js index d3a5aaaf..1e961c74 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/remotion_bridge.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/remotion_bridge.js @@ -6,13 +6,6 @@ function normalizeInput(input = {}, context = {}) { if (!payload.workspacePath && !payload.workspace_path && context.workspaceRoot) { payload.workspacePath = context.workspaceRoot; } - if (!payload.compositionId && payload.composition) { - payload.compositionId = payload.composition; - } - if (payload.selectedElement && !payload.selection) { - payload.selection = payload.selectedElement; - delete payload.selectedElement; - } return payload; } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/render_still.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/render_still.tool.json index 422a672a..0ee5b124 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/render_still.tool.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/render_still.tool.json @@ -4,15 +4,15 @@ "inputSchema": { "type": "object", "additionalProperties": false, + "required": ["compositionId", "frame", "expectedProjectRevision", "expectedDescriptorRevision"], "properties": { "workspacePath": { "type": "string" }, "entryPoint": { "type": "string" }, "compositionId": { "type": "string" }, - "composition": { "type": "string" }, + "expectedProjectRevision": { "type": "string", "minLength": 1 }, + "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, "frame": { "type": "integer" }, - "scale": { "type": "number" }, - "outputPath": { "type": "string" }, - "timeoutMs": { "type": "integer" }, + "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "inputProps": { "type": "object" } } }, diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/start_export.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/start_export.tool.json index 1e8c559f..c1e04730 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/start_export.tool.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/start_export.tool.json @@ -1,21 +1,25 @@ { "name": "start_export", - "description": "Start an MP4 export for a Remotion composition through the workspace Remotion toolchain.", + "description": "Start an asynchronous Remotion export job through the workspace renderer.", "inputSchema": { "type": "object", "additionalProperties": false, + "required": ["compositionId", "expectedProjectRevision", "expectedDescriptorRevision"], "properties": { "workspacePath": { "type": "string" }, "entryPoint": { "type": "string" }, "compositionId": { "type": "string" }, - "composition": { "type": "string" }, + "expectedProjectRevision": { "type": "string", "minLength": 1 }, + "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, "frameRange": { "type": "array", - "items": { "type": "integer" } + "items": { "type": "integer", "minimum": 0 }, + "minItems": 2, + "maxItems": 2 }, - "scale": { "type": "number" }, - "outputPath": { "type": "string" }, - "timeoutMs": { "type": "integer" }, + "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 4 }, + "codec": { "type": "string", "enum": ["h264", "h265", "vp8", "vp9", "prores", "gif"] }, + "outputName": { "type": "string" }, "inputProps": { "type": "object" } } }, @@ -23,6 +27,6 @@ "entry": "tools/start_export.js", "readonly": false, "permissions": {}, - "timeoutMs": 700000, + "timeoutMs": 90000, "maxOutputBytes": 60000 } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/component.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/component.json index 925164b2..956739bc 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/component.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/component.json @@ -2,7 +2,7 @@ "id": "builtin-remotion-runtime", "kind": "bridge", "name": "Sparo Video Engine", - "description": "Project analysis, Remotion Player preview hosting, Remotion Studio preview hosting, still generation, export orchestration, asset indexing, and diagnostics for Remotion Live.", + "description": "Project analysis, authoritative Remotion Player preview hosting, still generation, export orchestration, asset indexing, and diagnostics for Remotion Live.", "packageSource": "appPrivate", "ownerApp": { "appId": "builtin-remotion-live", @@ -12,21 +12,15 @@ { "id": "sparo.videoEngine", "title": "Sparo Video Engine", - "description": "Analyze Remotion project source, host Remotion Player and Studio preview runtimes, render stills and exports through the project toolchain, and report diagnostics.", + "description": "Analyze Remotion project source, host the authoritative Remotion Player runtime, render stills and exports through the project toolchain, and report diagnostics.", "actions": [ "detectProject", "compileProject", "getCompositionManifest", - "getFrameContext", - "evaluateFrame", - "renderPreviewFrame", - "renderPreviewClip", + "getFrameDescriptor", "ensurePlayerPreviewHost", "getPlayerPreviewHostStatus", "stopPlayerPreviewHost", - "ensurePreviewServer", - "getPreviewServerStatus", - "stopPreviewServer", "renderStill", "startExport", "getExportStatus", diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/bundle.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/bundle.json index 4f7abcf4..6081e474 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/bundle.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/bundle.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, "id": "builtin-remotion-runtime", - "version": 15 + "version": 22 } diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/manifest.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/manifest.json index 8671a3d9..df322097 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/manifest.json +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/manifest.json @@ -2,11 +2,12 @@ "schemaVersion": 1, "id": "builtin-remotion-runtime", "name": "Sparo Video Engine", - "description": "Project analysis, Remotion Player preview hosting, Remotion Studio preview hosting, still generation, export orchestration, asset indexing, and diagnostics for Remotion Live.", + "description": "Project analysis, authoritative Remotion Player preview hosting, still generation, export orchestration, asset indexing, and diagnostics for Remotion Live.", "kind": "daemon", "runtime": { "language": "javascript", - "entry": "worker.js" + "entry": "worker.js", + "idleTimeoutMs": 120000 }, "surfaces": { "launchableApp": false, @@ -37,22 +38,16 @@ { "id": "sparo.videoEngine", "title": "Sparo Video Engine", - "description": "Analyze Remotion project source, host the project's own Remotion Player and Studio preview runtimes, render stills/exports through the project toolchain, and report diagnostics.", + "description": "Analyze Remotion project source, host the authoritative Remotion Player runtime, render stills/exports through the project toolchain, and report diagnostics.", "category": "videoEngine", "actions": [ "detectProject", "compileProject", "getCompositionManifest", - "getFrameContext", - "evaluateFrame", - "renderPreviewFrame", - "renderPreviewClip", + "getFrameDescriptor", "ensurePlayerPreviewHost", "getPlayerPreviewHostStatus", "stopPlayerPreviewHost", - "ensurePreviewServer", - "getPreviewServerStatus", - "stopPreviewServer", "renderStill", "startExport", "getExportStatus", @@ -69,7 +64,13 @@ "management" ], "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 } + } }, "outputSchema": { "type": "object" @@ -81,7 +82,13 @@ "name": "detectProject", "description": "Detect whether the workspace contains a Remotion project and locate entry/composition source files.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 } + } }, "outputSchema": { "type": "object" @@ -92,9 +99,17 @@ }, { "name": "compileProject", - "description": "Compile a static native project manifest for Remotion Live.", + "description": "Materialize a fresh schema-v3 project snapshot by bundling the registered Root and resolving calculateMetadata through the project renderer.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 }, + "inputProps": { "type": "object" }, + "force": { "type": "boolean" } + } }, "outputSchema": { "type": "object" @@ -105,48 +120,17 @@ }, { "name": "getCompositionManifest", - "description": "Read the current native composition manifest.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "evaluateFrame", - "description": "Evaluate a composition frame into Sparo's native preview layer model.", + "description": "Read the current source-and-input-scoped resolved snapshot; use compileProject to refresh calculateMetadata explicitly.", "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "getFrameContext", - "description": "Read the current Remotion frame context for preview selection and agent handoff.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "renderPreviewFrame", - "description": "Render a scaled preview frame through the workspace's Remotion toolchain and return a cached image data URL.", - "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 }, + "inputProps": { "type": "object" }, + "force": { "type": "boolean" } + } }, "outputSchema": { "type": "object" @@ -156,10 +140,21 @@ "resumable": true }, { - "name": "renderPreviewClip", - "description": "Render a short low-resolution preview video clip through the workspace's Remotion toolchain and return a cached MP4 data URL.", + "name": "getFrameDescriptor", + "description": "Read the non-visual composition descriptor at an integer frame. Committed visual context is owned by the open Player surface.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath", "compositionId", "frame", "expectedProjectRevision", "expectedDescriptorRevision"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 }, + "compositionId": { "type": "string", "minLength": 1 }, + "frame": { "type": "integer", "minimum": 0 }, + "expectedProjectRevision": { "type": "string", "minLength": 1 }, + "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, + "inputProps": { "type": "object" } + } }, "outputSchema": { "type": "object" @@ -172,7 +167,21 @@ "name": "ensurePlayerPreviewHost", "description": "Build, start, or reuse the workspace's Remotion Player preview host for interactive playback.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath", "compositionId", "expectedProjectRevision", "expectedDescriptorRevision"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 }, + "compositionId": { "type": "string", "minLength": 1 }, + "frame": { "type": "integer", "minimum": 0 }, + "expectedProjectRevision": { "type": "string", "minLength": 1 }, + "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, + "inputProps": { "type": "object" }, + "force": { "type": "boolean" }, + "port": { "type": "integer", "minimum": 1024, "maximum": 65535 }, + "waitMs": { "type": "integer", "minimum": 0, "maximum": 120000 } + } }, "outputSchema": { "type": "object" @@ -185,7 +194,12 @@ "name": "getPlayerPreviewHostStatus", "description": "Read the current Remotion Player preview host status and recent logs.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 } + } }, "outputSchema": { "type": "object" @@ -198,46 +212,12 @@ "name": "stopPlayerPreviewHost", "description": "Stop the Remotion Player preview host for the workspace.", "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "ensurePreviewServer", - "description": "Start or reuse the workspace's Remotion Studio server for interactive preview.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "getPreviewServerStatus", - "description": "Read the current Remotion Studio preview server status and recent logs.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "stopPreviewServer", - "description": "Stop the Remotion Studio preview server for the workspace.", - "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 } + } }, "outputSchema": { "type": "object" @@ -250,7 +230,20 @@ "name": "renderStill", "description": "Render the current frame through the workspace's Remotion toolchain to a local image still.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath", "compositionId", "frame", "expectedProjectRevision", "expectedDescriptorRevision"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 }, + "compositionId": { "type": "string", "minLength": 1 }, + "frame": { "type": "integer", "minimum": 0 }, + "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, + "expectedProjectRevision": { "type": "string", "minLength": 1 }, + "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, + "inputProps": { "type": "object" }, + "force": { "type": "boolean" } + } }, "outputSchema": { "type": "object" @@ -261,9 +254,28 @@ }, { "name": "startExport", - "description": "Render a video export through the workspace's Remotion toolchain.", + "description": "Start an isolated asynchronous Remotion render job with durable progress reporting.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath", "compositionId", "expectedProjectRevision", "expectedDescriptorRevision"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 }, + "compositionId": { "type": "string", "minLength": 1 }, + "frameRange": { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "minItems": 2, + "maxItems": 2 + }, + "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 4 }, + "codec": { "type": "string", "enum": ["h264", "h265", "vp8", "vp9", "prores", "gif"] }, + "outputName": { "type": "string" }, + "expectedProjectRevision": { "type": "string", "minLength": 1 }, + "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, + "inputProps": { "type": "object" } + } }, "outputSchema": { "type": "object" @@ -276,7 +288,13 @@ "name": "getExportStatus", "description": "Read native export run status.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath", "runId"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "runId": { "type": "string", "minLength": 1 } + } }, "outputSchema": { "type": "object" @@ -287,9 +305,15 @@ }, { "name": "cancelExport", - "description": "Cancel or mark a native export run as cancelled.", + "description": "Request renderer-native export cancellation with isolated process-tree termination fallback.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath", "runId"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "runId": { "type": "string", "minLength": 1 } + } }, "outputSchema": { "type": "object" @@ -302,7 +326,13 @@ "name": "indexAssets", "description": "Index project media, font, caption, and data assets.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 } + } }, "outputSchema": { "type": "object" @@ -315,7 +345,13 @@ "name": "readDiagnostics", "description": "Read project diagnostics and source control changes.", "inputSchema": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["workspacePath"], + "properties": { + "workspacePath": { "type": "string", "minLength": 1 }, + "entryPoint": { "type": "string", "minLength": 1 } + } }, "outputSchema": { "type": "object" @@ -328,23 +364,17 @@ "tools": [ { "name": "SparoVideoEngine", - "description": "Analyze Remotion projects, host Remotion Player/Studio previews, and render stills/exports through the workspace Remotion toolchain.", + "description": "Analyze Remotion projects, host the authoritative Remotion Player preview, and render stills/exports through the workspace Remotion toolchain.", "capabilityId": "sparo.videoEngine", "action": "compileProject", "actions": [ "detectProject", "compileProject", "getCompositionManifest", - "getFrameContext", - "evaluateFrame", - "renderPreviewFrame", - "renderPreviewClip", + "getFrameDescriptor", "ensurePlayerPreviewHost", "getPlayerPreviewHostStatus", "stopPlayerPreviewHost", - "ensurePreviewServer", - "getPreviewServerStatus", - "stopPreviewServer", "renderStill", "startExport", "getExportStatus", diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/artifacts.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/artifacts.js new file mode 100644 index 00000000..d92d8395 --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/artifacts.js @@ -0,0 +1,83 @@ +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { ensureRuntimeDir, playerHostStatePath } = require("./paths"); +const { ARTIFACT_CACHE_MAX_BYTES } = require("./constants"); +const { readJson, safeStat, walkFiles } = require("./util"); + +function artifactRoot(workspacePath) { + return ensureRuntimeDir(workspacePath, "artifacts"); +} + +function artifactDirectory(workspacePath, kind) { + const directory = path.join(artifactRoot(workspacePath), kind); + fs.mkdirSync(directory, { recursive: true }); + return directory; +} + +function isInside(candidate, root) { + const relative = path.relative(root, candidate); + return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function artifactRelativeUrl(workspacePath, filePath) { + const root = artifactRoot(workspacePath); + const absolute = path.resolve(filePath); + if (!isInside(absolute, root)) throw new Error(`Artifact is outside the runtime artifact root: ${absolute}`); + const encoded = path.relative(root, absolute).split(path.sep).map(encodeURIComponent).join("/"); + return `/artifacts/${encoded}`; +} + +function artifactUrl(workspacePath, relativeUrl) { + const state = readJson(playerHostStatePath(workspacePath), null); + if (state?.ready !== true || state?.status !== "ready") return null; + const baseUrl = String(state?.baseUrl || state?.url || "").replace(/\/$/, ""); + return baseUrl ? `${baseUrl}${relativeUrl}` : null; +} + +function contentHash(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex").slice(0, 24); +} + +function pruneArtifactCache(workspacePath, activePath, maxBytes = ARTIFACT_CACHE_MAX_BYTES) { + const root = artifactRoot(workspacePath); + const active = path.resolve(activePath); + const entries = walkFiles(root, { maxFiles: 10_000 }) + .map((filePath) => ({ filePath: path.resolve(filePath), stat: safeStat(filePath) })) + .filter((entry) => entry.stat?.isFile()) + .sort((a, b) => (b.stat.mtimeMs || 0) - (a.stat.mtimeMs || 0)); + let total = entries.reduce((sum, entry) => sum + entry.stat.size, 0); + for (const entry of entries.reverse()) { + if (total <= maxBytes) break; + if (entry.filePath === active) continue; + try { + fs.unlinkSync(entry.filePath); + total -= entry.stat.size; + } catch { + // Cache pruning is best effort. The active artifact is never removed. + } + } +} + +function describeArtifact(workspacePath, filePath) { + const stat = safeStat(filePath); + if (!stat?.isFile()) throw new Error(`Artifact does not exist: ${filePath}`); + const relativeUrl = artifactRelativeUrl(workspacePath, filePath); + return { + artifactPath: filePath, + artifactRelativeUrl: relativeUrl, + artifactUrl: artifactUrl(workspacePath, relativeUrl), + contentHash: contentHash(filePath), + bytes: stat.size, + }; +} + +module.exports = { + artifactRoot, + artifactDirectory, + artifactRelativeUrl, + artifactUrl, + contentHash, + pruneArtifactCache, + describeArtifact, +}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/constants.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/constants.js index 630cf884..a9a4d2ab 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/constants.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/constants.js @@ -1,18 +1,51 @@ const IGNORED_DIRS = new Set([".git", "node_modules", "out", "dist", "build", ".next", ".sparo_os", "coverage"]); const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); -const PREVIEW_CACHE_LIMIT = 160; -const PREVIEW_VIDEO_CACHE_LIMIT = 24; -const DEFAULT_PREVIEW_SCALE = 1; -const DEFAULT_PREVIEW_VIDEO_SCALE = 0.5; -const DEFAULT_PREVIEW_CLIP_SECONDS = 3; +const PROJECT_REVISION_EXTENSIONS = new Set([ + ...SOURCE_EXTENSIONS, + ".json", + ".css", + ".scss", + ".sass", + ".less", + ".html", + ".md", + ".yml", + ".yaml", + ".png", + ".jpg", + ".jpeg", + ".webp", + ".avif", + ".gif", + ".svg", + ".bmp", + ".ico", + ".mp4", + ".mov", + ".webm", + ".m4v", + ".mp3", + ".wav", + ".m4a", + ".ogg", + ".oga", + ".opus", + ".aac", + ".flac", + ".ttf", + ".otf", + ".woff", + ".woff2", + ".srt", + ".vtt", +]); const DEFAULT_STILL_SCALE = 1; const REMOTION_RENDER_TIMEOUT_MS = 240_000; -const REMOTION_EXPORT_TIMEOUT_MS = 600_000; -const PREVIEW_SERVER_BOOT_WAIT_MS = 0; -const PREVIEW_SERVER_STALE_MS = 120_000; +const ARTIFACT_CACHE_MAX_BYTES = 512 * 1024 * 1024; +const PROJECT_BUNDLE_CACHE_LIMIT = 3; const PLAYER_HOST_BOOT_WAIT_MS = 45_000; -const PLAYER_HOST_STALE_MS = 10 * 60_000; -const PLAYER_HOST_RUNTIME_VERSION = 8; +const PLAYER_HOST_RUNTIME_VERSION = 14; +const PLAYER_CONTROL_PROTOCOL_VERSION = 3; const ASSET_EXTENSIONS = new Map([ [".png", "image"], [".jpg", "image"], @@ -41,18 +74,13 @@ const ASSET_EXTENSIONS = new Map([ module.exports = { IGNORED_DIRS, SOURCE_EXTENSIONS, - PREVIEW_CACHE_LIMIT, - PREVIEW_VIDEO_CACHE_LIMIT, - DEFAULT_PREVIEW_SCALE, - DEFAULT_PREVIEW_VIDEO_SCALE, - DEFAULT_PREVIEW_CLIP_SECONDS, + PROJECT_REVISION_EXTENSIONS, DEFAULT_STILL_SCALE, REMOTION_RENDER_TIMEOUT_MS, - REMOTION_EXPORT_TIMEOUT_MS, - PREVIEW_SERVER_BOOT_WAIT_MS, - PREVIEW_SERVER_STALE_MS, + ARTIFACT_CACHE_MAX_BYTES, + PROJECT_BUNDLE_CACHE_LIMIT, PLAYER_HOST_BOOT_WAIT_MS, - PLAYER_HOST_STALE_MS, PLAYER_HOST_RUNTIME_VERSION, + PLAYER_CONTROL_PROTOCOL_VERSION, ASSET_EXTENSIONS, }; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export-job-worker.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export-job-worker.js new file mode 100644 index 00000000..6ff1144a --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export-job-worker.js @@ -0,0 +1,160 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +function readJson(filePath, fallback = null) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return fallback; + } +} + +function writeJsonAtomic(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + try { + fs.renameSync(temporaryPath, filePath); + } catch (error) { + try { + fs.rmSync(filePath, { force: true }); + fs.renameSync(temporaryPath, filePath); + } catch { + fs.rmSync(temporaryPath, { force: true }); + throw error; + } + } +} + +async function main() { + const configPath = process.argv[2]; + const config = readJson(configPath, null); + if (!config) throw new Error("Export job configuration is missing or invalid."); + const renderer = require(config.rendererEntry); + const noReact = require(config.noReactEntry); + if (typeof renderer.renderMedia !== "function" || typeof renderer.makeCancelSignal !== "function") { + throw new Error("The project @remotion/renderer package must expose renderMedia() and makeCancelSignal()."); + } + + let run = readJson(config.manifestPath, config.run) || config.run; + const update = (patch) => { + const terminal = new Set(["completed", "cancelled", "failed"]); + if (terminal.has(run.status) && patch.status && !terminal.has(patch.status)) return; + run = { ...run, ...patch }; + writeJsonAtomic(config.manifestPath, run); + }; + const { cancel, cancelSignal } = renderer.makeCancelSignal(); + let cancellationSent = false; + let forceCancellationTimer = null; + const requestCancellationIfNeeded = () => { + if (!cancellationSent && fs.existsSync(config.cancelPath)) { + cancellationSent = true; + update({ status: "cancelling", phase: "cancelling", cancelRequestedAt: Date.now() }); + cancel(); + forceCancellationTimer = setTimeout(() => { + update({ status: "cancelled", phase: "cancelled", cancelledAt: Date.now(), forced: true }); + fs.rmSync(config.outputPath, { force: true }); + if (process.platform === "win32") { + const killer = spawn("taskkill", ["/PID", String(process.pid), "/T", "/F"], { + detached: true, + windowsHide: true, + stdio: "ignore", + }); + killer.unref(); + } else { + try { + process.kill(-process.pid, "SIGKILL"); + } catch { + process.kill(process.pid, "SIGKILL"); + } + } + }, 5_000); + } + }; + const watchCancel = setInterval(requestCancellationIfNeeded, 150); + watchCancel.unref?.(); + requestCancellationIfNeeded(); + + const deserialize = noReact.NoReactInternals.deserializeJSONWithSpecialTypes; + const resolvedProps = deserialize(config.composition.serializedResolvedProps); + const composition = { + id: config.composition.id, + width: config.composition.width, + height: config.composition.height, + fps: config.composition.fps, + durationInFrames: config.composition.durationInFrames, + defaultProps: config.composition.defaultProps || {}, + props: resolvedProps, + defaultCodec: config.composition.defaults?.codec || null, + defaultOutName: config.composition.defaults?.outName || null, + defaultVideoImageFormat: config.composition.defaults?.videoImageFormat || null, + defaultPixelFormat: config.composition.defaults?.pixelFormat || null, + defaultProResProfile: config.composition.defaults?.proResProfile || null, + defaultSampleRate: config.composition.defaults?.sampleRate || null, + }; + + try { + update({ status: "running", phase: "rendering", pid: process.pid, startedAt: run.startedAt || Date.now() }); + await renderer.renderMedia({ + serveUrl: config.bundlePath, + composition, + inputProps: resolvedProps, + codec: config.codec, + outputLocation: config.outputPath, + frameRange: config.frameRange, + scale: config.scale, + overwrite: true, + cancelSignal, + logLevel: "warn", + onProgress: (progress) => { + requestCancellationIfNeeded(); + update({ + status: cancellationSent ? "cancelling" : "running", + phase: cancellationSent ? "cancelling" : progress.stitchStage || "rendering", + progress: Math.max(0, Math.min(100, Math.round((Number(progress.progress) || 0) * 100))), + renderedFrames: Number(progress.renderedFrames) || 0, + encodedFrames: Number(progress.encodedFrames) || 0, + estimatedTimeMs: Number.isFinite(progress.renderEstimatedTime) ? progress.renderEstimatedTime : null, + updatedAt: Date.now(), + }); + }, + }); + if (fs.existsSync(config.cancelPath)) { + fs.rmSync(config.outputPath, { force: true }); + update({ status: "cancelled", phase: "cancelled", cancelledAt: Date.now() }); + return; + } + const stat = fs.statSync(config.outputPath); + update({ + status: "completed", + phase: "completed", + progress: 100, + bytes: stat.size, + completedAt: Date.now(), + }); + } catch (error) { + const cancelled = cancellationSent || fs.existsSync(config.cancelPath); + if (cancelled) { + fs.rmSync(config.outputPath, { force: true }); + update({ status: "cancelled", phase: "cancelled", cancelledAt: Date.now() }); + } else { + fs.rmSync(config.outputPath, { force: true }); + update({ + status: "failed", + phase: "failed", + error: error instanceof Error ? error.message : String(error), + failedAt: Date.now(), + }); + } + } finally { + clearInterval(watchCancel); + if (forceCancellationTimer) clearTimeout(forceCancellationTimer); + if (config.bundlePinPath) fs.rmSync(config.bundlePinPath, { force: true }); + } +} + +main().catch((error) => { + process.stderr.write(`Export worker failed: ${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export.js index c5896397..9d50b2ae 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export.js @@ -1,32 +1,93 @@ const fs = require("node:fs"); const path = require("node:path"); +const { spawn } = require("node:child_process"); const { ensureRuntimeDir, normalizeWorkspace, workspacePathOf } = require("./paths"); -const { readJson, writeJson, hashContent, safeFilePart, clampNumber } = require("./util"); -const { getCompositionManifest, detectProject } = require("./project"); -const { runRemotion } = require("./remotion-cli"); -const { fileUri } = require("./media"); -const { REMOTION_EXPORT_TIMEOUT_MS } = require("./constants"); +const { readJson, writeJsonAtomic, hashContent, safeFilePart, clampNumber, isProcessAlive } = require("./util"); +const { compositionForInput } = require("./project"); +const { resolveProjectModule } = require("./project-deps"); +const { pinProjectBundle, unpinProjectBundle } = require("./project-runtime"); + +const EXPORT_HISTORY_LIMIT = 50; +const EXPORT_SPAWN_LEASE_MS = 30_000; +const CODEC_EXTENSIONS = new Map([ + ["h264", ".mp4"], + ["h265", ".mp4"], + ["vp8", ".webm"], + ["vp9", ".webm"], + ["prores", ".mov"], + ["gif", ".gif"], +]); function runsPath(workspacePath) { return path.join(ensureRuntimeDir(workspacePath), "runs.json"); } +function exportDirectory(workspacePath, runId) { + return ensureRuntimeDir(workspacePath, path.join("exports", runId)); +} + +function runManifestPath(workspacePath, runId) { + return path.join(exportDirectory(workspacePath, runId), "manifest.json"); +} + +function cancelRequestPath(workspacePath, runId) { + return path.join(exportDirectory(workspacePath, runId), "cancel.requested.json"); +} + function readRuns(workspacePath) { - return readJson(runsPath(workspacePath), { runs: [] }) || { runs: [] }; + const index = readJson(runsPath(workspacePath), { runs: [] }) || { runs: [] }; + const runs = (Array.isArray(index.runs) ? index.runs : []).map((indexed) => { + const manifestPath = indexed.manifestPath || runManifestPath(workspacePath, indexed.runId); + let current = readJson(manifestPath, indexed) || indexed; + if (current.cancelledAt && !["completed", "failed"].includes(current.status)) { + current = { ...current, status: "cancelled", phase: "cancelled" }; + writeJsonAtomic(manifestPath, current); + } + const active = ["queued", "running", "cancelling"].includes(current.status); + const spawnLeaseExpired = current.status === "queued" + && !current.pid + && Date.now() > Number(current.spawnDeadlineAt || (current.queuedAt || 0) + EXPORT_SPAWN_LEASE_MS); + if (active && ((current.pid && !isProcessAlive(current.pid)) || spawnLeaseExpired)) { + const cancelled = fs.existsSync(cancelRequestPath(workspacePath, current.runId)); + const recovered = { + ...current, + status: cancelled ? "cancelled" : "failed", + ...(cancelled ? { cancelledAt: Date.now() } : { + failedAt: Date.now(), + error: spawnLeaseExpired + ? "The export worker was not started before its spawn lease expired." + : "The export worker stopped before producing a terminal result.", + }), + }; + writeJsonAtomic(manifestPath, recovered); + return recovered; + } + return current; + }); + return { runs }; } function writeRuns(workspacePath, runs) { - writeJson(runsPath(workspacePath), { runs }); + writeJsonAtomic(runsPath(workspacePath), { runs: runs.slice(0, EXPORT_HISTORY_LIMIT) }); } -function startExport(input = {}) { - const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const manifest = getCompositionManifest(input).manifest; - const detection = detectProject(input); - const compositionId = String(input.compositionId || input.composition || "").trim() || manifest.compositions[0]?.id; - const composition = manifest.compositions.find((item) => item.id === compositionId); - if (!composition) throw new Error(`Composition not found: ${compositionId || "(none)"}`); - if (!detection.entryPoint) throw new Error("Cannot export because no Remotion entry point was detected."); +function publishRun(workspacePath, run) { + const manifestPath = run.manifestPath || runManifestPath(workspacePath, run.runId); + const current = { ...run, manifestPath }; + writeJsonAtomic(manifestPath, current); + const runs = [current, ...readRuns(workspacePath).runs.filter((item) => item.runId !== current.runId)]; + writeRuns(workspacePath, runs); + return current; +} + +function outputExtension(codec) { + const extension = CODEC_EXTENSIONS.get(codec); + if (!extension) throw new Error(`Unsupported Remotion export codec: ${codec}`); + return extension; +} + +async function startExport(input = {}) { + const { workspacePath, projectRoot, manifest, composition } = await compositionForInput(input); const [fromRaw, toRaw] = Array.isArray(input.frameRange) ? input.frameRange : [0, composition.durationInFrames - 1]; const fromValue = Number(fromRaw); const toValue = Number(toRaw); @@ -35,58 +96,144 @@ function startExport(input = {}) { composition.durationInFrames - 1, Number.isFinite(toValue) ? toValue : composition.durationInFrames - 1, ))); - const runId = `export-${hashContent(`${composition.id}:${from}:${to}:${Date.now()}`)}`; - const outputDir = ensureRuntimeDir(workspacePath, path.join("exports", runId)); + const codec = String(input.codec || composition.defaults?.codec || "h264").toLowerCase(); + const extension = outputExtension(codec); + const runId = `export-${hashContent(`${manifest.sourceRevision}:${composition.id}:${from}:${to}:${Date.now()}:${Math.random()}`)}`; + const outputDir = exportDirectory(workspacePath, runId); + const requestedName = String(input.outputName || composition.defaults?.outName || composition.id).replace(/\.[^.]+$/, ""); + const outputPath = path.join(outputDir, `${safeFilePart(requestedName)}${extension}`); + const manifestPath = runManifestPath(workspacePath, runId); + const logPath = path.join(outputDir, "export.log"); + const configPath = path.join(outputDir, "job.json"); + const scale = clampNumber(input.scale, 0.05, 4, 1); fs.mkdirSync(outputDir, { recursive: true }); - const outputPath = path.join(outputDir, `${safeFilePart(composition.id)}.mp4`); - const args = [ - "render", - detection.entryPoint, - composition.id, - outputPath, - "--overwrite", - ]; - if (Number.isFinite(Number(input.scale))) { - args.push("--scale", String(clampNumber(input.scale, 0.05, 1, 1))); + const bundlePinPath = pinProjectBundle(manifest.bundlePath, runId); + + let run; + try { + run = publishRun(workspacePath, { + runId, + kind: "remotion-video", + status: "queued", + progress: 0, + phase: "queued", + compositionId: composition.id, + sourceRevision: manifest.sourceRevision, + projectRevision: manifest.projectRevision || manifest.sourceRevision, + descriptorRevision: composition.descriptorRevision || manifest.descriptorRevision, + frameRange: [from, to], + codec, + scale, + outputPath, + manifestPath, + logPath, + queuedAt: Date.now(), + spawnDeadlineAt: Date.now() + EXPORT_SPAWN_LEASE_MS, + }); + writeJsonAtomic(configPath, { + run, + workspacePath, + projectRoot, + bundlePath: manifest.bundlePath, + composition, + outputPath, + frameRange: [from, to], + codec, + scale, + manifestPath, + cancelPath: cancelRequestPath(workspacePath, runId), + rendererEntry: resolveProjectModule("@remotion/renderer", projectRoot, workspacePath), + noReactEntry: resolveProjectModule("remotion/no-react", projectRoot, workspacePath), + bundlePinPath, + }); + } catch (error) { + unpinProjectBundle(bundlePinPath); + if (!run) throw error; + run = publishRun(workspacePath, { + ...run, + status: "failed", + phase: "failed", + error: error instanceof Error ? error.message : String(error), + failedAt: Date.now(), + }); + return { ok: false, run }; } - if (Array.isArray(input.frameRange)) { - args.push(`--frames=${from}-${to}`); + const workerPath = path.join(__dirname, "export-job-worker.js"); + let output; + try { + output = fs.openSync(logPath, "a"); + } catch (error) { + unpinProjectBundle(bundlePinPath); + run = publishRun(workspacePath, { + ...run, + status: "failed", + phase: "failed", + error: error instanceof Error ? error.message : String(error), + failedAt: Date.now(), + }); + return { ok: false, run }; } - const renderLog = runRemotion(workspacePath, args, { timeoutMs: REMOTION_EXPORT_TIMEOUT_MS }); - const run = { - runId, - kind: "remotion-video", - status: "completed", - compositionId: composition.id, - frameRange: [from, to], - outputPath, - outputUri: fileUri(outputPath), - renderLog, - completedAt: Date.now(), - }; - const runs = [run, ...readRuns(workspacePath).runs.filter((item) => item.runId !== runId)].slice(0, 50); - writeRuns(workspacePath, runs); - writeJson(path.join(outputDir, "manifest.json"), run); - return { ok: true, ...run, manifestPath: path.join(outputDir, "manifest.json") }; + let child; + try { + child = spawn(process.execPath, [workerPath, configPath], { + cwd: projectRoot, + detached: true, + windowsHide: true, + stdio: ["ignore", output, output], + env: { + ...process.env, + NO_COLOR: "1", + FORCE_COLOR: "0", + }, + }); + child.unref(); + } catch (error) { + fs.closeSync(output); + unpinProjectBundle(bundlePinPath); + run = publishRun(workspacePath, { + ...run, + status: "failed", + phase: "failed", + error: error instanceof Error ? error.message : String(error), + failedAt: Date.now(), + }); + return { ok: false, run }; + } + fs.closeSync(output); + + run = publishRun(workspacePath, { + ...run, + status: "running", + phase: "rendering", + pid: child.pid, + startedAt: Date.now(), + }); + return { ok: true, run }; } function getExportStatus(input = {}) { const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const runId = String(input.runId || input.exportId || "").trim(); + const runId = String(input.runId || "").trim(); const runs = readRuns(workspacePath).runs; - if (!runId) return { ok: true, runs }; + writeRuns(workspacePath, runs); + if (!runId) return { ok: false, run: null, error: "runId is required." }; const run = runs.find((item) => item.runId === runId); return { ok: Boolean(run), run: run || null }; } function cancelExport(input = {}) { const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const runId = String(input.runId || input.exportId || "").trim(); + const runId = String(input.runId || "").trim(); if (!runId) return { ok: false, status: "not_found" }; - const state = readRuns(workspacePath); - const runs = state.runs.map((run) => run.runId === runId ? { ...run, status: "cancelled", cancelledAt: Date.now() } : run); - writeRuns(workspacePath, runs); - return { ok: true, run: runs.find((run) => run.runId === runId) || null }; + const run = readRuns(workspacePath).runs.find((item) => item.runId === runId); + if (!run) return { ok: false, status: "not_found" }; + if (["completed", "cancelled", "failed"].includes(run.status)) return { ok: true, run }; + + writeJsonAtomic(cancelRequestPath(workspacePath, runId), { runId, requestedAt: Date.now() }); + const cancelling = { ...run, status: "cancelling", phase: "cancelling", cancelRequestedAt: Date.now() }; + writeJsonAtomic(run.manifestPath || runManifestPath(workspacePath, runId), cancelling); + + return { ok: true, run: cancelling }; } module.exports = { diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/media.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/media.js deleted file mode 100644 index 5cfd382a..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/media.js +++ /dev/null @@ -1,48 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { safeStat } = require("./util"); -const { PREVIEW_CACHE_LIMIT } = require("./constants"); - -function mimeForPath(filePath) { - const ext = path.extname(filePath).toLowerCase(); - if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; - if (ext === ".webp") return "image/webp"; - if (ext === ".mp4") return "video/mp4"; - if (ext === ".webm") return "video/webm"; - return "image/png"; -} - -function fileDataUrl(filePath) { - const data = fs.readFileSync(filePath); - return `data:${mimeForPath(filePath)};base64,${data.toString("base64")}`; -} - -function fileUri(filePath) { - const resolved = path.resolve(filePath).replace(/\\/g, "/"); - return `file:///${resolved.replace(/^\/+/, "")}`; -} - -function pruneDirectoryFiles(dir, maxFiles = PREVIEW_CACHE_LIMIT) { - if (!fs.existsSync(dir)) return; - const entries = fs.readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isFile()) - .map((entry) => { - const filePath = path.join(dir, entry.name); - return { filePath, mtimeMs: safeStat(filePath)?.mtimeMs || 0 }; - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs); - for (const entry of entries.slice(maxFiles)) { - try { - fs.unlinkSync(entry.filePath); - } catch { - // Best-effort cache pruning only. - } - } -} - -module.exports = { - mimeForPath, - fileDataUrl, - fileUri, - pruneDirectoryFiles, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/paths.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/paths.js index 021cd7a7..4dd0e63c 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/paths.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/paths.js @@ -31,22 +31,6 @@ function ensureRuntimeDir(workspacePath, child = "") { return dir; } -function previewServerStatePath(workspacePath) { - return path.join(ensureRuntimeDir(workspacePath), "preview-server.json"); -} - -function previewServerLogPath(workspacePath) { - return path.join(ensureRuntimeDir(workspacePath, "logs"), "preview-server.log"); -} - -function previewServerLauncherPath(workspacePath) { - return path.join(ensureRuntimeDir(workspacePath, "bin"), "preview-server-launcher.js"); -} - -function previewServerLauncherConfigPath(workspacePath, port) { - return path.join(ensureRuntimeDir(workspacePath, "bin"), `preview-server-${port}.json`); -} - function playerHostStatePath(workspacePath) { return path.join(ensureRuntimeDir(workspacePath), "player-host.json"); } @@ -76,10 +60,6 @@ module.exports = { normalizeWorkspace, relativeToWorkspace, ensureRuntimeDir, - previewServerStatePath, - previewServerLogPath, - previewServerLauncherPath, - previewServerLauncherConfigPath, playerHostStatePath, playerHostRootDir, playerHostSourceDir, diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/player-host.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/player-host.js index e4161b31..404f6733 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/player-host.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/player-host.js @@ -2,9 +2,14 @@ const fs = require("node:fs"); const path = require("node:path"); const { spawn, spawnSync } = require("node:child_process"); const { playerHostStatePath, playerHostRootDir, playerHostSourceDir, playerHostDistDir, playerHostLogPath, playerHostServerPath, normalizeWorkspace, workspacePathOf } = require("./paths"); -const { hashContent, safeFilePart, sideEffectImportsForSource, relativeImport, jsString, readJson, writeJson, isProcessAlive, httpStatus, tailPlayerHostLogs, psQuote, findFreePort, clampNumber, waitForHttp, terminateProcessTree } = require("./util"); +const { hashContent, readJson, writeJson, isProcessAlive, httpStatus, tailPlayerHostLogs, psQuote, findFreePort, clampNumber, waitForHttp, terminateProcessTree } = require("./util"); const { compositionForInput } = require("./project"); -const { PLAYER_HOST_RUNTIME_VERSION, PLAYER_HOST_STALE_MS, PLAYER_HOST_BOOT_WAIT_MS } = require("./constants"); +const { RUNTIME_SCHEMA_VERSION } = require("./project-runtime"); +const { artifactRoot } = require("./artifacts"); +const { PLAYER_CONTROL_PROTOCOL_VERSION, PLAYER_HOST_RUNTIME_VERSION, PLAYER_HOST_BOOT_WAIT_MS } = require("./constants"); + +const playerHostFlights = new Map(); +const playerHostWorkspaceTails = new Map(); function moduleSearchPaths(workspacePath, projectRoot) { return Array.from(new Set([ @@ -24,59 +29,81 @@ function resolveRemotionBundlerLoader(workspacePath, projectRoot) { return path.join(path.dirname(packagePath), "dist", "esbuild-loader", "index.js"); } -function playerHostBundleId(manifest, composition) { +function playerHostBundleId(manifest) { return hashContent(JSON.stringify({ runtimeVersion: PLAYER_HOST_RUNTIME_VERSION, + protocolVersion: PLAYER_CONTROL_PROTOCOL_VERSION, buildId: manifest.buildId, - compositionId: composition.id, - componentPath: composition.componentPath, - componentName: composition.componentName, - defaultProps: composition.defaultProps || {}, - width: composition.width, - height: composition.height, - fps: composition.fps, - durationInFrames: composition.durationInFrames, + entryPoint: manifest.entryPoint, })); } -function writePlayerHostEntry(workspacePath, manifest, composition, bundleId) { +function playerHostProjectEntryPath(workspacePath, manifest) { + const workspaceRoot = path.resolve(workspacePath); + const relativeEntry = String(manifest?.entryPoint || "").trim(); + const projectEntryPath = relativeEntry ? path.resolve(workspaceRoot, relativeEntry) : null; + const relative = projectEntryPath ? path.relative(workspaceRoot, projectEntryPath) : ""; + if ( + !projectEntryPath + || relative.startsWith("..") + || path.isAbsolute(relative) + || !fs.existsSync(projectEntryPath) + || !fs.statSync(projectEntryPath).isFile() + ) { + throw new Error(`Cannot build Player preview because the Remotion entry point was not found: ${relativeEntry || "(missing)"}`); + } + return projectEntryPath; +} + +function playerHostWebpackEntries(workspacePath, manifest, hostEntryPath) { + return [ + playerHostProjectEntryPath(workspacePath, manifest), + path.resolve(hostEntryPath), + ]; +} + +function writePlayerHostEntry(workspacePath, manifest, bundleId) { const sourceDir = playerHostSourceDir(workspacePath); fs.mkdirSync(sourceDir, { recursive: true }); - const entryPath = path.join(sourceDir, `entry-${safeFilePart(composition.id)}-${bundleId}.tsx`); - const entryDir = path.dirname(entryPath); - const sourcePath = composition.sourcePath ? path.join(workspacePath, composition.sourcePath) : null; - const componentPath = composition.componentPath ? path.join(workspacePath, composition.componentPath) : null; - if (!componentPath || !fs.existsSync(componentPath)) { - throw new Error(`Cannot build Player preview because component source was not found: ${composition.componentPath || "(missing)"}`); + const entryPath = path.join(sourceDir, `entry-project-${bundleId}.tsx`); + playerHostProjectEntryPath(workspacePath, manifest); + const compositionDescriptors = (manifest.compositions || []).map((item) => ({ + id: item.id, + descriptorRevision: item.descriptorRevision, + durationInFrames: item.durationInFrames, + fps: item.fps, + width: item.width, + height: item.height, + resolvedProps: item.resolvedProps || item.defaultProps || {}, + serializedResolvedProps: item.serializedResolvedProps || null, + })); + if (!compositionDescriptors.length) { + throw new Error("Cannot build Player preview because the compiled manifest has no compositions."); } - const sourceImport = sideEffectImportsForSource(entryDir, sourcePath); - const componentImport = relativeImport(entryDir, componentPath); - const componentName = composition.componentName || "default"; - const defaultProps = JSON.stringify(composition.defaultProps || {}); const code = ` import * as React from "react"; -import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from "react"; +import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react"; import {createRoot} from "react-dom/client"; import {Player, type PlayerRef} from "@remotion/player"; -${sourceImport} -import * as ComponentModule from ${jsString(componentImport)}; - -const composition = ${JSON.stringify({ - id: composition.id, - durationInFrames: composition.durationInFrames, - fps: composition.fps, - width: composition.width, - height: composition.height, - })}; -const defaultProps = ${defaultProps}; -const componentName = ${JSON.stringify(componentName)}; -const Component = (ComponentModule as Record>)[componentName] || (ComponentModule as any).default; +import {Internals} from "remotion"; +import {NoReactInternals} from "remotion/no-react"; + const runtimeVersion = ${PLAYER_HOST_RUNTIME_VERSION}; +const protocolVersion = ${PLAYER_CONTROL_PROTOCOL_VERSION}; +const projectRevision = ${JSON.stringify(manifest.projectRevision || manifest.sourceRevision)}; const initialParams = new URLSearchParams(window.location.search); +const compositionDescriptors = ${JSON.stringify(compositionDescriptors)}; +const requestedCompositionId = initialParams.get("compositionId"); +const composition = compositionDescriptors.find((item) => item.id === requestedCompositionId) || compositionDescriptors[0]; +const resolvedProps = composition.serializedResolvedProps + ? NoReactInternals.deserializeJSONWithSpecialTypes(composition.serializedResolvedProps) + : composition.resolvedProps; const instanceId = initialParams.get("instanceId") || "default"; -const componentPath = ${JSON.stringify(composition.componentPath || null)}; +const channelNonce = initialParams.get("channelNonce") || ""; +const RegisteredRoot = Internals.getRoot(); const ELEMENT_SELECTOR = "img,video,canvas,svg,h1,h2,h3,h4,h5,h6,p,span,strong,em,small,div,section,article,main,header,footer,li,button"; const SKIP_CLASS_PATTERN = /(remotion|player|rl-player|__remotion)/i; +let controlPort: MessagePort | null = null; function clampFrame(value: unknown) { const frame = Math.round(Number(value) || 0); @@ -84,13 +111,33 @@ function clampFrame(value: unknown) { } function post(type: string, payload: Record = {}) { - window.parent?.postMessage({ + if (!controlPort) return false; + controlPort.postMessage({ ...payload, source: "sparo-remotion-player-host", runtimeVersion, + protocolVersion, type, compositionId: composition.id, + projectRevision, + descriptorRevision: composition.descriptorRevision, instanceId, + channelNonce, + }); + return true; +} + +function announceBootstrap() { + window.parent?.postMessage({ + source: "sparo-remotion-player-host", + type: "bootstrapReady", + runtimeVersion, + protocolVersion, + compositionId: composition.id, + projectRevision, + descriptorRevision: composition.descriptorRevision, + instanceId, + channelNonce, }, "*"); } @@ -185,8 +232,8 @@ function measureFrameContext(stage: HTMLElement | null, frame: number) { type: tag, label, source: "player-dom", - sourceHint: componentPath, - componentPath, + sourceHint: ${JSON.stringify(manifest.entryPoint || null)}, + componentPath: null, elementPath: path, x, y, @@ -208,32 +255,31 @@ function measureFrameContext(stage: HTMLElement | null, frame: number) { }; } -function App() { +function PlayerRuntime({Component}: {Component: React.ComponentType>}) { const playerRef = useRef(null); const stageRef = useRef(null); const cleanupRef = useRef void)>(null); const readyPostedRef = useRef(false); const frameContextRequestRef = useRef(null); + const reconcileRequestRef = useRef(null); + const latestReconcileRef = useRef | null>(null); const pendingCommandsRef = useRef>>([]); const playingRef = useRef(false); - const manualPlaybackFrameRef = useRef(null); - const manualPlaybackStartedAtRef = useRef(0); - const manualPlaybackStartFrameRef = useRef(0); + const bufferingRef = useRef(false); + const seekingRef = useRef(false); + const mutedRef = useRef(true); + const volumeRef = useRef(1); + const activeRevisionRef = useRef(-1); const initialFrame = useMemo(() => { return clampFrame(initialParams.get("frame")); }, []); const lastKnownFrameRef = useRef(initialFrame); - const shouldAutoplay = useMemo(() => { - return initialParams.get("autoplay") === "1"; - }, []); const currentFrame = useCallback(() => { const actual = playerRef.current?.getCurrentFrame?.(); const frame = Number.isFinite(Number(actual)) ? clampFrame(actual) : clampFrame(lastKnownFrameRef.current); - if (playingRef.current || manualPlaybackFrameRef.current !== null) { - lastKnownFrameRef.current = frame; - } - return playingRef.current ? frame : clampFrame(lastKnownFrameRef.current); + lastKnownFrameRef.current = frame; + return frame; }, []); const postFrameContext = useCallback((frame: number) => { @@ -252,6 +298,11 @@ function App() { requestId, frame, playing: playingRef.current, + buffering: bufferingRef.current, + seeking: seekingRef.current, + muted: mutedRef.current, + volume: volumeRef.current, + revision: activeRevisionRef.current, durationInFrames: composition.durationInFrames, fps: composition.fps, width: composition.width, @@ -260,261 +311,278 @@ function App() { }); }, [currentFrame]); - const stopManualPlayback = useCallback(() => { - if (manualPlaybackFrameRef.current === null) return; - window.clearTimeout(manualPlaybackFrameRef.current); - manualPlaybackFrameRef.current = null; - }, []); - - const postFrame = useCallback((frame: unknown, type = "frame") => { + const actualPayload = useCallback((frame: unknown = currentFrame()) => { const next = clampFrame(frame); lastKnownFrameRef.current = next; - post(type, {frame: next, playing: playingRef.current}); - postFrameContext(next); - return next; - }, [postFrameContext]); - - const forcePausePlayer = useCallback((player: PlayerRef | null = playerRef.current) => { - if (!player) return; - player.pause(); - [0, 80, 220].forEach((delay) => { - window.setTimeout(() => { - if (!playingRef.current) player.pause(); - }, delay); - }); - }, []); + return { + frame: next, + playing: playingRef.current, + buffering: bufferingRef.current, + seeking: seekingRef.current, + muted: mutedRef.current, + volume: volumeRef.current, + revision: activeRevisionRef.current, + }; + }, [currentFrame]); - const startManualPlayback = useCallback((fromFrame: number) => { - const player = playerRef.current; - if (!player) return; - stopManualPlayback(); - player.pause(); - manualPlaybackStartFrameRef.current = clampFrame(fromFrame); - lastKnownFrameRef.current = manualPlaybackStartFrameRef.current; - manualPlaybackStartedAtRef.current = performance.now(); - - const tick = () => { - const currentPlayer = playerRef.current; - if (!playingRef.current || !currentPlayer) { - manualPlaybackFrameRef.current = null; - return; - } + const postActualState = useCallback((type = "actualState", frame?: unknown) => { + post(type, actualPayload(frame)); + }, [actualPayload]); - const elapsedFrames = Math.floor(((performance.now() - manualPlaybackStartedAtRef.current) / 1000) * composition.fps); - const nextFrame = clampFrame(manualPlaybackStartFrameRef.current + elapsedFrames); - if (currentPlayer.isPlaying?.()) currentPlayer.pause(); - currentPlayer.seekTo(nextFrame); - postFrame(nextFrame); + const applyDesiredState = useCallback((message: Record) => { + const player = playerRef.current; + const desired = message.desired as Record | undefined; + const revision = Number(message.revision ?? desired?.revision); + if (!player || !desired || !Number.isInteger(revision) || revision < 0) return; - if (nextFrame >= composition.durationInFrames - 1) { - playingRef.current = false; - stopManualPlayback(); - forcePausePlayer(currentPlayer); - post("ended", {frame: nextFrame, playing: false}); - postFrameContext(nextFrame); - return; - } + if (revision < activeRevisionRef.current) { + post("commandAccepted", { + commandId: message.commandId, + command: message.command, + revision, + accepted: false, + reason: "stale-revision", + }); + postActualState(); + return; + } - const delay = Math.max(16, Math.round(1000 / Math.min(60, Number(composition.fps) || 30))); - manualPlaybackFrameRef.current = window.setTimeout(tick, delay); - }; + activeRevisionRef.current = revision; + post("commandAccepted", { + commandId: message.commandId, + command: message.command, + revision, + accepted: true, + }); - manualPlaybackFrameRef.current = window.setTimeout(tick, 0); - }, [forcePausePlayer, postFrame, postFrameContext, stopManualPlayback]); + const nextVolume = Math.max(0, Math.min(1, Number(desired.volume) || 0)); + const nextMuted = desired.muted !== false; + player.setVolume?.(nextVolume); + if (nextMuted) player.mute?.(); + else player.unmute?.(); + volumeRef.current = player.getVolume?.() ?? nextVolume; + mutedRef.current = player.isMuted?.() ?? nextMuted; - const seekTo = useCallback((frame: unknown) => { - const next = clampFrame(frame); - lastKnownFrameRef.current = next; - const player = playerRef.current; - if (!playingRef.current) { - stopManualPlayback(); - forcePausePlayer(player); - } - player?.seekTo(next); - if (!playingRef.current) { - forcePausePlayer(player); - } else if (manualPlaybackFrameRef.current !== null) { - manualPlaybackStartFrameRef.current = next; - manualPlaybackStartedAtRef.current = performance.now(); + const nextFrame = clampFrame(desired.frame); + if (Math.abs(currentFrame() - nextFrame) > 0) { + seekingRef.current = true; + player.seekTo(nextFrame); } - postFrame(next); - return next; - }, [forcePausePlayer, postFrame, stopManualPlayback]); - const play = useCallback((frame?: unknown) => { - const player = playerRef.current; - const startFrame = frame !== undefined && frame !== null ? seekTo(frame) : currentFrame(); - playingRef.current = true; - const result = player?.play(); - if (player) { - const nextFrame = clampFrame(player.getCurrentFrame()); - post("play", {frame: nextFrame, playing: true}); - postFrameContext(nextFrame); - window.setTimeout(() => { - if (!playingRef.current) return; - const current = clampFrame(player.getCurrentFrame()); - if (current <= nextFrame + 1) { - startManualPlayback(Math.max(startFrame, current)); - } - }, 450); + if (desired.playing === true) { + try { + player.play(); + } catch (error) { + post("error", {message: error instanceof Error ? error.message : String(error), revision}); + } } else { - startManualPlayback(startFrame); + player.pause(); } - if (result && typeof (result as Promise).catch === "function") { - (result as Promise).catch((error) => { - playingRef.current = false; - stopManualPlayback(); - post("error", {message: error instanceof Error ? error.message : String(error)}); - }); - } - }, [currentFrame, postFrameContext, seekTo, startManualPlayback, stopManualPlayback]); - const pause = useCallback(() => { - const player = playerRef.current; - playingRef.current = false; - stopManualPlayback(); - forcePausePlayer(player); - if (player) { - const nextFrame = clampFrame(player.getCurrentFrame()); - post("pause", {frame: nextFrame, playing: false}); - postFrameContext(nextFrame); + window.requestAnimationFrame(() => postActualState()); + }, [currentFrame, postActualState]); + + const flushLatestReconcile = useCallback(() => { + if (reconcileRequestRef.current !== null) { + window.cancelAnimationFrame(reconcileRequestRef.current); + reconcileRequestRef.current = null; } - }, [forcePausePlayer, postFrameContext, stopManualPlayback]); + const message = latestReconcileRef.current; + latestReconcileRef.current = null; + if (message) applyDesiredState(message); + }, [applyDesiredState]); const runCommand = useCallback((message: Record) => { if (message.type === "snapshot") { + flushLatestReconcile(); postSnapshot(message.requestId); return; } - let commandFrame: number | null = null; - if (message.type === "seek") commandFrame = seekTo(message.frame); - if (message.type === "play") { - play(message.frame); - commandFrame = currentFrame(); - } - if (message.type === "pause") { - pause(); - commandFrame = currentFrame(); + if (message.type === "frameContext") { + flushLatestReconcile(); + postFrameContext(currentFrame()); + return; } - if (message.type === "toggle") { - if (playingRef.current) pause(); - else play(message.frame); - commandFrame = currentFrame(); + if (message.type === "reconcile") { + latestReconcileRef.current = message; + if (reconcileRequestRef.current === null) { + reconcileRequestRef.current = window.requestAnimationFrame(() => { + reconcileRequestRef.current = null; + const latest = latestReconcileRef.current; + latestReconcileRef.current = null; + if (latest) applyDesiredState(latest); + }); + } } - const nextFrame = commandFrame ?? currentFrame(); - post("command", { - commandId: message.commandId, - command: message.type, - accepted: true, - frame: nextFrame, - playing: playingRef.current, - }); - postFrameContext(nextFrame); - }, [currentFrame, pause, play, postFrameContext, postSnapshot, seekTo]); + }, [applyDesiredState, currentFrame, flushLatestReconcile, postFrameContext, postSnapshot]); const ensurePlayerReady = useCallback(() => { const player = playerRef.current; if (!player) return false; if (!cleanupRef.current) { - const onFrame = (event: Event) => { - const detailFrame = (event as CustomEvent<{frame?: number}>).detail?.frame; - const frame = detailFrame ?? player.getCurrentFrame(); - const nextFrame = clampFrame(frame); - if (!playingRef.current && player.isPlaying?.()) forcePausePlayer(player); - post("frame", {frame: nextFrame, playing: playingRef.current}); - postFrameContext(nextFrame); - }; - const onPlay = () => { - if (!playingRef.current) { - forcePausePlayer(player); - return; - } - playingRef.current = true; - const nextFrame = clampFrame(player.getCurrentFrame()); - post("play", {frame: nextFrame, playing: true}); - postFrameContext(nextFrame); - }; - const onPause = () => { - if (playingRef.current) return; - playingRef.current = false; - const nextFrame = clampFrame(player.getCurrentFrame()); - post("pause", {frame: nextFrame, playing: false}); - postFrameContext(nextFrame); - }; - const onEnded = () => { - playingRef.current = false; - stopManualPlayback(); - post("ended", {frame: composition.durationInFrames - 1, playing: false}); - }; - const onError = (event: Event) => { - const error = (event as CustomEvent<{error?: Error}>).detail?.error; - post("error", {message: error instanceof Error ? error.message : String(error || "Player error")}); - }; - player.addEventListener("timeupdate", onFrame as any); - player.addEventListener("frameupdate", onFrame as any); - player.addEventListener("seeked", onFrame as any); - player.addEventListener("play", onPlay as any); - player.addEventListener("pause", onPause as any); - player.addEventListener("ended", onEnded as any); - player.addEventListener("error", onError as any); + const onFrame = (event: Event) => { + const detailFrame = (event as CustomEvent<{frame?: number}>).detail?.frame; + postActualState("frameCommitted", detailFrame ?? player.getCurrentFrame()); + }; + const onSeeked = () => { + seekingRef.current = false; + const frame = clampFrame(player.getCurrentFrame()); + postActualState("seekSettled", frame); + postFrameContext(frame); + }; + const onPlay = () => { + playingRef.current = true; + bufferingRef.current = false; + postActualState("playing"); + }; + const onPause = () => { + playingRef.current = false; + const frame = clampFrame(player.getCurrentFrame()); + postActualState("paused", frame); + postFrameContext(frame); + }; + const onWaiting = () => { + bufferingRef.current = true; + postActualState("buffering"); + }; + const onResume = () => { + bufferingRef.current = false; + postActualState("buffering"); + }; + const onVolumeChange = () => { + mutedRef.current = player.isMuted?.() ?? mutedRef.current; + volumeRef.current = player.getVolume?.() ?? volumeRef.current; + postActualState(); + }; + const onMuteChange = () => { + mutedRef.current = player.isMuted?.() ?? mutedRef.current; + postActualState(); + }; + const onEnded = () => { + playingRef.current = false; + bufferingRef.current = false; + seekingRef.current = false; + postActualState("ended", composition.durationInFrames - 1); + postFrameContext(composition.durationInFrames - 1); + }; + const onError = (event: Event) => { + const error = (event as CustomEvent<{error?: Error}>).detail?.error; + post("error", { + message: error instanceof Error ? error.message : String(error || "Player error"), + revision: activeRevisionRef.current, + }); + }; + player.addEventListener("frameupdate", onFrame as any); + player.addEventListener("seeked", onSeeked as any); + player.addEventListener("play", onPlay as any); + player.addEventListener("pause", onPause as any); + player.addEventListener("waiting", onWaiting as any); + player.addEventListener("resume", onResume as any); + player.addEventListener("volumechange", onVolumeChange as any); + player.addEventListener("mutechange", onMuteChange as any); + player.addEventListener("ended", onEnded as any); + player.addEventListener("error", onError as any); cleanupRef.current = () => { - player.removeEventListener("timeupdate", onFrame as any); - player.removeEventListener("frameupdate", onFrame as any); - player.removeEventListener("seeked", onFrame as any); - player.removeEventListener("play", onPlay as any); - player.removeEventListener("pause", onPause as any); - player.removeEventListener("ended", onEnded as any); - player.removeEventListener("error", onError as any); - }; + player.removeEventListener("frameupdate", onFrame as any); + player.removeEventListener("seeked", onSeeked as any); + player.removeEventListener("play", onPlay as any); + player.removeEventListener("pause", onPause as any); + player.removeEventListener("waiting", onWaiting as any); + player.removeEventListener("resume", onResume as any); + player.removeEventListener("volumechange", onVolumeChange as any); + player.removeEventListener("mutechange", onMuteChange as any); + player.removeEventListener("ended", onEnded as any); + player.removeEventListener("error", onError as any); + }; } const frame = clampFrame(player.getCurrentFrame?.() ?? initialFrame); if (!readyPostedRef.current) { - readyPostedRef.current = true; - post("ready", { + const posted = post("ready", { + ...actualPayload(frame), frame, durationInFrames: composition.durationInFrames, fps: composition.fps, width: composition.width, height: composition.height, - playing: playingRef.current, }); - postFrameContext(frame); - if (shouldAutoplay) { - window.setTimeout(() => play(frame), 0); + if (posted) { + readyPostedRef.current = true; + postFrameContext(frame); + window.requestAnimationFrame(() => { + postActualState("frameCommitted", clampFrame(player.getCurrentFrame())); + }); } - } else { - postFrameContext(frame); } const pending = pendingCommandsRef.current.splice(0); pending.forEach(runCommand); return true; - }, [initialFrame, play, postFrameContext, runCommand, shouldAutoplay]); + }, [actualPayload, initialFrame, postActualState, postFrameContext, runCommand]); useLayoutEffect(() => { postFrameContext(clampFrame(playerRef.current?.getCurrentFrame?.() ?? initialFrame)); }, [initialFrame, postFrameContext]); useEffect(() => { - const onMessage = (event: MessageEvent) => { + const onConnect = (event: MessageEvent) => { const message = event.data || {}; if (message.source !== "sparo-remotion-live") return; - if (message.compositionId && message.compositionId !== composition.id) return; - if (message.instanceId && message.instanceId !== instanceId) return; - if (message.type === "ping") { - ensurePlayerReady(); - return; - } - if (!ensurePlayerReady()) { - pendingCommandsRef.current.push(message); + if (message.type !== "connect") return; + if (message.protocolVersion !== protocolVersion) return; + if (message.compositionId !== composition.id) return; + if (message.projectRevision !== projectRevision) return; + if (message.descriptorRevision !== composition.descriptorRevision) return; + if (message.instanceId !== instanceId) return; + if (!channelNonce || message.channelNonce !== channelNonce) return; + const port = event.ports?.[0]; + if (!port) return; + + // Nested opaque frames in WebView2 may not preserve WindowProxy object + // identity across postMessage. Authenticate the one-time connection with + // the URL-bound nonce plus the complete immutable preview identity; the + // transferred MessagePort is the capability used after this handshake. + + controlPort?.close(); + controlPort = port; + controlPort.onmessage = (portEvent) => { + const command = portEvent.data || {}; + if (command.source !== "sparo-remotion-live") return; + if (command.protocolVersion !== protocolVersion) return; + if (command.compositionId !== composition.id) return; + if (command.projectRevision !== projectRevision) return; + if (command.descriptorRevision !== composition.descriptorRevision) return; + if (command.instanceId !== instanceId) return; + if (command.channelNonce !== channelNonce) return; + if (!ensurePlayerReady()) { + if (command.type === "reconcile") { + pendingCommandsRef.current = pendingCommandsRef.current.filter((item) => item.type !== "reconcile"); + } + pendingCommandsRef.current.push(command); + return; + } + runCommand(command); + }; + controlPort.start(); + post("channelReady", {revision: activeRevisionRef.current}); + ensurePlayerReady(); + }; + window.addEventListener("message", onConnect); + announceBootstrap(); + const bootstrapTimer = window.setInterval(() => { + if (controlPort) { + window.clearInterval(bootstrapTimer); return; } - runCommand(message); + announceBootstrap(); + }, 250); + return () => { + window.clearInterval(bootstrapTimer); + window.removeEventListener("message", onConnect); + controlPort?.close(); + controlPort = null; }; - window.addEventListener("message", onMessage); - return () => window.removeEventListener("message", onMessage); }, [ensurePlayerReady, runCommand]); useEffect(() => { @@ -534,16 +602,14 @@ function App() { window.cancelAnimationFrame(frameContextRequestRef.current); frameContextRequestRef.current = null; } - stopManualPlayback(); + if (reconcileRequestRef.current !== null) { + window.cancelAnimationFrame(reconcileRequestRef.current); + reconcileRequestRef.current = null; + } cleanupRef.current?.(); cleanupRef.current = null; }; - }, [ensurePlayerReady, stopManualPlayback]); - - if (!Component) { - post("error", {message: "Composition component could not be resolved."}); - return
Composition component could not be resolved.
; - } + }, [ensurePlayerReady]); return (
@@ -554,7 +620,7 @@ function App() { fps={composition.fps} compositionWidth={composition.width} compositionHeight={composition.height} - inputProps={defaultProps} + inputProps={resolvedProps} initialFrame={initialFrame} controls={false} clickToPlay={false} @@ -568,6 +634,52 @@ function App() { ); } +function RegisteredPlayer() { + const [registeredComposition, setRegisteredComposition] = useState>}>(null); + + useEffect(() => { + let attempts = 0; + const findComposition = () => { + attempts += 1; + const selected = Internals.compositionsRef.current?.getCompositions().find((item) => item.id === composition.id); + if (selected?.component) { + setRegisteredComposition({component: selected.component as React.ComponentType>}); + return true; + } + if (attempts >= 400) { + post("error", {message: "Timed out waiting for the registered Remotion composition."}); + return true; + } + return false; + }; + if (findComposition()) return; + const timer = window.setInterval(() => { + if (findComposition()) window.clearInterval(timer); + }, 25); + return () => window.clearInterval(timer); + }, []); + + if (!registeredComposition) return
Loading composition...
; + return ; +} + +function App() { + if (!RegisteredRoot) { + return
Remotion root was not registered by the project entry point.
; + } + return ( + + + + + ); +} + const root = createRoot(document.getElementById("root")!); root.render(); `; @@ -593,12 +705,12 @@ async function runWebpackBuild(webpack, config) { }); } -async function bundlePlayerHost(workspacePath, projectRoot, manifest, composition, bundleId) { +async function bundlePlayerHost(workspacePath, projectRoot, manifest, bundleId) { const moduleRoot = projectRoot || workspacePath; const distDir = playerHostDistDir(workspacePath); fs.rmSync(distDir, { recursive: true, force: true }); fs.mkdirSync(distDir, { recursive: true }); - const entryPath = writePlayerHostEntry(workspacePath, manifest, composition, bundleId); + const entryPath = writePlayerHostEntry(workspacePath, manifest, bundleId); const webpack = require(resolveWorkspaceModule(workspacePath, "webpack", moduleRoot)); const esbuild = require(resolveWorkspaceModule(workspacePath, "esbuild", moduleRoot)); const esbuildLoaderPath = resolveRemotionBundlerLoader(workspacePath, moduleRoot); @@ -606,8 +718,12 @@ async function bundlePlayerHost(workspacePath, projectRoot, manifest, compositio mode: "development", target: "web", context: moduleRoot, - entry: entryPath, - devtool: "source-map", + // Remotion's entry point is intentionally side-effectful: registerRoot() is + // its public contract. Make it a real, ordered Webpack entry so package-level + // `sideEffects` metadata cannot tree-shake the registration before the host + // reads Internals.getRoot(). + entry: playerHostWebpackEntries(workspacePath, manifest, entryPath), + devtool: false, output: { path: distDir, filename: "player-host.js", @@ -623,15 +739,15 @@ async function bundlePlayerHost(workspacePath, projectRoot, manifest, compositio "node_modules", ], alias: { - "react/jsx-runtime": resolveWorkspaceModule(workspacePath, "react/jsx-runtime", moduleRoot), - "react/jsx-dev-runtime": resolveWorkspaceModule(workspacePath, "react/jsx-dev-runtime", moduleRoot), - "react-dom/client": resolveWorkspaceModule(workspacePath, "react-dom/client", moduleRoot), - "react-dom": resolveWorkspaceModule(workspacePath, "react-dom", moduleRoot), - react: resolveWorkspaceModule(workspacePath, "react", moduleRoot), - "@remotion/player": resolveWorkspaceModule(workspacePath, "@remotion/player", moduleRoot), - "remotion/no-react": path.resolve(resolveWorkspaceModule(workspacePath, "remotion", moduleRoot), "..", "..", "esm", "no-react.mjs"), - "remotion/version": path.resolve(resolveWorkspaceModule(workspacePath, "remotion", moduleRoot), "..", "..", "esm", "version.mjs"), - remotion: path.resolve(resolveWorkspaceModule(workspacePath, "remotion", moduleRoot), "..", "..", "esm", "index.mjs"), + "react/jsx-runtime$": resolveWorkspaceModule(workspacePath, "react/jsx-runtime", moduleRoot), + "react/jsx-dev-runtime$": resolveWorkspaceModule(workspacePath, "react/jsx-dev-runtime", moduleRoot), + "react-dom/client$": resolveWorkspaceModule(workspacePath, "react-dom/client", moduleRoot), + "react-dom$": resolveWorkspaceModule(workspacePath, "react-dom", moduleRoot), + "react$": resolveWorkspaceModule(workspacePath, "react", moduleRoot), + "@remotion/player$": resolveWorkspaceModule(workspacePath, "@remotion/player", moduleRoot), + "remotion/no-react$": path.resolve(resolveWorkspaceModule(workspacePath, "remotion", moduleRoot), "..", "..", "esm", "no-react.mjs"), + "remotion/version$": path.resolve(resolveWorkspaceModule(workspacePath, "remotion", moduleRoot), "..", "..", "esm", "version.mjs"), + "remotion$": path.resolve(resolveWorkspaceModule(workspacePath, "remotion", moduleRoot), "..", "..", "esm", "index.mjs"), }, }, module: { @@ -697,6 +813,7 @@ async function bundlePlayerHost(workspacePath, projectRoot, manifest, compositio + "# } + +#[cfg(test)] +mod tests { + use super::build_bridge_script; + + fn bridge_script() -> String { + build_bridge_script( + "app-id", + "C:/app-data", + "C:/workspace", + "dark", + "windows", + "{}", + "source-revision", + "deps-revision", + false, + false, + ) + } + + #[test] + fn bridge_exposes_panel_mode_request() { + let script = bridge_script(); + + assert!(script.contains("setPanelMode")); + assert!(script.contains("host.setPanelMode")); + } + + #[test] + fn bridge_restricts_host_events_and_correlates_rehearsal_responses() { + let script = bridge_script(); + + assert!(script.contains("e.source === window.parent")); + assert!(script.contains("_parentOriginFromReferrer")); + assert!(script.contains("requestId: plan")); + } +} diff --git a/src/crates/core/src/product_app_runtime_host_engine/runtime_ui_kit.rs b/src/crates/core/src/product_app_runtime_host_engine/runtime_ui_kit.rs index b74389f8..ccfd3605 100644 --- a/src/crates/core/src/product_app_runtime_host_engine/runtime_ui_kit.rs +++ b/src/crates/core/src/product_app_runtime_host_engine/runtime_ui_kit.rs @@ -8,6 +8,7 @@ /// Names exported by `window.app.ui`. pub const RUNTIME_UI_KIT_COMPONENTS: &[&str] = &[ "Button", + "IconButton", "Card", "CardHeader", "CardBody", @@ -18,6 +19,10 @@ pub const RUNTIME_UI_KIT_COMPONENTS: &[&str] = &[ "Empty", "Stack", "Toolbar", + "Spinner", + "Status", + "SegmentedControl", + "Dialog", ]; /// CSS for the runtime UI kit. The class names intentionally mirror a small @@ -230,6 +235,152 @@ pub fn build_runtime_ui_kit_css() -> &'static str { to { transform: rotate(360deg); } } +.bfui-spinner { + display: inline-flex; + align-items: center; + gap: var(--size-gap-2); + color: var(--color-text-muted); + font-size: var(--font-size-xs); +} + +.bfui-spinner__mark { + width: var(--bfui-spinner-size, 16px); + height: var(--bfui-spinner-size, 16px); + border: 2px solid color-mix(in srgb, currentColor 22%, transparent); + border-top-color: currentColor; + border-radius: 50%; + animation: bfui-spin 0.8s linear infinite; +} + +.bfui-status { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--color-text-muted); + font-size: var(--font-size-xs); +} + +.bfui-status__dot { + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 50%; + background: currentColor; +} + +.bfui-status--success { color: var(--color-success); } +.bfui-status--warning { color: var(--color-warning); } +.bfui-status--error { color: var(--color-error); } +.bfui-status--info { color: var(--color-info); } +.bfui-status--accent { color: var(--color-accent-500); } + +.bfui-segmented { + display: inline-flex; + align-items: center; + gap: 2px; + min-height: 32px; + padding: 2px; + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-sm); + background: var(--element-bg-soft); +} + +.bfui-segmented__item { + min-height: 26px; + padding: 0 10px; + border: 0; + border-radius: calc(var(--size-radius-sm) - 2px); + background: transparent; + color: var(--color-text-muted); + font: inherit; + font-size: var(--font-size-xs); + cursor: pointer; +} + +.bfui-segmented__item:hover:not(:disabled) { + color: var(--color-text-primary); +} + +.bfui-segmented__item[aria-pressed="true"] { + background: var(--element-bg-medium); + color: var(--color-text-primary); +} + +.bfui-segmented__item:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-accent-500) 55%, transparent); + outline-offset: 1px; +} + +.bfui-dialog { + width: min(var(--bfui-dialog-width, 440px), calc(100vw - 32px)); + max-height: min(720px, calc(100vh - 32px)); + padding: 0; + border: 1px solid var(--border-base); + border-radius: var(--size-radius-lg); + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + box-shadow: 0 24px 72px color-mix(in srgb, #000 52%, transparent); + overflow: hidden; +} + +.bfui-dialog::backdrop { + background: color-mix(in srgb, #000 58%, transparent); + backdrop-filter: blur(3px); +} + +.bfui-dialog__header, +.bfui-dialog__body, +.bfui-dialog__footer { + padding: var(--size-gap-4); +} + +.bfui-dialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--size-gap-3); + border-bottom: 1px solid var(--border-subtle); +} + +.bfui-dialog__title { + color: var(--color-text-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); +} + +.bfui-dialog__description { + margin-top: var(--size-gap-1); + color: var(--color-text-muted); + font-size: var(--font-size-xs); +} + +.bfui-dialog__body { + overflow: auto; + font-size: var(--font-size-sm); + line-height: var(--line-height-relaxed); +} + +.bfui-dialog__footer { + display: flex; + justify-content: flex-end; + gap: var(--size-gap-2); + border-top: 1px solid var(--border-subtle); +} + +@media (prefers-reduced-motion: reduce) { + .btn, + .v-card, + .sparo-input-container { + transition: none; + } + + .btn-loading-icon, + .bfui-spinner__mark { + animation-duration: 1.8s; + } +} + .v-card { display: flex; flex-direction: column; @@ -488,6 +639,7 @@ pub fn build_runtime_ui_kit_script() -> &'static str { (function () { const COMPONENTS = Object.freeze([ 'Button', + 'IconButton', 'Card', 'CardHeader', 'CardBody', @@ -498,6 +650,10 @@ pub fn build_runtime_ui_kit_script() -> &'static str { 'Empty', 'Stack', 'Toolbar', + 'Spinner', + 'Status', + 'SegmentedControl', + 'Dialog', ]); const sizeClass = { small: 'sm', medium: 'base', large: 'lg' }; @@ -577,6 +733,16 @@ pub fn build_runtime_ui_kit_script() -> &'static str { return node; } + function IconButton(options = {}) { + return Button({ + ...options, + iconOnly: true, + variant: options.variant || 'ghost', + children: options.icon ?? options.children, + ariaLabel: options.ariaLabel || options.title, + }); + } + function Card(options = {}) { return createElement('div', { className: classes([ @@ -692,6 +858,115 @@ pub fn build_runtime_ui_kit_script() -> &'static str { return createElement('div', { className: classes(['bfui-toolbar', options.className]) }, options.children); } + function Spinner(options = {}) { + const mark = createElement('span', { className: 'bfui-spinner__mark', 'aria-hidden': 'true' }); + if (options.size != null) { + mark.style.setProperty('--bfui-spinner-size', typeof options.size === 'number' ? options.size + 'px' : String(options.size)); + } + return createElement('span', { + className: classes(['bfui-spinner', options.className]), + role: 'status', + 'aria-live': options.live || 'polite', + 'aria-label': options.ariaLabel || options.label || 'Loading', + }, [mark, options.label ? createElement('span', null, options.label) : null]); + } + + function Status(options = {}) { + return createElement('span', { + className: classes(['bfui-status', 'bfui-status--' + (options.variant || 'neutral'), options.className]), + role: options.role || 'status', + 'aria-live': options.live || 'polite', + }, [ + createElement('span', { className: 'bfui-status__dot', 'aria-hidden': 'true' }), + createElement('span', { className: 'bfui-status__label' }, options.label || options.children || ''), + ]); + } + + function SegmentedControl(options = {}) { + const items = Array.isArray(options.items) ? options.items : []; + const root = createElement('div', { + className: classes(['bfui-segmented', options.className]), + role: 'group', + 'aria-label': options.ariaLabel, + }); + const buttons = items.map((item) => { + const value = typeof item === 'object' ? item.value : item; + const label = typeof item === 'object' ? item.label : item; + const button = createElement('button', { + type: 'button', + className: 'bfui-segmented__item', + disabled: typeof item === 'object' && item.disabled, + 'aria-pressed': value === options.value ? 'true' : 'false', + dataset: { value: String(value) }, + onClick: () => options.onChange?.(value), + }, label); + root.appendChild(button); + return button; + }); + root.addEventListener('keydown', (event) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + const enabled = buttons.filter((button) => !button.disabled); + if (!enabled.length) return; + const activeIndex = Math.max(0, enabled.indexOf(document.activeElement)); + const nextIndex = event.key === 'Home' + ? 0 + : event.key === 'End' + ? enabled.length - 1 + : (activeIndex + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length; + event.preventDefault(); + enabled[nextIndex].focus(); + enabled[nextIndex].click(); + }); + return root; + } + + function Dialog(options = {}) { + const previousFocus = document.activeElement; + const titleId = 'bfui-dialog-title-' + Math.random().toString(36).slice(2); + const descriptionId = options.description ? titleId + '-description' : null; + const dialog = createElement('dialog', { + className: classes(['bfui-dialog', options.className]), + 'aria-labelledby': titleId, + 'aria-describedby': descriptionId, + }); + if (options.width) dialog.style.setProperty('--bfui-dialog-width', typeof options.width === 'number' ? options.width + 'px' : String(options.width)); + const close = (returnValue = '') => { + if (dialog.open) dialog.close(returnValue); + }; + const header = createElement('div', { className: 'bfui-dialog__header' }, [ + createElement('div', null, [ + createElement('div', { className: 'bfui-dialog__title', id: titleId }, options.title || ''), + options.description ? createElement('div', { className: 'bfui-dialog__description', id: descriptionId }, options.description) : null, + ]), + options.dismissible === false ? null : IconButton({ icon: '×', ariaLabel: options.closeLabel || 'Close', onClick: () => close('cancel') }), + ]); + dialog.appendChild(header); + dialog.appendChild(createElement('div', { className: 'bfui-dialog__body' }, options.children)); + if (options.actions) dialog.appendChild(createElement('div', { className: 'bfui-dialog__footer' }, options.actions)); + dialog.addEventListener('cancel', (event) => { + if (options.dismissible === false) event.preventDefault(); + }); + dialog.addEventListener('click', (event) => { + if (options.closeOnBackdrop !== false && event.target === dialog) close('cancel'); + }); + dialog.addEventListener('close', () => { + options.onClose?.(dialog.returnValue); + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + }); + dialog.openDialog = () => { + if (!dialog.isConnected) document.body.appendChild(dialog); + if (!dialog.open) dialog.showModal(); + requestAnimationFrame(() => { + const initial = options.initialFocus || dialog.querySelector('[autofocus], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'); + initial?.focus?.(); + }); + return dialog; + }; + dialog.closeDialog = close; + if (options.open !== false) queueMicrotask(() => dialog.openDialog()); + return dialog; + } + function mount(target, child) { const root = typeof target === 'string' ? document.querySelector(target) : target; if (!root) throw new Error('Product App Runtime Host UI Kit mount target not found'); @@ -706,6 +981,7 @@ pub fn build_runtime_ui_kit_script() -> &'static str { createElement, mount, Button, + IconButton, Card, CardHeader, CardBody, @@ -716,6 +992,10 @@ pub fn build_runtime_ui_kit_script() -> &'static str { Empty, Stack, Toolbar, + Spinner, + Status, + SegmentedControl, + Dialog, }); window.BitfunProductAppRuntimeHostSurfaceUI = ui; @@ -733,7 +1013,19 @@ mod tests { #[test] fn runtime_ui_kit_exports_expected_subset() { for component in [ - "Button", "Card", "Input", "Badge", "Alert", "Empty", "Stack", "Toolbar", + "Button", + "IconButton", + "Card", + "Input", + "Badge", + "Alert", + "Empty", + "Stack", + "Toolbar", + "Spinner", + "Status", + "SegmentedControl", + "Dialog", ] { assert!(RUNTIME_UI_KIT_COMPONENTS.contains(&component)); } @@ -754,5 +1046,7 @@ mod tests { assert!(script.contains("window.app.ui = ui")); assert!(script.contains("Button")); assert!(script.contains("Card")); + assert!(script.contains("SegmentedControl")); + assert!(script.contains("Dialog")); } } diff --git a/src/crates/core/src/product_app_runtime_host_engine/storage.rs b/src/crates/core/src/product_app_runtime_host_engine/storage.rs index e6201b0d..fc9b7356 100644 --- a/src/crates/core/src/product_app_runtime_host_engine/storage.rs +++ b/src/crates/core/src/product_app_runtime_host_engine/storage.rs @@ -7,8 +7,11 @@ use crate::product_app_runtime_host_engine::types::{ ProductAppRuntimeHostSurface, ProductAppRuntimeHostSurfaceMeta, }; use serde_json; -use std::path::PathBuf; +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use tokio::sync::RwLock; const META_JSON: &str = "meta.json"; const SOURCE_DIR: &str = "source"; @@ -32,6 +35,7 @@ const STANDARD_SOURCE_FILES: &[&str] = &[ I18N_JSON, SOURCE_MANIFEST_JSON, ]; +static SOURCE_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(1); fn infer_source_file_kind(path: &str) -> ProductAppRuntimeHostSourceFileKind { if path.ends_with(".js") || path.ends_with(".mjs") || path.ends_with(".ts") { @@ -49,27 +53,50 @@ fn infer_source_file_kind(path: &str) -> ProductAppRuntimeHostSourceFileKind { fn sanitize_source_relative_path(path: &str) -> CoreResult { let normalized = path.replace('\\', "/"); - if normalized.starts_with('/') - || normalized.contains("../") - || normalized == ".." + let segments = normalized.split('/').collect::>(); + let invalid_segment = segments.iter().any(|segment| { + segment.is_empty() + || *segment == "." + || *segment == ".." + || segment.contains(':') + || segment.eq_ignore_ascii_case("node_modules") + }); + let candidate = Path::new(&normalized); + let invalid_component = candidate + .components() + .any(|component| !matches!(component, Component::Normal(_))); + if normalized.is_empty() || normalized.contains('\0') + || invalid_segment + || invalid_component + || STANDARD_SOURCE_FILES + .iter() + .any(|standard| normalized.eq_ignore_ascii_case(standard)) { return Err(CoreError::validation(format!( "Invalid source file path: {}", path ))); } - Ok(PathBuf::from(normalized)) + let mut relative = PathBuf::new(); + for segment in segments { + relative.push(segment); + } + Ok(relative) } /// Product App Runtime Host storage service (file-based under `path_manager.product_app_runtime_hosts_dir()`). pub struct ProductAppRuntimeHostStorage { path_manager: Arc, + snapshot_lock: RwLock<()>, } impl ProductAppRuntimeHostStorage { pub fn new(path_manager: Arc) -> Self { - Self { path_manager } + Self { + path_manager, + snapshot_lock: RwLock::new(()), + } } fn app_dir(&self, app_id: &str) -> PathBuf { @@ -100,6 +127,11 @@ impl ProductAppRuntimeHostStorage { /// Ensure app directory and source subdir exist. pub async fn ensure_app_dir(&self, app_id: &str) -> CoreResult<()> { + let _guard = self.snapshot_lock.write().await; + self.ensure_app_dir_unlocked(app_id).await + } + + async fn ensure_app_dir_unlocked(&self, app_id: &str) -> CoreResult<()> { let dir = self.app_dir(app_id); let source = self.source_dir(app_id); tokio::fs::create_dir_all(&dir).await.map_err(|e| { @@ -152,6 +184,7 @@ impl ProductAppRuntimeHostStorage { /// Load full Product App Runtime Host surface by id (meta + source + compiled_html). pub async fn load(&self, app_id: &str) -> CoreResult { + let _guard = self.snapshot_lock.read().await; let meta_path = self.meta_path(app_id); let meta_content = tokio::fs::read_to_string(&meta_path).await.map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { @@ -318,6 +351,7 @@ impl ProductAppRuntimeHostStorage { /// Load only source files and package dependencies from disk. pub async fn load_source_only(&self, app_id: &str) -> CoreResult { + let _guard = self.snapshot_lock.read().await; self.load_source(app_id).await } @@ -359,7 +393,10 @@ impl ProductAppRuntimeHostStorage { /// Save full Product App Runtime Host surface (meta, source files, compiled.html). pub async fn save(&self, app: &ProductAppRuntimeHostSurface) -> CoreResult<()> { - self.ensure_app_dir(&app.id).await?; + let _guard = self.snapshot_lock.write().await; + self.ensure_app_dir_unlocked(&app.id).await?; + + self.replace_source_snapshot(&app.id, &app.source).await?; let meta = ProductAppRuntimeHostSurfaceMeta::from(app); let meta_path = self.meta_path(&app.id); @@ -368,58 +405,151 @@ impl ProductAppRuntimeHostStorage { .await .map_err(|e| CoreError::io(format!("Failed to write meta: {}", e)))?; - let sd = self.source_dir(&app.id); - tokio::fs::write(sd.join(INDEX_HTML), &app.source.html) - .await - .map_err(|e| CoreError::io(format!("Failed to write index.html: {}", e)))?; - tokio::fs::write(sd.join(STYLE_CSS), &app.source.css) - .await - .map_err(|e| CoreError::io(format!("Failed to write style.css: {}", e)))?; - tokio::fs::write(sd.join(UI_JS), &app.source.ui_js) - .await - .map_err(|e| CoreError::io(format!("Failed to write ui.js: {}", e)))?; - tokio::fs::write(sd.join(WORKER_JS), &app.source.worker_js) - .await - .map_err(|e| CoreError::io(format!("Failed to write worker.js: {}", e)))?; - let source_manifest = - serde_json::to_string_pretty(&app.source.entry).map_err(CoreError::from)?; - tokio::fs::write(sd.join(SOURCE_MANIFEST_JSON), source_manifest) + self.write_package_json(&app.id, &app.source.npm_dependencies) + .await?; + + tokio::fs::write(self.compiled_path(&app.id), &app.compiled_html) .await - .map_err(|e| CoreError::io(format!("Failed to write source_manifest.json: {}", e)))?; - for file in &app.source.source_files { + .map_err(|e| CoreError::io(format!("Failed to write compiled.html: {}", e)))?; + + Ok(()) + } + + async fn replace_source_snapshot( + &self, + app_id: &str, + source: &ProductAppRuntimeHostSource, + ) -> CoreResult<()> { + let mut source_files = Vec::with_capacity(source.source_files.len()); + let mut seen_paths = BTreeSet::new(); + for file in &source.source_files { let relative = sanitize_source_relative_path(&file.path)?; - let path = sd.join(relative); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await.map_err(|e| { - CoreError::io(format!("Failed to create source file dir: {}", e)) - })?; + let normalized = relative.to_string_lossy().replace('\\', "/"); + if !seen_paths.insert(normalized.to_ascii_lowercase()) { + return Err(CoreError::validation(format!( + "Duplicate source file path: {}", + file.path + ))); } - tokio::fs::write(&path, &file.content).await.map_err(|e| { - CoreError::io(format!("Failed to write source file {}: {}", file.path, e)) - })?; + source_files.push((relative, file)); } + let source_manifest = + serde_json::to_string_pretty(&source.entry).map_err(CoreError::from)?; let esm_json = - serde_json::to_string_pretty(&app.source.esm_dependencies).map_err(CoreError::from)?; - tokio::fs::write(sd.join(ESM_DEPS_JSON), esm_json) - .await - .map_err(|e| CoreError::io(format!("Failed to write esm_dependencies.json: {}", e)))?; + serde_json::to_string_pretty(&source.esm_dependencies).map_err(CoreError::from)?; let i18n_json = - serde_json::to_string_pretty(&app.source.i18n_messages).map_err(CoreError::from)?; - tokio::fs::write(sd.join(I18N_JSON), i18n_json) + serde_json::to_string_pretty(&source.i18n_messages).map_err(CoreError::from)?; + + let sequence = SOURCE_SNAPSHOT_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let app_dir = self.app_dir(app_id); + let source_dir = self.source_dir(app_id); + let staging_dir = app_dir.join(format!( + ".{SOURCE_DIR}.staging-{}-{sequence}", + std::process::id() + )); + let backup_dir = app_dir.join(format!( + ".{SOURCE_DIR}.backup-{}-{sequence}", + std::process::id() + )); + + for transaction_dir in [&staging_dir, &backup_dir] { + if transaction_dir.exists() { + tokio::fs::remove_dir_all(transaction_dir) + .await + .map_err(|e| { + CoreError::io(format!( + "Failed to reset source transaction dir {}: {}", + transaction_dir.display(), + e + )) + })?; + } + } + tokio::fs::create_dir_all(&staging_dir) .await - .map_err(|e| CoreError::io(format!("Failed to write i18n.json: {}", e)))?; + .map_err(|e| CoreError::io(format!("Failed to create source staging dir: {}", e)))?; + + let prepare_result = async { + Self::write_source_file(&staging_dir, INDEX_HTML, &source.html).await?; + Self::write_source_file(&staging_dir, STYLE_CSS, &source.css).await?; + Self::write_source_file(&staging_dir, UI_JS, &source.ui_js).await?; + Self::write_source_file(&staging_dir, WORKER_JS, &source.worker_js).await?; + Self::write_source_file(&staging_dir, SOURCE_MANIFEST_JSON, &source_manifest).await?; + Self::write_source_file(&staging_dir, ESM_DEPS_JSON, &esm_json).await?; + Self::write_source_file(&staging_dir, I18N_JSON, &i18n_json).await?; + for (relative, file) in source_files { + let path = staging_dir.join(relative); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + CoreError::io(format!("Failed to create source file dir: {}", e)) + })?; + } + tokio::fs::write(&path, &file.content).await.map_err(|e| { + CoreError::io(format!("Failed to write source file {}: {}", file.path, e)) + })?; + } + Ok::<(), CoreError>(()) + } + .await; + if let Err(error) = prepare_result { + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(error); + } - self.write_package_json(&app.id, &app.source.npm_dependencies) - .await?; + let had_source = source_dir.exists(); + if had_source { + if let Err(error) = tokio::fs::rename(&source_dir, &backup_dir).await { + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(CoreError::io(format!( + "Failed to preserve source snapshot {}: {}", + source_dir.display(), + error + ))); + } + } - tokio::fs::write(self.compiled_path(&app.id), &app.compiled_html) - .await - .map_err(|e| CoreError::io(format!("Failed to write compiled.html: {}", e)))?; + if let Err(install_error) = tokio::fs::rename(&staging_dir, &source_dir).await { + let rollback_error = if had_source { + tokio::fs::rename(&backup_dir, &source_dir).await.err() + } else { + None + }; + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(CoreError::io(match rollback_error { + Some(rollback_error) => format!( + "Failed to commit source snapshot {}: {}; rollback also failed: {}", + source_dir.display(), + install_error, + rollback_error + ), + None => format!( + "Failed to commit source snapshot {}: {}", + source_dir.display(), + install_error + ), + })); + } + if had_source { + if let Err(error) = tokio::fs::remove_dir_all(&backup_dir).await { + log::warn!( + "Source snapshot was replaced but backup cleanup failed: app_id={}, backup_dir={}, error={}", + app_id, + backup_dir.display(), + error + ); + } + } Ok(()) } + async fn write_source_file(root: &Path, relative: &str, content: &str) -> CoreResult<()> { + tokio::fs::write(root.join(relative), content) + .await + .map_err(|e| CoreError::io(format!("Failed to write {}: {}", relative, e))) + } + pub async fn copy_source_dir_recursive( from: &std::path::Path, to: &std::path::Path, @@ -518,7 +648,8 @@ impl ProductAppRuntimeHostStorage { key: &str, value: serde_json::Value, ) -> CoreResult<()> { - self.ensure_app_dir(app_id).await?; + let _guard = self.snapshot_lock.write().await; + self.ensure_app_dir_unlocked(app_id).await?; let mut current = self.load_app_storage(app_id).await?; let obj = current .as_object_mut() @@ -534,6 +665,7 @@ impl ProductAppRuntimeHostStorage { /// Delete Product App Runtime Host surface directory entirely. pub async fn delete(&self, app_id: &str) -> CoreResult<()> { + let _guard = self.snapshot_lock.write().await; let dir = self.app_dir(app_id); if dir.exists() { tokio::fs::remove_dir_all(&dir) @@ -588,3 +720,174 @@ impl ProductAppRuntimeHostStorage { .map_err(|e| CoreError::parse(format!("Invalid version file: {}", e))) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_platform::AppIconSpec; + use crate::infrastructure::PathManager; + use crate::product_app_runtime_host_engine::types::{ + ProductAppRuntimeHostI18n, ProductAppRuntimeHostPermissions, + ProductAppRuntimeHostRuntimeState, + }; + + fn test_root(name: &str) -> PathBuf { + let sequence = SOURCE_SNAPSHOT_SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sparo-runtime-host-storage-{name}-{}-{sequence}", + std::process::id() + )) + } + + fn source_file(path: &str, content: &str) -> ProductAppRuntimeHostSourceFile { + ProductAppRuntimeHostSourceFile { + path: path.to_string(), + kind: ProductAppRuntimeHostSourceFileKind::Script, + content: content.to_string(), + } + } + + fn surface(app_id: &str) -> ProductAppRuntimeHostSurface { + ProductAppRuntimeHostSurface { + id: app_id.to_string(), + name: "Snapshot test".to_string(), + description: "Snapshot replacement fixture".to_string(), + icon: AppIconSpec::Monogram { + label: "ST".to_string(), + seed: None, + background: None, + }, + category: "test".to_string(), + tags: Vec::new(), + i18n: ProductAppRuntimeHostI18n::default(), + version: 1, + created_at: 1, + updated_at: 1, + source: ProductAppRuntimeHostSource { + html: "
snapshot
".to_string(), + css: "main { display: block; }".to_string(), + ui_js: "export {};".to_string(), + worker_js: "export {};".to_string(), + source_files: vec![ + source_file("src/current.js", "export const value = 1;"), + source_file("src/stale.js", "export const stale = true;"), + ], + ..ProductAppRuntimeHostSource::default() + }, + compiled_html: "
compiled
".to_string(), + permissions: ProductAppRuntimeHostPermissions::default(), + backends: Vec::new(), + interaction: None, + ai_context: None, + permission_rationale: None, + runtime: ProductAppRuntimeHostRuntimeState::default(), + } + } + + #[test] + fn source_paths_must_be_portable_relative_files() { + assert_eq!( + sanitize_source_relative_path("src\\module.js").expect("relative source path"), + PathBuf::from("src").join("module.js") + ); + for invalid in [ + "", + "../escape.js", + "src/../escape.js", + "/absolute.js", + "C:\\absolute.js", + "node_modules/dependency.js", + "ui.js", + ] { + assert!( + sanitize_source_relative_path(invalid).is_err(), + "path should be rejected: {invalid}" + ); + } + } + + #[tokio::test] + async fn save_replaces_source_snapshot_and_preserves_runtime_state() { + let root = test_root("exact-source-snapshot"); + let _ = tokio::fs::remove_dir_all(&root).await; + let path_manager = Arc::new(PathManager::with_user_root_for_tests(root.clone())); + let storage = ProductAppRuntimeHostStorage::new(path_manager); + let app_id = "snapshot-test"; + let mut app = surface(app_id); + + storage.save(&app).await.expect("save initial snapshot"); + storage + .save_app_storage(app_id, "preserved", serde_json::json!(true)) + .await + .expect("save runtime storage"); + storage + .save_version(app_id, app.version, &app) + .await + .expect("save version snapshot"); + let dependency_marker = storage + .app_dir(app_id) + .join("node_modules") + .join("dependency") + .join("marker.txt"); + tokio::fs::create_dir_all(dependency_marker.parent().expect("dependency parent")) + .await + .expect("create dependency dir"); + tokio::fs::write(&dependency_marker, "preserved") + .await + .expect("write dependency marker"); + + app.source.source_files = vec![source_file("src/current.js", "export const value = 2;")]; + app.updated_at = 2; + storage + .save(&app) + .await + .expect("replace authoritative source snapshot"); + + let loaded = storage.load(app_id).await.expect("load replaced snapshot"); + assert_eq!(loaded.source.source_files.len(), 1); + assert_eq!(loaded.source.source_files[0].path, "src/current.js"); + assert_eq!( + loaded.source.source_files[0].content, + "export const value = 2;" + ); + assert!( + !storage + .source_dir(app_id) + .join("src") + .join("stale.js") + .exists(), + "files absent from the authoritative source snapshot must be deleted" + ); + assert_eq!( + storage + .load_app_storage(app_id) + .await + .expect("load runtime storage")["preserved"], + serde_json::json!(true) + ); + assert_eq!( + storage.list_versions(app_id).await.expect("list versions"), + vec![1] + ); + assert_eq!( + tokio::fs::read_to_string(&dependency_marker) + .await + .expect("read dependency marker"), + "preserved" + ); + + let mut entries = tokio::fs::read_dir(storage.app_dir(app_id)) + .await + .expect("read app dir"); + while let Some(entry) = entries.next_entry().await.expect("read app entry") { + assert!( + !entry.file_name().to_string_lossy().starts_with(".source."), + "completed source transactions must not leave staging or backup directories" + ); + } + + tokio::fs::remove_dir_all(root) + .await + .expect("remove test root"); + } +} diff --git a/src/crates/core/src/product_app_runtime_host_engine/types.rs b/src/crates/core/src/product_app_runtime_host_engine/types.rs index 5b723602..80bc2590 100644 --- a/src/crates/core/src/product_app_runtime_host_engine/types.rs +++ b/src/crates/core/src/product_app_runtime_host_engine/types.rs @@ -145,6 +145,19 @@ pub struct ProductAppRuntimeHostPermissions { pub node: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ai: Option, + /// Explicit Permissions Policy features granted to the sandbox iframe. + /// Features stay denied unless the surface component declares them. + #[serde(skip_serializing_if = "Option::is_none")] + pub iframe: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IframePermissions { + #[serde(default)] + pub autoplay: bool, + #[serde(default)] + pub fullscreen: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/src/crates/core/src/service/daily_letter/mod.rs b/src/crates/core/src/service/daily_letter/mod.rs index 0768b018..672863e7 100644 --- a/src/crates/core/src/service/daily_letter/mod.rs +++ b/src/crates/core/src/service/daily_letter/mod.rs @@ -8,11 +8,11 @@ pub use service::{ global_daily_letters_output_dir, install_global_daily_letter_service, DailyLetterService, }; pub use types::{ - DailyLetterAgentOutput, DailyLetterAgentResult, DailyLetterAppOpportunity, - DailyLetterApplyReceiptsRequest, DailyLetterAttemptStatus, DailyLetterContextPacket, - DailyLetterGenerateRequest, DailyLetterGetRequest, DailyLetterListRequest, DailyLetterPreview, - DailyLetterReceiptAction, DailyLetterReceiptCandidate, DailyLetterReceiptStatus, - DailyLetterRecord, DailyLetterRecordStatus, DailyLetterRunSummary, DailyLetterScope, - DailyLetterSealRequest, DailyLetterSourceFragment, DailyLetterSourceFragmentType, - DailyLetterSourceStats, DailyLetterState, DailyLetterTrigger, DailyLetterWorkspaceRef, + DailyLetterAgentOutput, DailyLetterAppOpportunity, DailyLetterApplyReceiptsRequest, + DailyLetterAttemptStatus, DailyLetterContextPacket, DailyLetterGenerateRequest, + DailyLetterGetRequest, DailyLetterListRequest, DailyLetterPreview, DailyLetterReceiptAction, + DailyLetterReceiptCandidate, DailyLetterReceiptStatus, DailyLetterRecord, + DailyLetterRecordStatus, DailyLetterRunSummary, DailyLetterScope, DailyLetterSealRequest, + DailyLetterSourceFragment, DailyLetterSourceFragmentType, DailyLetterSourceStats, + DailyLetterState, DailyLetterTrigger, DailyLetterWorkspaceRef, }; diff --git a/src/crates/core/src/service/daily_letter/service.rs b/src/crates/core/src/service/daily_letter/service.rs index 345557fd..f99b3988 100644 --- a/src/crates/core/src/service/daily_letter/service.rs +++ b/src/crates/core/src/service/daily_letter/service.rs @@ -1,17 +1,17 @@ use super::prompt::build_daily_letter_user_prompt; use super::store::{ - daily_letter_markdown_path, daily_letter_record_id, get_daily_letter, list_daily_letters, - load_daily_letter_record, load_daily_letter_state, path_string, resolve_request_scope, - save_daily_letter_record, save_daily_letter_state, + daily_letter_markdown_path, daily_letter_record_id, daily_letter_root, get_daily_letter, + list_daily_letters, load_daily_letter_record, load_daily_letter_state, path_string, + resolve_request_scope, save_daily_letter_record, save_daily_letter_state, }; use super::types::{ - DailyLetterAgentOutput, DailyLetterAgentResult, DailyLetterAppOpportunity, - DailyLetterApplyReceiptsRequest, DailyLetterAttemptStatus, DailyLetterContextPacket, - DailyLetterGenerateRequest, DailyLetterGetRequest, DailyLetterListRequest, DailyLetterPreview, - DailyLetterReceiptAction, DailyLetterReceiptCandidate, DailyLetterReceiptStatus, - DailyLetterRecord, DailyLetterRecordStatus, DailyLetterRunSummary, DailyLetterScope, - DailyLetterSealRequest, DailyLetterSourceFragment, DailyLetterSourceFragmentType, - DailyLetterSourceStats, DailyLetterState, DailyLetterTrigger, DailyLetterWorkspaceRef, + DailyLetterAgentOutput, DailyLetterAppOpportunity, DailyLetterApplyReceiptsRequest, + DailyLetterAttemptStatus, DailyLetterContextPacket, DailyLetterGenerateRequest, + DailyLetterGetRequest, DailyLetterListRequest, DailyLetterPreview, DailyLetterReceiptAction, + DailyLetterReceiptCandidate, DailyLetterReceiptStatus, DailyLetterRecord, + DailyLetterRecordStatus, DailyLetterRunSummary, DailyLetterScope, DailyLetterSealRequest, + DailyLetterSourceFragment, DailyLetterSourceFragmentType, DailyLetterSourceStats, + DailyLetterState, DailyLetterTrigger, DailyLetterWorkspaceRef, }; use crate::agentic::coordination::get_global_coordinator; use crate::agentic::memory::store::{ @@ -19,6 +19,7 @@ use crate::agentic::memory::store::{ }; use crate::agentic_os::work::{default_work_store, WorkScope}; use crate::error::{CoreError, CoreResult}; +use crate::infrastructure::events::{emit_global_event, BackendEvent}; use crate::infrastructure::get_path_manager_arc; use crate::service::config::{get_app_language_code, get_global_config_service, GlobalConfig}; use crate::util::extract_json_from_ai_response; @@ -385,25 +386,17 @@ impl DailyLetterService { save_daily_letter_state(scope, workspace_path, &state).await?; } - let result: CoreResult> = async { + let result: CoreResult = async { let packet = build_context_packet(&date, scope, workspace_path).await?; - if !has_high_signal_fragment(&packet) && matches!(trigger, DailyLetterTrigger::Auto) { - return Ok(None); - } - - let record = if !has_high_signal_fragment(&packet) { - build_insufficient_context_record(&packet) - } else { - let output = generate_letter_with_ai(&packet).await?; - build_record_from_agent_output(&packet, output)? - }; + let output = generate_letter_with_ai(&packet).await?; + let record = build_record_from_agent_output(&packet, output)?; save_daily_letter_record(&record).await?; - Ok(Some(record)) + Ok(record) } .await; match result { - Ok(Some(record)) => { + Ok(record) => { let mut state = self.state.lock().await; state.last_attempt_finished_at_ms = Some(now_ms()); state.last_attempt_status = Some(DailyLetterAttemptStatus::Ok); @@ -414,6 +407,14 @@ impl DailyLetterService { state.next_auto_run_not_before_ms = Some(next_auto_wake_timestamp_ms()); save_daily_letter_state(scope, workspace_path, &state).await?; self.wake_notify.notify_one(); + if let Err(error) = emit_global_event(BackendEvent::Custom { + event_name: "daily-letter://arrived".to_string(), + payload: serde_json::to_value(&record).unwrap_or(serde_json::Value::Null), + }) + .await + { + warn!("Failed to emit daily letter arrival: {}", error); + } Ok(DailyLetterRunSummary { started: true, trigger, @@ -422,24 +423,6 @@ impl DailyLetterService { reason: None, }) } - Ok(None) => { - let mut state = self.state.lock().await; - state.last_attempt_finished_at_ms = Some(now_ms()); - state.last_attempt_status = Some(DailyLetterAttemptStatus::SkippedNoSources); - state.last_error = None; - state.active_date = None; - state.active_record_id = None; - state.next_auto_run_not_before_ms = Some(next_auto_wake_timestamp_ms()); - save_daily_letter_state(scope, workspace_path, &state).await?; - self.wake_notify.notify_one(); - Ok(DailyLetterRunSummary { - started: false, - trigger, - date: Some(date), - record: None, - reason: Some("No authorized daily context was available".to_string()), - }) - } Err(error) => { let mut state = self.state.lock().await; state.last_attempt_finished_at_ms = Some(now_ms()); @@ -712,6 +695,21 @@ async fn collect_exploration_target_fragments( ); } + if let Ok(letters_root) = daily_letter_root(scope, workspace_path) { + push_path_fragment( + &mut fragments, + "letters-archive".to_string(), + DailyLetterSourceFragmentType::Memory, + "Earlier daily letters".to_string(), + format!( + "Archive of previously sent daily letters as /.md. Skim the most recent few before writing so this letter continues one correspondence: pick up threads left open before {}, and avoid repeating a recent topic, angle, or gift shape.", + coverage.start_date + ), + letters_root, + 0.8, + ); + } + let explicit_root = match scope { DailyLetterScope::AgenticOs => path_manager .agentic_os_runtime_root() @@ -1190,12 +1188,7 @@ fn build_record_from_agent_output( date: packet.date.clone(), scope: packet.scope, workspace: packet.workspace.clone(), - status: match output.result { - DailyLetterAgentResult::Letter => DailyLetterRecordStatus::Ready, - DailyLetterAgentResult::InsufficientContext => { - DailyLetterRecordStatus::InsufficientContext - } - }, + status: DailyLetterRecordStatus::Ready, preview: DailyLetterPreview { title: non_empty_or( output.preview.title.trim(), @@ -1215,34 +1208,11 @@ fn build_record_from_agent_output( updated_at_ms: now, }; record.status = next_record_status(&record); - validate_daily_letter_record(&packet, &record)?; + validate_daily_letter_record(&record)?; Ok(record) } -fn has_high_signal_fragment(packet: &DailyLetterContextPacket) -> bool { - packet - .fragments - .iter() - .chain(packet.memory_context.iter()) - .any(|fragment| { - fragment.confidence >= 0.7 - && (!fragment.summary.trim().is_empty() || fragment.source_path.is_some()) - }) -} - -fn validate_daily_letter_record( - packet: &DailyLetterContextPacket, - record: &DailyLetterRecord, -) -> CoreResult<()> { - if matches!(record.status, DailyLetterRecordStatus::InsufficientContext) { - return Ok(()); - } - - if !has_high_signal_fragment(packet) { - return Err(CoreError::validation( - "Daily letter requires at least one high-signal source fragment", - )); - } +fn validate_daily_letter_record(record: &DailyLetterRecord) -> CoreResult<()> { if record.preview.title.trim().is_empty() || record.preview.one_line.trim().is_empty() { return Err(CoreError::validation( "Daily letter preview title and oneLine are required", @@ -1270,46 +1240,8 @@ fn validate_daily_letter_record( Ok(()) } -fn build_insufficient_context_record(packet: &DailyLetterContextPacket) -> DailyLetterRecord { - let now = now_ms(); - let is_en = packet.locale == "en-US"; - DailyLetterRecord { - id: daily_letter_record_id( - &packet.date, - packet.scope, - packet.workspace.as_ref().map(|w| Path::new(&w.path)), - ), - date: packet.date.clone(), - scope: packet.scope, - workspace: packet.workspace.clone(), - status: DailyLetterRecordStatus::InsufficientContext, - preview: DailyLetterPreview { - title: if is_en { - format!("Daily Letter · {}", packet.date) - } else { - format!("今日来信 · {}", packet.date) - }, - one_line: if is_en { - "I do not have enough authorized context for today yet.".to_string() - } else { - "今天能看见的上下文还不多,我先把这封空白处留给你。".to_string() - }, - receipt_count: 0, - app_idea_count: 0, - }, - body_markdown: if is_en { - "Today I do not have enough authorized context to write a useful letter. When more work, commands, memories, or explicit notes are available, I will turn them into a warmer summary here.".to_string() - } else { - "今天我能看见的上下文还不够多,所以不硬写成一份总结。\n\n等有新的会话摘要、工作状态、命令结果、Git 活动,或你主动加入今日来信的片段,我会把它们整理成一封更像信的回望。".to_string() - }, - receipt_candidates: Vec::new(), - app_opportunity: None, - created_at_ms: now, - updated_at_ms: now, - } -} - fn next_record_status(record: &DailyLetterRecord) -> DailyLetterRecordStatus { + // Legacy records generated before every-day letters keep their status. if matches!(record.status, DailyLetterRecordStatus::InsufficientContext) { return DailyLetterRecordStatus::InsufficientContext; } diff --git a/src/crates/core/src/service/daily_letter/types.rs b/src/crates/core/src/service/daily_letter/types.rs index 8dfdc60a..62c450f3 100644 --- a/src/crates/core/src/service/daily_letter/types.rs +++ b/src/crates/core/src/service/daily_letter/types.rs @@ -17,6 +17,8 @@ impl Default for DailyLetterScope { #[serde(rename_all = "snake_case")] pub enum DailyLetterRecordStatus { Ready, + /// Legacy status kept only so records written by older versions still + /// deserialize. New letters are always written, whatever the day held. InsufficientContext, NeedsReceipt, Sealed, @@ -112,13 +114,6 @@ pub struct DailyLetterContextPacket { pub user_preferences: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DailyLetterAgentResult { - Letter, - InsufficientContext, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DailyLetterAgentPreview { @@ -148,7 +143,6 @@ pub struct DailyLetterAgentAppOpportunity { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DailyLetterAgentOutput { - pub result: DailyLetterAgentResult, pub preview: DailyLetterAgentPreview, pub body_markdown: String, #[serde(default)] @@ -292,6 +286,8 @@ pub enum DailyLetterAttemptStatus { Ok, Error, Cancelled, + /// Legacy status kept only so older state files still deserialize. + /// Letters are no longer skipped for lack of sources. SkippedNoSources, } diff --git a/src/web-ui/src/app/agentic-os/excel-live/excelLiveFocusStore.test.ts b/src/web-ui/src/app/agentic-os/excel-live/excelLiveFocusStore.test.ts new file mode 100644 index 00000000..e0daf49c --- /dev/null +++ b/src/web-ui/src/app/agentic-os/excel-live/excelLiveFocusStore.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSpreadsheetFocusContext, + isSpreadsheetFocusBoundToSession, + spreadsheetFormulaResultsTrustworthy, + useExcelLiveFocusStore, +} from './excelLiveFocusStore'; + +describe('Excel Live focus context', () => { + it('preserves focus revision, cache evidence, mode, and capture time', () => { + const context = buildSpreadsheetFocusContext({ + sessionId: 'session-1', + workbookId: 'workbook-1', + workbookPath: '/workspace/report.xlsx', + sheetId: 'sheet-1', + sheetName: 'Revenue', + a1: 'B2:D4', + selectionKind: 'range', + rowCount: 3, + columnCount: 3, + mode: 'inspect', + revision: 17, + cacheCoverage: { cachedCellCount: 9, selectedCellCount: 9 }, + cacheComplete: true, + formulaResultsFresh: true, + calculationStatus: { status: 'current', formulaCount: 1 }, + fidelity: { level: 'source-preserving', canRoundTrip: true }, + capturedAt: 1_700_000_000_123, + previewTsv: '1\t2\t3', + valueSummary: { cellCount: 9, numericCount: 3 }, + }); + + expect(context).toMatchObject({ + sessionId: 'session-1', + workbookId: 'workbook-1', + mode: 'inspect', + revision: 17, + cacheCoverage: { cachedCellCount: 9, selectedCellCount: 9 }, + cacheComplete: true, + formulaResultsFresh: true, + calculationStatus: { status: 'current', formulaCount: 1 }, + fidelity: { level: 'source-preserving', canRoundTrip: true }, + capturedAt: 1_700_000_000_123, + previewTsv: '1\t2\t3', + }); + expect(context && isSpreadsheetFocusBoundToSession(context, 'session-1')).toBe(true); + expect(context && isSpreadsheetFocusBoundToSession(context, 'session-2')).toBe(false); + }); + + it('never forwards a preview without explicit complete-cache evidence', () => { + const context = buildSpreadsheetFocusContext({ + sessionId: 'session-1', + workbookId: 'workbook-1', + sheetId: 'sheet-1', + sheetName: 'Revenue', + a1: 'A1:Z100', + rowCount: 100, + columnCount: 26, + cacheCoverage: 0.1, + cacheComplete: false, + capturedAt: 1_700_000_000_123, + previewTsv: 'cached-but-incomplete', + }); + + expect(context?.cacheComplete).toBe(false); + expect(context?.cacheCoverage).toBe(0.1); + expect(context?.previewTsv).toBeUndefined(); + }); + + it('never forwards cached formula results marked stale', () => { + const context = buildSpreadsheetFocusContext({ + sessionId: 'session-1', + workbookId: 'workbook-1', + sheetId: 'sheet-1', + sheetName: 'Revenue', + a1: 'E2:E4', + rowCount: 3, + columnCount: 1, + cacheComplete: true, + formulaResultsFresh: false, + calculationStatus: { status: 'stale', formulaCount: 3 }, + previewTsv: '0.10\n0.20\n0.30', + }); + + expect(context?.cacheComplete).toBe(true); + expect(context?.formulaResultsFresh).toBe(false); + expect(context?.previewTsv).toBeUndefined(); + }); + + it('honors an explicit stale marker even when legacy formula counts are missing', () => { + const context = buildSpreadsheetFocusContext({ + sessionId: 'session-1', + workbookId: 'workbook-1', + sheetId: 'sheet-1', + sheetName: 'Revenue', + a1: 'A1', + rowCount: 1, + columnCount: 1, + cacheComplete: true, + formulaResultsFresh: false, + previewTsv: '42', + }); + + expect(context?.previewTsv).toBeUndefined(); + }); + + it('treats missing formula freshness as untrusted when formula evidence exists', () => { + const context = buildSpreadsheetFocusContext({ + sessionId: 'session-1', + workbookId: 'workbook-1', + sheetId: 'sheet-1', + sheetName: 'Revenue', + a1: 'E2:E4', + rowCount: 3, + columnCount: 1, + cacheComplete: true, + calculationStatus: { status: 'cached', formulaCount: 3 }, + previewTsv: '0.10\n0.20\n0.30', + valueSummary: { + cellCount: 3, + formulaCount: 3, + numericCount: 3, + sum: 0.6, + }, + }); + + expect(context?.previewTsv).toBeUndefined(); + expect(context?.valueSummary).toEqual({ cellCount: 3, formulaCount: 3 }); + }); + + it('fails closed when formula calculation evidence is completely missing', () => { + const context = buildSpreadsheetFocusContext({ + sessionId: 'session-unknown-formulas', + workbookId: 'workbook-unknown-formulas', + sheetId: 'sheet-1', + sheetName: 'Sheet1', + a1: 'A1:B1', + rowCount: 1, + columnCount: 2, + cacheComplete: true, + previewTsv: '10\t20', + valueSummary: { + cellCount: 2, + numericCount: 2, + formulaCount: 0, + sum: 30, + }, + }); + + expect(context?.previewTsv).toBeUndefined(); + expect(context?.valueSummary).toEqual({ cellCount: 2, formulaCount: 0 }); + expect(context && spreadsheetFormulaResultsTrustworthy(context)).toBe(false); + }); + + it('does not let a fresh flag override cached calculation status', () => { + expect(spreadsheetFormulaResultsTrustworthy({ + formulaResultsFresh: true, + calculationStatus: { status: 'cached', formulaCount: 1 }, + valueSummary: { cellCount: 1, formulaCount: 0 }, + })).toBe(false); + }); + + it('keeps ambient focus isolated per chat session', () => { + useExcelLiveFocusStore.setState({ ambient: null, ambientBySessionId: {} }); + const store = useExcelLiveFocusStore.getState(); + store.setAmbientFocus({ + sessionId: 'session-a', + workbookId: 'workbook-a', + sheetId: 'sheet-a', + a1: 'A1', + }); + useExcelLiveFocusStore.getState().setAmbientFocus({ + sessionId: 'session-b', + workbookId: 'workbook-b', + sheetId: 'sheet-b', + a1: 'B2', + }); + + expect(useExcelLiveFocusStore.getState().getAmbientForSession('session-a')?.workbookId) + .toBe('workbook-a'); + expect(useExcelLiveFocusStore.getState().getAmbientForSession('session-b')?.workbookId) + .toBe('workbook-b'); + + useExcelLiveFocusStore.getState().clearAmbientFocus('session-a'); + expect(useExcelLiveFocusStore.getState().getAmbientForSession('session-a')).toBeNull(); + expect(useExcelLiveFocusStore.getState().getAmbientForSession('session-b')?.workbookId) + .toBe('workbook-b'); + }); +}); diff --git a/src/web-ui/src/app/agentic-os/excel-live/excelLiveFocusStore.ts b/src/web-ui/src/app/agentic-os/excel-live/excelLiveFocusStore.ts new file mode 100644 index 00000000..8ed2fcdd --- /dev/null +++ b/src/web-ui/src/app/agentic-os/excel-live/excelLiveFocusStore.ts @@ -0,0 +1,328 @@ +import { create } from 'zustand'; +import type { + SpreadsheetFocusCacheCoverage, + SpreadsheetFocusContext, + SpreadsheetFocusMode, +} from '@/shared/types/context'; + +export interface SpreadsheetFocusPayload { + sessionId?: string; + workbookId?: string; + workbookPath?: string; + sheetId?: string; + sheetName?: string; + a1?: string; + selectionKind?: SpreadsheetFocusContext['selectionKind']; + rowCount?: number; + columnCount?: number; + mode?: SpreadsheetFocusMode; + revision?: string | number; + cacheCoverage?: SpreadsheetFocusCacheCoverage; + cacheComplete?: boolean; + formulaResultsFresh?: boolean; + calculationStatus?: Record; + fidelity?: Record; + capturedAt?: number; + previewTsv?: string; + previewTruncated?: boolean; + valueSummary?: SpreadsheetFocusContext['valueSummary']; + role?: 'ambient' | 'pinned'; + includeOnSend?: boolean; + includeFocusOnSend?: boolean; + label?: string; +} + +interface ExcelLiveFocusState { + ambient: SpreadsheetFocusContext | null; + ambientBySessionId: Record; + includeOnSend: boolean; + setAmbientFocus: (payload: SpreadsheetFocusPayload | null) => void; + setIncludeOnSend: (include: boolean) => void; + clearAmbientFocus: (sessionId?: string) => void; + getAmbientForSession: (sessionId: string | null | undefined) => SpreadsheetFocusContext | null; + toContextItem: (role?: 'ambient' | 'pinned', sessionId?: string) => SpreadsheetFocusContext | null; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function normalizeMode(value: unknown): SpreadsheetFocusMode | undefined { + return value === 'inspect' || value === 'edit' || value === 'author' + ? value + : undefined; +} + +function normalizeRevision(value: unknown): string | number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + return optionalString(value); +} + +function normalizeCacheCoverage(value: unknown): SpreadsheetFocusCacheCoverage | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { ...(value as Record) }; + } + return undefined; +} + +function normalizeCapturedAt(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : fallback; +} + +function normalizeRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? { ...(value as Record) } + : undefined; +} + +function calculationStatusIsExplicitlyFresh(status: Record | undefined): boolean { + const token = String(status?.status ?? status?.state ?? '').trim().toLowerCase(); + return token === 'current' + || token === 'fresh' + || token === 'ready' + || token === 'calculated' + || token === 'recalculated' + || token === 'ok' + || token === 'not-required'; +} + +function calculationStatusIsExplicitlyUntrusted(status: Record | undefined): boolean { + const token = String(status?.status ?? status?.state ?? '').trim().toLowerCase(); + return token === 'cached' + || token === 'stale' + || token === 'pending' + || token === 'dirty' + || token === 'unknown' + || token === 'failed' + || token === 'error'; +} + +export function spreadsheetFormulaResultsTrustworthy(context: Pick< + SpreadsheetFocusPayload, + 'formulaResultsFresh' | 'calculationStatus' | 'valueSummary' +>): boolean { + if (context.formulaResultsFresh === false) return false; + if (calculationStatusIsExplicitlyUntrusted(context.calculationStatus)) return false; + if (calculationStatusIsExplicitlyFresh(context.calculationStatus)) return true; + // A surface may explicitly attest freshness, but absence of both freshness + // and calculation evidence is never proof that formulas do not exist. + return context.formulaResultsFresh === true; +} + +function trustworthyValueSummary( + valueSummary: SpreadsheetFocusPayload['valueSummary'], + formulaTrustworthy: boolean, +): SpreadsheetFocusPayload['valueSummary'] { + if (!valueSummary || formulaTrustworthy) { + return valueSummary; + } + return { + cellCount: valueSummary.cellCount, + ...(valueSummary.formulaCount == null ? {} : { formulaCount: valueSummary.formulaCount }), + }; +} + +function buildContext( + payload: SpreadsheetFocusPayload, + role: 'ambient' | 'pinned', +): SpreadsheetFocusContext | null { + const workbookId = typeof payload.workbookId === 'string' ? payload.workbookId.trim() : ''; + const sheetId = typeof payload.sheetId === 'string' ? payload.sheetId.trim() : ''; + const sheetName = typeof payload.sheetName === 'string' ? payload.sheetName.trim() : sheetId; + const a1 = typeof payload.a1 === 'string' ? payload.a1.trim() : ''; + if (!workbookId || !sheetId || !a1) return null; + + const selectionKind = payload.selectionKind || 'range'; + const rowCount = Math.max(0, Number(payload.rowCount) || 0); + const columnCount = Math.max(0, Number(payload.columnCount) || 0); + const timestamp = Date.now(); + const capturedAt = normalizeCapturedAt(payload.capturedAt, timestamp); + const sessionId = optionalString(payload.sessionId); + const workbookPath = optionalString(payload.workbookPath); + const mode = normalizeMode(payload.mode); + const revision = normalizeRevision(payload.revision); + const cacheCoverage = normalizeCacheCoverage(payload.cacheCoverage); + const formulaResultsFresh = typeof payload.formulaResultsFresh === 'boolean' + ? payload.formulaResultsFresh + : undefined; + const calculationStatus = normalizeRecord(payload.calculationStatus); + const fidelity = normalizeRecord(payload.fidelity); + const formulaTrustworthy = spreadsheetFormulaResultsTrustworthy({ + formulaResultsFresh, + calculationStatus, + valueSummary: payload.valueSummary, + }); + // A preview built from only the viewport cache can silently turn unknown + // cells into empty cells. Only forward it when the surface explicitly + // proves that its cache covers the entire selected range. + const cacheComplete = payload.cacheComplete === true; + const previewTsv = cacheComplete + && formulaTrustworthy + && typeof payload.previewTsv === 'string' + ? payload.previewTsv + : undefined; + + return { + id: role === 'pinned' + ? `spreadsheet-focus-pinned-${workbookId}-${sheetId}-${a1}-${timestamp}` + : `spreadsheet-focus-ambient-${sessionId || 'unbound'}-${workbookId}`, + timestamp, + type: 'spreadsheet-focus', + schemaVersion: 1, + role, + sessionId, + workbookId, + workbookPath, + sheetId, + sheetName: sheetName || sheetId, + a1, + selectionKind, + rowCount, + columnCount, + mode, + revision, + cacheCoverage, + cacheComplete, + formulaResultsFresh, + calculationStatus, + fidelity, + capturedAt, + previewTsv, + previewTruncated: Boolean(payload.previewTruncated), + valueSummary: trustworthyValueSummary(payload.valueSummary, formulaTrustworthy), + metadata: { + source: 'excel-live-focus', + binding: { + sessionId: sessionId ?? null, + workbookId, + }, + snapshot: { + mode: mode ?? null, + revision: revision ?? null, + cacheCoverage: cacheCoverage ?? null, + cacheComplete, + formulaResultsFresh: formulaResultsFresh ?? null, + calculationStatus: calculationStatus ?? null, + fidelity: fidelity ?? null, + capturedAt, + previewIncluded: Boolean(previewTsv), + }, + }, + }; +} + +export const useExcelLiveFocusStore = create((set, get) => ({ + ambient: null, + ambientBySessionId: {}, + includeOnSend: true, + + setAmbientFocus: (payload) => { + if (!payload) { + set({ ambient: null, ambientBySessionId: {} }); + return; + } + const next = buildContext(payload, 'ambient'); + const includeOnSend = payload.includeOnSend !== false && payload.includeFocusOnSend !== false; + set((state) => { + const bindingSessionId = next?.sessionId; + const current = bindingSessionId + ? state.ambientBySessionId[bindingSessionId] ?? null + : state.ambient; + const sameBinding = Boolean( + current + && next + && current.sessionId === next.sessionId + && current.workbookId === next.workbookId, + ); + // Ignore delayed focus events from an older selection. The include + // toggle is still accepted because it is a separate user preference. + if (sameBinding && next && current && next.capturedAt < current.capturedAt) { + return { includeOnSend }; + } + const ambientBySessionId = bindingSessionId && next + ? { ...state.ambientBySessionId, [bindingSessionId]: next } + : state.ambientBySessionId; + return { + ambient: next, + ambientBySessionId, + includeOnSend, + }; + }); + }, + + setIncludeOnSend: (include) => set({ includeOnSend: include }), + + clearAmbientFocus: (sessionId) => set((state) => { + if (!sessionId) return { ambient: null, ambientBySessionId: {} }; + if (!(sessionId in state.ambientBySessionId)) return state; + const ambientBySessionId = { ...state.ambientBySessionId }; + delete ambientBySessionId[sessionId]; + return { + ambient: state.ambient?.sessionId === sessionId ? null : state.ambient, + ambientBySessionId, + }; + }), + + getAmbientForSession: (sessionId) => ( + sessionId ? get().ambientBySessionId[sessionId] ?? null : null + ), + + toContextItem: (role = 'ambient', sessionId) => { + const ambient = sessionId + ? get().ambientBySessionId[sessionId] ?? null + : get().ambient; + if (!ambient) return null; + if (role === 'ambient') return ambient; + return { + ...ambient, + id: `spreadsheet-focus-pinned-${ambient.workbookId}-${ambient.sheetId}-${ambient.a1}-${Date.now()}`, + role: 'pinned', + timestamp: Date.now(), + }; + }, +})); + +export function buildSpreadsheetFocusContext( + payload: SpreadsheetFocusPayload, + role: 'ambient' | 'pinned' = 'ambient', +): SpreadsheetFocusContext | null { + return buildContext(payload, role); +} + +export function isSpreadsheetFocusBoundToSession( + context: SpreadsheetFocusContext, + sessionId: string | null | undefined, +): boolean { + return Boolean(sessionId && context.sessionId && context.sessionId === sessionId); +} + +export function spreadsheetFocusMetadata(context: SpreadsheetFocusContext): Record { + const capturedAt = Number.isFinite(context.capturedAt) && context.capturedAt > 0 + ? context.capturedAt + : context.timestamp; + return { + schemaVersion: context.schemaVersion, + role: context.role, + sessionId: context.sessionId ?? null, + workbookId: context.workbookId, + workbookPath: context.workbookPath ?? null, + sheetId: context.sheetId, + sheetName: context.sheetName, + a1: context.a1, + selectionKind: context.selectionKind, + rowCount: context.rowCount, + columnCount: context.columnCount, + mode: context.mode ?? null, + revision: context.revision ?? null, + cacheCoverage: context.cacheCoverage ?? null, + cacheComplete: context.cacheComplete === true, + formulaResultsFresh: context.formulaResultsFresh ?? null, + calculationStatus: context.calculationStatus ?? null, + fidelity: context.fidelity ?? null, + capturedAt, + previewIncluded: context.cacheComplete === true && Boolean(context.previewTsv), + }; +} diff --git a/src/web-ui/src/app/agentic-os/excel-live/excelLiveLaunchStore.test.ts b/src/web-ui/src/app/agentic-os/excel-live/excelLiveLaunchStore.test.ts new file mode 100644 index 00000000..fb2a1886 --- /dev/null +++ b/src/web-ui/src/app/agentic-os/excel-live/excelLiveLaunchStore.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useExcelLiveLaunchStore } from './excelLiveLaunchStore'; + +describe('Excel Live launch binding', () => { + beforeEach(() => { + useExcelLiveLaunchStore.setState({ pendingPaths: {} }); + }); + + it('keeps concurrent file launches isolated by runtime entity', () => { + const store = useExcelLiveLaunchStore.getState(); + store.setPendingPath('excel-file:A.xlsx', 'A.xlsx'); + store.setPendingPath('excel-file:B.xlsx', 'B.xlsx'); + + expect(store.consumePendingPath('excel-file:A.xlsx')).toBe('A.xlsx'); + expect(useExcelLiveLaunchStore.getState().peekPendingPath('excel-file:B.xlsx')).toBe('B.xlsx'); + expect(useExcelLiveLaunchStore.getState().peekPendingPath('excel-file:A.xlsx')).toBeNull(); + }); + + it('does not let an older navigation clear a newer launch for the same file', () => { + const store = useExcelLiveLaunchStore.getState(); + store.setPendingPath('excel-file:A.xlsx', 'A.xlsx', 'owner-a'); + store.setPendingPath('excel-file:A.xlsx', 'A.xlsx', 'owner-b'); + + store.clearPendingPath('excel-file:A.xlsx', 'owner-a'); + + expect(useExcelLiveLaunchStore.getState().peekPendingPath('excel-file:A.xlsx')).toBe('A.xlsx'); + store.clearPendingPath('excel-file:A.xlsx', 'owner-b'); + expect(useExcelLiveLaunchStore.getState().peekPendingPath('excel-file:A.xlsx')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/agentic-os/excel-live/excelLiveLaunchStore.ts b/src/web-ui/src/app/agentic-os/excel-live/excelLiveLaunchStore.ts new file mode 100644 index 00000000..96ebda39 --- /dev/null +++ b/src/web-ui/src/app/agentic-os/excel-live/excelLiveLaunchStore.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand'; + +interface PendingExcelLiveLaunch { + filePath: string; + ownerToken: string | null; +} + +interface ExcelLiveLaunchState { + pendingPaths: Record; + setPendingPath: (launchKey: string, path: string, ownerToken?: string) => void; + /** Read the pending path without clearing it (safe before iframe is ready). */ + peekPendingPath: (launchKey: string) => string | null; + consumePendingPath: (launchKey: string) => string | null; + /** Clear only the launch owned by ownerToken when one is provided. */ + clearPendingPath: (launchKey: string, ownerToken?: string) => void; +} + +export const useExcelLiveLaunchStore = create((set, get) => ({ + pendingPaths: {}, + setPendingPath: (launchKey, path, ownerToken) => set(state => ({ + pendingPaths: { + ...state.pendingPaths, + [launchKey]: { filePath: path, ownerToken: ownerToken ?? null }, + }, + })), + peekPendingPath: (launchKey) => get().pendingPaths[launchKey]?.filePath ?? null, + consumePendingPath: (launchKey) => { + const path = get().pendingPaths[launchKey]?.filePath ?? null; + set(state => { + if (!(launchKey in state.pendingPaths)) return state; + const pendingPaths = { ...state.pendingPaths }; + delete pendingPaths[launchKey]; + return { pendingPaths }; + }); + return path; + }, + clearPendingPath: (launchKey, ownerToken) => set(state => { + const pending = state.pendingPaths[launchKey]; + if (!pending || (ownerToken !== undefined && pending.ownerToken !== ownerToken)) return state; + const pendingPaths = { ...state.pendingPaths }; + delete pendingPaths[launchKey]; + return { pendingPaths }; + }), +})); diff --git a/src/web-ui/src/app/agentic-os/excel-live/openExcelLive.test.ts b/src/web-ui/src/app/agentic-os/excel-live/openExcelLive.test.ts new file mode 100644 index 00000000..bf80c532 --- /dev/null +++ b/src/web-ui/src/app/agentic-os/excel-live/openExcelLive.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + cancelPendingSessionNavigation, + commitPendingSessionNavigation, +} from '@/app/navigation/navigationController'; +import { openProductAppRuntime } from '@/app/scenes/apps/product-app-runtime/productAppRuntimeService'; +import { + intelligentAppAPI, + type ActiveAppRef, + type IntelligentAppCatalog, +} from '@/infrastructure/api/service-api/IntelligentAppAPI'; +import { useExcelLiveLaunchStore } from './excelLiveLaunchStore'; +import { openExcelLive } from './openExcelLive'; + +const navigationMock = vi.hoisted(() => ({ epoch: 0 })); + +vi.mock('@/app/navigation/navigationController', () => ({ + beginNavigationIntent: vi.fn(() => ++navigationMock.epoch), + cancelPendingSessionNavigation: vi.fn(() => true), + commitPendingSessionNavigation: vi.fn(() => true), + getNavigationEpoch: vi.fn(() => navigationMock.epoch), +})); + +vi.mock('@/app/scenes/apps/product-app-runtime/productAppRuntimeService', () => ({ + openProductAppRuntime: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/IntelligentAppAPI', () => ({ + intelligentAppAPI: { + listCatalog: vi.fn(), + activeRef: vi.fn(), + }, +})); + +vi.mock('@/infrastructure/theme', () => ({ + useThemeStore: { + getState: () => ({ currentTheme: { type: 'light' } }), + }, +})); + +const excelApp: ActiveAppRef = { + slotId: 'builtin-excel-live', + appId: 'builtin-excel-live', + releaseId: 'release_excel_live', + configRevision: 'sha256:config', + dataSchemaVersion: '1.0.0', + runtime: { + launch: { + kind: 'applicationSurface', + targetId: 'builtin-excel-live', + scopeRequirement: 'workspaceOptional', + }, + primarySurface: { + componentId: 'builtin-excel-live-surface', + surfaceId: 'main', + }, + primarySurfaceMode: 'immersivePrimary', + workMultiplicity: 'singleton', + icon: { kind: 'lucide', name: 'Sheet' }, + category: 'productivity', + tags: ['excel'], + }, +}; + +const catalog = { + slots: [{ + slotId: excelApp.slotId, + displayName: 'Excel Live', + variants: [{ + app: { + appId: excelApp.appId, + slotId: excelApp.slotId, + displayName: 'Excel Live', + owner: { kind: 'system' }, + createdAtMs: 1, + }, + releases: [], + upstreamUpdateAvailable: false, + state: 'active', + }], + }], + drafts: [], +} satisfies IntelligentAppCatalog; + +describe('openExcelLive', () => { + beforeEach(() => { + navigationMock.epoch = 0; + vi.mocked(intelligentAppAPI.listCatalog).mockReset().mockResolvedValue(catalog); + vi.mocked(intelligentAppAPI.activeRef).mockReset().mockReturnValue(excelApp); + vi.mocked(openProductAppRuntime).mockReset().mockResolvedValue(undefined); + vi.mocked(commitPendingSessionNavigation).mockClear().mockReturnValue(true); + vi.mocked(cancelPendingSessionNavigation).mockClear().mockReturnValue(true); + useExcelLiveLaunchStore.setState({ pendingPaths: {} }); + }); + + it('paints a pending conversation shell before activation lookup completes', async () => { + let resolveCatalog!: (value: IntelligentAppCatalog) => void; + vi.mocked(intelligentAppAPI.listCatalog).mockReturnValueOnce( + new Promise(resolve => { resolveCatalog = resolve; }), + ); + + const opening = openExcelLive(); + + expect(commitPendingSessionNavigation).toHaveBeenCalledWith( + 'pending-excel-live:1', + { navigationEpoch: 1 }, + ); + expect(openProductAppRuntime).not.toHaveBeenCalled(); + + resolveCatalog(catalog); + await opening; + + expect(openProductAppRuntime).toHaveBeenCalledWith( + excelApp, + expect.objectContaining({ navigationEpoch: 1, theme: 'light' }), + ); + expect(cancelPendingSessionNavigation).toHaveBeenCalledWith(1); + }); + + it('fails explicitly when the Excel Live slot has no active Release', async () => { + vi.mocked(intelligentAppAPI.activeRef).mockReturnValueOnce(null); + + await expect(openExcelLive()).rejects.toThrow('no active compatible Release'); + expect(openProductAppRuntime).not.toHaveBeenCalled(); + }); + + it('does not let an older activation lookup steal navigation from a newer open', async () => { + let resolveFirst!: (value: IntelligentAppCatalog) => void; + vi.mocked(intelligentAppAPI.listCatalog) + .mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; })) + .mockResolvedValueOnce(catalog); + + const first = openExcelLive(); + const second = openExcelLive(); + await second; + resolveFirst(catalog); + await first; + + expect(openProductAppRuntime).toHaveBeenCalledTimes(1); + expect(openProductAppRuntime).toHaveBeenCalledWith( + excelApp, + expect.objectContaining({ navigationEpoch: 2 }), + ); + }); + + it('does not let an older same-file open clear the newer launch binding', async () => { + let releaseFirstRuntime!: () => void; + vi.mocked(openProductAppRuntime) + .mockImplementationOnce(() => new Promise(resolve => { releaseFirstRuntime = resolve; })) + .mockResolvedValueOnce(undefined); + + const first = openExcelLive({ filePath: 'C:\\files\\budget.xlsx' }); + await vi.waitFor(() => expect(openProductAppRuntime).toHaveBeenCalledTimes(1)); + const launchKey = vi.mocked(openProductAppRuntime).mock.calls[0][1]?.entityId; + expect(launchKey).toEqual(expect.stringContaining('excel-file:')); + + const second = openExcelLive({ filePath: 'C:\\files\\budget.xlsx' }); + await second; + expect(useExcelLiveLaunchStore.getState().peekPendingPath(launchKey!)) + .toBe('C:\\files\\budget.xlsx'); + + releaseFirstRuntime(); + await first; + + expect(useExcelLiveLaunchStore.getState().peekPendingPath(launchKey!)) + .toBe('C:\\files\\budget.xlsx'); + }); +}); diff --git a/src/web-ui/src/app/agentic-os/excel-live/openExcelLive.ts b/src/web-ui/src/app/agentic-os/excel-live/openExcelLive.ts new file mode 100644 index 00000000..9735f9bd --- /dev/null +++ b/src/web-ui/src/app/agentic-os/excel-live/openExcelLive.ts @@ -0,0 +1,120 @@ +import { openProductAppRuntime } from '@/app/scenes/apps/product-app-runtime/productAppRuntimeService'; +import { + beginNavigationIntent, + cancelPendingSessionNavigation, + commitPendingSessionNavigation, + getNavigationEpoch, +} from '@/app/navigation/navigationController'; +import { + intelligentAppAPI, + type ActiveAppRef, +} from '@/infrastructure/api/service-api/IntelligentAppAPI'; +import { useThemeStore } from '@/infrastructure/theme'; +import { appScopeFromWorkspacePath, systemAppScope } from '@/shared/types/app-scope'; +import { createLogger } from '@/shared/utils/logger'; +import { useExcelLiveLaunchStore } from './excelLiveLaunchStore'; + +const log = createLogger('ExcelLive'); + +const EXCEL_LIVE_APP_ID = 'builtin-excel-live'; + +function normalizeLaunchPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('//') + ? normalized.toLowerCase() + : normalized; +} + +async function launchKeyForPath(filePath: string): Promise { + const identity = normalizeLaunchPath(filePath); + if (globalThis.crypto?.subtle) { + const digest = await globalThis.crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(identity), + ); + const hex = [...new Uint8Array(digest)] + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + return `excel-file:${hex}`; + } + return `excel-file:${encodeURIComponent(identity)}`; +} + +async function resolveExcelLiveApp(): Promise { + const catalog = await intelligentAppAPI.listCatalog(); + const slot = catalog.slots.find((candidate) => ( + candidate.variants.some(({ app }) => app.appId === EXCEL_LIVE_APP_ID) + )); + const active = slot ? intelligentAppAPI.activeRef(slot) : null; + if (!active) { + throw new Error('Excel Live has no active compatible Release.'); + } + return active; +} + +export async function openExcelLive(options: { + workspacePath?: string | null; + filePath?: string | null; +} = {}): Promise { + const navigationEpoch = beginNavigationIntent(); + const launchOwnerToken = `excel-live-navigation:${navigationEpoch}`; + const isNavigationCurrent = () => navigationEpoch === getNavigationEpoch(); + const committed = commitPendingSessionNavigation( + `pending-excel-live:${navigationEpoch}`, + { navigationEpoch }, + ); + if (!committed) return; + + let launchKey: string | null = null; + try { + launchKey = options.filePath ? await launchKeyForPath(options.filePath) : null; + if (!isNavigationCurrent()) return; + + if (options.filePath && launchKey) { + useExcelLiveLaunchStore.getState().setPendingPath( + launchKey, + options.filePath, + launchOwnerToken, + ); + } + + const scope = options.workspacePath + ? appScopeFromWorkspacePath(options.workspacePath) || systemAppScope() + : systemAppScope(); + const entry = await resolveExcelLiveApp(); + if (!isNavigationCurrent()) return; + + log.info('Opening Excel Live', { + workspacePath: options.workspacePath || null, + filePath: options.filePath || null, + }); + + await openProductAppRuntime(entry, { + entityId: launchKey, + workspacePath: options.workspacePath || undefined, + scope, + title: 'Excel Live', + objective: options.filePath ? `Edit ${options.filePath}` : 'Open a workbook', + theme: useThemeStore.getState().currentTheme?.type ?? 'dark', + navigationEpoch, + isNavigationCurrent, + }); + } catch (error) { + if (launchKey) { + useExcelLiveLaunchStore.getState().clearPendingPath(launchKey, launchOwnerToken); + } + throw error; + } finally { + if (!isNavigationCurrent() && launchKey) { + useExcelLiveLaunchStore.getState().clearPendingPath(launchKey, launchOwnerToken); + } + cancelPendingSessionNavigation(navigationEpoch); + } +} + +export function isSpreadsheetFilePath(path: string): boolean { + const lower = path.toLowerCase(); + return lower.endsWith('.xlsx') + || lower.endsWith('.xlsm') + || lower.endsWith('.csv'); +} diff --git a/src/web-ui/src/app/agentic-os/work/data/workApi.ts b/src/web-ui/src/app/agentic-os/work/data/workApi.ts index bdd2969a..7aecdf5a 100644 --- a/src/web-ui/src/app/agentic-os/work/data/workApi.ts +++ b/src/web-ui/src/app/agentic-os/work/data/workApi.ts @@ -133,9 +133,10 @@ type RawMemoryRef = { type RawRuntimeInstanceRef = { id: string; - product_app_id: string; - app_version: string; - component_lock_digest: string; + slot_id: string; + app_id: string; + release_id: string; + config_revision: string; product_app_surface_id: string; surface_id: string; }; @@ -148,9 +149,11 @@ type RawWorkTitleState = { type RawWorkAppRef = { kind: WorkAppRef['kind']; + slot_id: string; app_id: string; - app_version?: string; - component_lock_digest?: string; + release_id: string; + config_revision: string; + data_schema_version: string; }; type RawWorkComponentRef = { @@ -386,18 +389,22 @@ function fromRawScope(scope: RawWorkScope): WorkScope { function toRawAppRef(app: WorkAppRef): RawWorkAppRef { return { kind: app.kind, + slot_id: app.slotId, app_id: app.appId, - app_version: app.appVersion ?? '', - component_lock_digest: app.componentLockDigest ?? '', + release_id: app.releaseId, + config_revision: app.configRevision, + data_schema_version: app.dataSchemaVersion, }; } function fromRawAppRef(app: RawWorkAppRef): WorkAppRef { return { kind: app.kind, + slotId: app.slot_id, appId: app.app_id, - appVersion: app.app_version || undefined, - componentLockDigest: app.component_lock_digest || undefined, + releaseId: app.release_id, + configRevision: app.config_revision, + dataSchemaVersion: app.data_schema_version, }; } @@ -613,9 +620,10 @@ function fromRawMemoryRef(ref: RawMemoryRef): MemoryRef { function fromRawRuntimeInstanceRef(ref: RawRuntimeInstanceRef): RuntimeInstanceRef { return { id: ref.id, - productAppId: ref.product_app_id, - appVersion: ref.app_version, - componentLockDigest: ref.component_lock_digest, + slotId: ref.slot_id, + appId: ref.app_id, + releaseId: ref.release_id, + configRevision: ref.config_revision, productAppSurfaceId: ref.product_app_surface_id, surfaceId: ref.surface_id, }; diff --git a/src/web-ui/src/app/agentic-os/work/domain/productAppRefs.ts b/src/web-ui/src/app/agentic-os/work/domain/productAppRefs.ts index 7edd4eed..3332dbf7 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/productAppRefs.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/productAppRefs.ts @@ -1,32 +1,44 @@ -import type { ProductAppCatalogEntry } from '@/infrastructure/api/service-api/AppCatalogAPI'; import type { WorkAppRef } from './workTypes'; -export function productAppWorkRef(app: ProductAppCatalogEntry): WorkAppRef { +type ImmutableProductAppRef = Pick< + WorkAppRef, + 'slotId' | 'appId' | 'releaseId' | 'configRevision' | 'dataSchemaVersion' +>; + +export function productAppWorkRef(app: ImmutableProductAppRef): WorkAppRef { return { kind: 'product_app', - appId: app.id, - appVersion: app.version, - componentLockDigest: app.componentLockDigest || app.componentLockId, + slotId: app.slotId, + appId: app.appId, + releaseId: app.releaseId, + configRevision: app.configRevision, + dataSchemaVersion: app.dataSchemaVersion, }; } +/** Core-owned surfaces still receive an explicit, stable execution identity. */ export function nativeAppWorkRef(appId: string): WorkAppRef { return { kind: 'native_app', + slotId: appId, appId, + releaseId: `core-${appId}`, + configRevision: 'core-default', + dataSchemaVersion: '1', }; } export function sameProductAppRef(left: WorkAppRef, right: WorkAppRef): boolean { - return left.kind === right.kind - && left.appId === right.appId - && left.appVersion === right.appVersion - && left.componentLockDigest === right.componentLockDigest; + return left.kind === 'product_app' + && right.kind === 'product_app' + && sameAppRef(left, right); } export function sameAppRef(left: WorkAppRef, right: WorkAppRef): boolean { - if (left.kind !== right.kind || left.appId !== right.appId) return false; - if (left.kind === 'native_app') return true; - return left.appVersion === right.appVersion - && left.componentLockDigest === right.componentLockDigest; + return left.kind === right.kind + && left.slotId === right.slotId + && left.appId === right.appId + && left.releaseId === right.releaseId + && left.configRevision === right.configRevision + && left.dataSchemaVersion === right.dataSchemaVersion; } diff --git a/src/web-ui/src/app/agentic-os/work/domain/workStatus.test.ts b/src/web-ui/src/app/agentic-os/work/domain/workStatus.test.ts index 3309b0b7..b4d03e46 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/workStatus.test.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/workStatus.test.ts @@ -17,9 +17,11 @@ function workWithBindings( kind: 'app', app: { kind: 'product_app', + slotId: 'primary', appId: 'product-app-1', - appVersion: '1.0.0', - componentLockDigest: 'sha256:test', + releaseId: 'release-product-app-1', + configRevision: 'sha256:config', + dataSchemaVersion: '1', }, intent: 'run', }, diff --git a/src/web-ui/src/app/agentic-os/work/domain/workSurface.ts b/src/web-ui/src/app/agentic-os/work/domain/workSurface.ts index c9ea2965..cc57f7ed 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/workSurface.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/workSurface.ts @@ -6,6 +6,49 @@ export function findWorkSessionSurface(work: WorkRecord): Extract number | undefined, + isSessionCompatible?: (sessionId: string) => boolean | undefined, +): Extract | null { + // Work bindings are append-only. Prefer the most recently linked session so + // singleton Product Apps (for example one Excel Work serving several files) + // do not keep reopening their oldest conversation. + const candidates = work.surfaces + .filter((surface): surface is Extract => ( + surface.kind === 'agent_session' + )) + .map(surface => ({ + surface, + compatibility: isSessionCompatible?.(surface.sessionId), + })) + .filter(candidate => candidate.compatibility !== false); + return candidates + .map((candidate, index) => ({ + ...candidate, + index, + recency: getSessionRecency?.(candidate.surface.sessionId), + })) + .sort((left, right) => { + if (left.compatibility !== right.compatibility) { + return Number(right.compatibility === true) - Number(left.compatibility === true); + } + if (left.recency !== undefined && right.recency !== undefined) { + const recencyOrder = right.recency - left.recency; + if (recencyOrder !== 0) return recencyOrder; + } + // Partial metadata preload is not evidence that an unknown candidate is + // older; retain append order until both sides can be compared. + return right.index - left.index; + })[0]?.surface ?? null; +} + export function resolveDefaultWorkSurface(work: WorkRecord): WorkSurfaceRef { const workSession = findWorkSessionSurface(work); if (workSession) return workSession; diff --git a/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts b/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts index 6ea5ca85..7db7bda4 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts @@ -41,9 +41,11 @@ export type WorkAppKind = 'native_app' | 'product_app'; export interface WorkAppRef { kind: WorkAppKind; + slotId: string; appId: string; - appVersion?: string; - componentLockDigest?: string; + releaseId: string; + configRevision: string; + dataSchemaVersion: string; } export type WorkAppIntent = 'use' | 'run' | 'develop' | 'debug' | 'edit' | 'review'; @@ -167,9 +169,10 @@ export interface MemoryRef { export interface RuntimeInstanceRef { id: string; - productAppId: string; - appVersion: string; - componentLockDigest: string; + slotId: string; + appId: string; + releaseId: string; + configRevision: string; productAppSurfaceId: string; surfaceId: string; } diff --git a/src/web-ui/src/app/agentic-os/work/navigation/openWork.test.ts b/src/web-ui/src/app/agentic-os/work/navigation/openWork.test.ts index 08006778..b6ca3827 100644 --- a/src/web-ui/src/app/agentic-os/work/navigation/openWork.test.ts +++ b/src/web-ui/src/app/agentic-os/work/navigation/openWork.test.ts @@ -1,7 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useWorkDockStore } from '@/app/stores/workDockStore'; -import { openArtifactInCenter, openWorkInCenter } from './openWork'; +import { openArtifactInCenter, openWork, openWorkInCenter } from './openWork'; import { openWorkspaceScene } from '@/app/navigation/workspaceNavigation'; +import { openMainSession } from '@/flow_chat/services/childSessionPanels'; +import { openProductAppRuntimeForWorkSurface } from '@/app/scenes/apps/product-app-runtime/productAppRuntimeService'; +import type { WorkRecord, WorkSurfaceRef } from '../domain/workTypes'; +import { + cancelPendingSessionNavigation, + commitPendingSessionNavigation, +} from '@/app/navigation/navigationController'; + +const navigationMock = vi.hoisted(() => ({ epoch: 0 })); vi.mock('@/app/navigation/workspaceNavigation', () => ({ openWorkspaceScene: vi.fn(), @@ -9,6 +18,12 @@ vi.mock('@/app/navigation/workspaceNavigation', () => ({ vi.mock('@/flow_chat/services/childSessionPanels', () => ({ openMainSession: vi.fn(), })); +vi.mock('@/app/navigation/navigationController', () => ({ + beginNavigationIntent: vi.fn(() => ++navigationMock.epoch), + cancelPendingSessionNavigation: vi.fn(() => true), + commitPendingSessionNavigation: vi.fn(() => true), + getNavigationEpoch: vi.fn(() => navigationMock.epoch), +})); vi.mock('@/app/scenes/apps/product-app-runtime/productAppRuntimeService', () => ({ openProductAppRuntimeForWorkSurface: vi.fn(), })); @@ -25,10 +40,51 @@ function resetWorkDockStore() { }); } +const applicationSurface: WorkSurfaceRef = { + kind: 'application_surface', + productAppId: 'builtin-excel-live', + productAppSurfaceId: 'excel-surface', + surfaceId: 'primary', +}; + +function appWork(id: string, surfaces: WorkSurfaceRef[]): WorkRecord { + return { + id, + kind: 'app_workflow', + title: `Work ${id}`, + objective: 'Edit a workbook', + status: 'active', + visibility: 'primary', + subject: { + kind: 'app', + app: { kind: 'product_app', appId: 'builtin-excel-live' }, + intent: 'run', + }, + appRefs: [], + scope: { kind: 'system' }, + primarySurface: applicationSurface, + surfaces, + lifecycle: { events: [] }, + sessionRefs: [], + executionBindings: [], + runtimeInstances: [], + artifactRefs: [], + memoryRefs: [], + systemManaged: false, + createdAt: 1, + updatedAt: 1, + }; +} + describe('openWork navigation', () => { beforeEach(() => { resetWorkDockStore(); + navigationMock.epoch = 0; vi.mocked(openWorkspaceScene).mockClear(); + vi.mocked(openMainSession).mockReset().mockResolvedValue('opened'); + vi.mocked(openProductAppRuntimeForWorkSurface).mockReset().mockResolvedValue(undefined); + vi.mocked(commitPendingSessionNavigation).mockClear(); + vi.mocked(cancelPendingSessionNavigation).mockClear(); }); it('opens an artifact in its owner Work Center context', () => { @@ -50,4 +106,69 @@ describe('openWork navigation', () => { expect(state.workCenterSelectedWorkId).toBe('work_2'); expect(state.workCenterSelectedArtifactId).toBeNull(); }); + + it('opens an existing composite Product App session before runtime resolution', async () => { + const linkedSession: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'excel-session' }; + + await openWork(appWork('work-excel', [applicationSurface, linkedSession])); + + expect(openMainSession).toHaveBeenCalledWith('excel-session', { + context: { kind: 'work', workId: 'work-excel' }, + commitPendingSurface: true, + navigationEpoch: 1, + }); + expect(openProductAppRuntimeForWorkSurface).not.toHaveBeenCalled(); + }); + + it('coalesces repeated opens while the same Work is preparing', async () => { + let release!: () => void; + vi.mocked(openProductAppRuntimeForWorkSurface).mockImplementation(() => ( + new Promise((resolve) => { release = resolve; }) + )); + const work = appWork('work-slow', [applicationSurface]); + + const first = openWork(work); + const second = openWork(work); + await Promise.resolve(); + + expect(openProductAppRuntimeForWorkSurface).toHaveBeenCalledTimes(1); + expect(commitPendingSessionNavigation).toHaveBeenCalledWith( + 'pending-work:work-slow', + expect.objectContaining({ + context: { kind: 'work', workId: 'work-slow' }, + navigationEpoch: 1, + }), + ); + release(); + await Promise.all([first, second]); + expect(cancelPendingSessionNavigation).toHaveBeenCalledWith(1); + }); + + it('invalidates an older Work preparation when a newer Work is selected', async () => { + const guards = new Map boolean>(); + vi.mocked(openProductAppRuntimeForWorkSurface).mockImplementation(async (request, options) => { + guards.set(request.workId, options.isNavigationCurrent!); + }); + + await openWork(appWork('work-a', [applicationSurface])); + const guardA = guards.get('work-a')!; + expect(guardA()).toBe(true); + + await openWork(appWork('work-b', [applicationSurface])); + + expect(guardA()).toBe(false); + expect(guards.get('work-b')?.()).toBe(true); + }); + + it('falls back to the application surface when a linked session is missing', async () => { + vi.mocked(openMainSession).mockResolvedValueOnce('missing'); + const linkedSession: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'deleted-session' }; + + await openWork(appWork('work-fallback', [applicationSurface, linkedSession])); + + expect(openProductAppRuntimeForWorkSurface).toHaveBeenCalledWith( + expect.objectContaining({ workId: 'work-fallback', appId: 'builtin-excel-live' }), + expect.objectContaining({ context: { kind: 'work', workId: 'work-fallback' } }), + ); + }); }); diff --git a/src/web-ui/src/app/agentic-os/work/navigation/openWork.ts b/src/web-ui/src/app/agentic-os/work/navigation/openWork.ts index 28b8c3c0..e6ecd1b5 100644 --- a/src/web-ui/src/app/agentic-os/work/navigation/openWork.ts +++ b/src/web-ui/src/app/agentic-os/work/navigation/openWork.ts @@ -1,8 +1,17 @@ import { openMainSession } from '@/flow_chat/services/childSessionPanels'; import { openWorkspaceScene } from '@/app/navigation/workspaceNavigation'; +import { + beginNavigationIntent, + cancelPendingSessionNavigation, + commitPendingSessionNavigation, + getNavigationEpoch, + type OpenWorkspaceSessionResult, +} from '@/app/navigation/navigationController'; import { useWorkDockStore } from '@/app/stores/workDockStore'; import type { RuntimeInstanceRef, WorkRecord, WorkSurfaceRef } from '../domain/workTypes'; import { resolveWorkSurface } from './workSurfaceResolver'; +import { resolveDefaultWorkSurface } from '../domain/workSurface'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { openProductAppRuntimeForWorkSurface } from '@/app/scenes/apps/product-app-runtime/productAppRuntimeService'; import type { WorkspaceSurfaceContext } from '@/app/navigation/workspaceSurfaceTypes'; import { appScopeFromWorkspacePath, systemAppScope, type AppScope } from '@/shared/types/app-scope'; @@ -12,6 +21,16 @@ import { systemRuntimeScope, type RuntimeScope, } from '@/shared/types/runtime-scope'; +import { useThemeStore } from '@/infrastructure/theme'; + +interface PendingWorkOpen { + intent: number; + navigationEpoch: number; + promise: Promise; +} + +let workOpenIntent = 0; +const pendingWorkOpens = new Map(); function runtimeScopeFromWork(work: WorkRecord): RuntimeScope { return work.scope.kind === 'workspace' @@ -31,12 +50,13 @@ function runtimeInstanceForSurface(work: WorkRecord, surface: WorkSurfaceRef): R if (surface.kind !== 'application_surface') return null; const appRef = productAppRefForSurface(work, surface); return work.runtimeInstances.find(instance => - instance.productAppId === surface.productAppId && + instance.appId === surface.productAppId && instance.productAppSurfaceId === surface.productAppSurfaceId && instance.surfaceId === surface.surfaceId && (!appRef || ( - instance.appVersion === appRef.appVersion && - instance.componentLockDigest === appRef.componentLockDigest + instance.slotId === appRef.slotId && + instance.releaseId === appRef.releaseId && + instance.configRevision === appRef.configRevision )) ) ?? null; } @@ -85,17 +105,76 @@ export function openWorkCenterForApp(app: WorkAppRef): void { openWorkspaceScene('work-center'); } -export async function openWork(work: WorkRecord): Promise { - const surface = resolveWorkSurface(work); +async function performOpenWork( + work: WorkRecord, + isNavigationCurrent: () => boolean, + navigationEpoch: number, +): Promise { + if (!isNavigationCurrent()) return; + const defaultSurface = resolveDefaultWorkSurface(work); + const surface = resolveWorkSurface(work, { + getSessionRecency: (sessionId) => { + const session = flowChatStore.getState().sessions.get(sessionId); + return session?.lastActiveAt ?? session?.createdAt; + }, + isLinkedSessionCompatible: (sessionId) => { + if (defaultSurface.kind !== 'application_surface') return false; + const session = flowChatStore.getState().sessions.get(sessionId); + if (!session) return undefined; + const binding = session.customMetadata?.productAppRuntime; + if (!binding || binding.appId !== defaultSurface.productAppId) return false; + const boundWorkId = binding.runtimeContext?.workId; + return !boundWorkId || boundWorkId === work.id; + }, + }); const runtimeScope = runtimeScopeFromWork(work); - await openWorkSurface(surface, work.id, { + const openSurface = (target: WorkSurfaceRef) => openWorkSurface(target, work.id, { runtimeScope, - productAppRef: productAppRefForSurface(work, surface), - runtimeInstance: runtimeInstanceForSurface(work, surface), + productAppRef: productAppRefForSurface(work, target), + runtimeInstance: runtimeInstanceForSurface(work, target), scope: work.scope.kind === 'workspace' ? appScopeFromWorkspacePath(work.scope.workspacePath) ?? systemAppScope() : systemAppScope(), + isNavigationCurrent, + navigationEpoch, }); + const result = await openSurface(surface); + + if (result === 'missing' && surface.kind === 'agent_session' && isNavigationCurrent()) { + if (defaultSurface.kind === 'application_surface') { + await openSurface(defaultSurface); + } + } +} + +export async function openWork(work: WorkRecord): Promise { + const intent = ++workOpenIntent; + const existing = pendingWorkOpens.get(work.id); + if (existing && existing.navigationEpoch === getNavigationEpoch()) { + // Re-selecting the same in-flight Work joins its preparation instead of + // starting another catalog/runtime resolution chain. + existing.intent = intent; + return existing.promise; + } + + const navigationEpoch = beginNavigationIntent(); + const pending: PendingWorkOpen = { + intent, + navigationEpoch, + promise: Promise.resolve(), + }; + const isNavigationCurrent = () => ( + pending.intent === workOpenIntent && + pending.navigationEpoch === getNavigationEpoch() + ); + + pending.promise = performOpenWork(work, isNavigationCurrent, navigationEpoch).finally(() => { + if (pendingWorkOpens.get(work.id) === pending) { + pendingWorkOpens.delete(work.id); + } + }); + pendingWorkOpens.set(work.id, pending); + return pending.promise; } export async function openWorkSurface( @@ -106,36 +185,66 @@ export async function openWorkSurface( runtimeScope?: RuntimeScope | null; productAppRef?: WorkAppRef | null; runtimeInstance?: RuntimeInstanceRef | null; + isNavigationCurrent?: () => boolean; + navigationEpoch?: number; } = {} -): Promise { +): Promise { + if (options.isNavigationCurrent?.() === false) return 'superseded'; const context: WorkspaceSurfaceContext = { kind: 'work', workId: fallbackWorkId }; switch (surface.kind) { case 'work_session': case 'agent_session': - await openMainSession(surface.sessionId, { context }); - return; + return openMainSession(surface.sessionId, { + context, + commitPendingSurface: true, + navigationEpoch: options.navigationEpoch, + }); case 'work_center': openWorkInCenter(surface.workId); - return; + return 'opened'; case 'application_surface': - await openProductAppRuntimeForWorkSurface({ - workId: fallbackWorkId, - productAppId: surface.productAppId, - runtimeInstanceId: options.runtimeInstance?.id, - productAppVersion: options.productAppRef?.appVersion, - componentLockDigest: options.productAppRef?.componentLockDigest, - productAppSurfaceId: surface.productAppSurfaceId, - surfaceId: surface.surfaceId, - }, { - scope: options.scope ?? systemAppScope(), - context, - }); - return; + if (!options.productAppRef) { + throw new Error(`Work ${fallbackWorkId} has no immutable App binding for ${surface.productAppId}`); + } + if (options.navigationEpoch !== undefined) { + const committed = commitPendingSessionNavigation( + `pending-work:${fallbackWorkId}`, + { + context, + navigationEpoch: options.navigationEpoch, + }, + ); + if (!committed) return 'superseded'; + } + try { + await openProductAppRuntimeForWorkSurface({ + workId: fallbackWorkId, + slotId: options.productAppRef.slotId, + appId: surface.productAppId, + releaseId: options.productAppRef.releaseId, + configRevision: options.productAppRef.configRevision, + runtimeInstanceId: options.runtimeInstance?.id, + productAppSurfaceId: surface.productAppSurfaceId, + surfaceId: surface.surfaceId, + }, { + scope: options.scope ?? systemAppScope(), + context, + theme: useThemeStore.getState().currentTheme?.type ?? 'dark', + navigationEpoch: options.navigationEpoch, + isNavigationCurrent: options.isNavigationCurrent, + }); + return 'opened'; + } finally { + if (options.navigationEpoch !== undefined) { + cancelPendingSessionNavigation(options.navigationEpoch); + } + } case 'os_agent_home': openWorkInCenter(fallbackWorkId); - return; + return 'opened'; default: openWorkInCenter(fallbackWorkId); + return 'opened'; } } diff --git a/src/web-ui/src/app/agentic-os/work/navigation/workSurfaceResolver.test.ts b/src/web-ui/src/app/agentic-os/work/navigation/workSurfaceResolver.test.ts new file mode 100644 index 00000000..112635ab --- /dev/null +++ b/src/web-ui/src/app/agentic-os/work/navigation/workSurfaceResolver.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkRecord, WorkSurfaceRef } from '../domain/workTypes'; +import { resolveWorkSurface } from './workSurfaceResolver'; + +const applicationSurface: WorkSurfaceRef = { + kind: 'application_surface', + productAppId: 'builtin-excel-live', + productAppSurfaceId: 'excel-surface', + surfaceId: 'primary', +}; + +function workWithSurfaces(surfaces: WorkSurfaceRef[]): WorkRecord { + return { + id: 'work-1', + kind: 'app_workflow', + title: 'Excel Live', + objective: 'Edit a workbook', + status: 'active', + visibility: 'primary', + subject: { kind: 'goal' }, + appRefs: [], + scope: { kind: 'system' }, + primarySurface: applicationSurface, + surfaces, + lifecycle: { events: [] }, + sessionRefs: [], + executionBindings: [], + runtimeInstances: [], + artifactRefs: [], + memoryRefs: [], + systemManaged: false, + createdAt: 1, + updatedAt: 1, + }; +} + +describe('resolveWorkSurface', () => { + it('resumes a composite Product App through its linked conversation shell', () => { + const linkedSession: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'excel-session' }; + + expect(resolveWorkSurface(workWithSurfaces([applicationSurface, linkedSession]))) + .toEqual(linkedSession); + }); + + it('keeps the application surface for a Work without a linked session', () => { + expect(resolveWorkSurface(workWithSurfaces([applicationSurface]))) + .toEqual(applicationSurface); + }); + + it('keeps an owned Work session ahead of linked application sessions', () => { + const workSession: WorkSurfaceRef = { kind: 'work_session', sessionId: 'work-session' }; + const linkedSession: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'app-session' }; + + expect(resolveWorkSurface(workWithSurfaces([applicationSurface, linkedSession, workSession]))) + .toEqual(workSession); + }); + + it('prefers the most recent linked Product App session', () => { + const older: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'older' }; + const newer: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'newer' }; + + expect(resolveWorkSurface( + workWithSurfaces([applicationSurface, newer, older]), + { getSessionRecency: id => (id === 'newer' ? 20 : 10) }, + )).toEqual(newer); + }); + + it('keeps newest-link order when only older session metadata is preloaded', () => { + const older: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'older-known' }; + const newest: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'newest-not-loaded' }; + + expect(resolveWorkSurface( + workWithSurfaces([applicationSurface, older, newest]), + { getSessionRecency: id => (id === 'older-known' ? 10 : undefined) }, + )).toEqual(newest); + }); + + it('ignores a more active linked session owned by another interaction', () => { + const excel: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'excel-session' }; + const unrelated: WorkSurfaceRef = { kind: 'agent_session', sessionId: 'runno-session' }; + + expect(resolveWorkSurface( + workWithSurfaces([applicationSurface, excel, unrelated]), + { + getSessionRecency: id => (id === 'runno-session' ? 30 : 20), + isLinkedSessionCompatible: id => id === 'excel-session', + }, + )).toEqual(excel); + }); +}); diff --git a/src/web-ui/src/app/agentic-os/work/navigation/workSurfaceResolver.ts b/src/web-ui/src/app/agentic-os/work/navigation/workSurfaceResolver.ts index 79f727dc..350c1daa 100644 --- a/src/web-ui/src/app/agentic-os/work/navigation/workSurfaceResolver.ts +++ b/src/web-ui/src/app/agentic-os/work/navigation/workSurfaceResolver.ts @@ -1,6 +1,32 @@ import type { WorkRecord, WorkSurfaceRef } from '../domain/workTypes'; -import { resolveDefaultWorkSurface } from '../domain/workSurface'; +import { + findLinkedAgentSessionSurface, + resolveDefaultWorkSurface, +} from '../domain/workSurface'; -export function resolveWorkSurface(work: WorkRecord): WorkSurfaceRef { - return resolveDefaultWorkSurface(work); +export interface ResolveWorkSurfaceOptions { + getSessionRecency?: (sessionId: string) => number | undefined; + isLinkedSessionCompatible?: (sessionId: string) => boolean | undefined; +} + +export function resolveWorkSurface( + work: WorkRecord, + options: ResolveWorkSurfaceOptions = {}, +): WorkSurfaceRef { + const defaultSurface = resolveDefaultWorkSurface(work); + + // The persisted primary surface still describes where attention belongs. + // For a composite Product App, however, its linked agent session is the + // cheapest complete resume shell: it owns both transcript metadata and the + // application sidecar binding. Do not force every resume through runtime + // discovery again once that shell exists. + if (defaultSurface.kind === 'application_surface') { + return findLinkedAgentSessionSurface( + work, + options.getSessionRecency, + options.isLinkedSessionCompatible, + ) ?? defaultSurface; + } + + return defaultSurface; } diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss index 5272b402..ea6b5a28 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss @@ -1,7 +1,4 @@ -/** - * About dialog — "ignition plaque" surface. - * Zone A brand stage (story) -> Zone B spec sheet (facts) -> Zone C link rail (index). - */ +/** About dialog — compact system identity card. */ .ds-dialog-overlay.sparo-about-dialog-overlay { padding: var(--ds-space-6) var(--ds-space-8); @@ -12,7 +9,7 @@ overflow: hidden; background: var(--ds-color-bg-elevated); box-shadow: var(--ds-shadow-modal); - max-width: 440px; + max-width: 480px; } .ds-dialog__header { @@ -48,361 +45,266 @@ } } -// ==================== Shell ==================== - .sparo-about-dialog { position: relative; display: flex; flex-direction: column; overflow: hidden; + padding: 30px; } -// ==================== Zone A — brand stage ==================== - -.sparo-about-dialog__stage { - position: relative; +.sparo-about-dialog__identity { display: flex; - flex-direction: column; align-items: center; - padding: 40px 32px 32px; - text-align: center; - background: var(--ds-color-bg-scene); + gap: 16px; + padding-inline-end: 28px; } -// ---- Ignition mark ---- - .sparo-about-dialog__mark { - position: relative; display: flex; align-items: center; justify-content: center; - width: 72px; - height: 72px; - margin-bottom: 18px; - padding: 0; - border: none; - background: none; - cursor: pointer; - animation: sparo-about-pop 260ms var(--ds-easing-standard, ease) both; - - &:focus-visible { - outline: 2px solid var(--ds-focus-ring); - outline-offset: 4px; - border-radius: var(--ds-radius-full, 50%); - } + flex: 0 0 auto; + width: 58px; + height: 58px; } .sparo-about-dialog__logo { - width: 56px; - height: 56px; + width: 42px; + height: 42px; object-fit: contain; user-select: none; -webkit-user-drag: none; } -.sparo-about-dialog__mark-ring { - position: absolute; - inset: -4px; - border-radius: 50%; - border: 1.5px solid var(--ds-color-border-medium); - opacity: 0; +.sparo-about-dialog__identity-copy { + min-width: 0; } -.sparo-about-dialog__mark--active .sparo-about-dialog__mark-ring { - animation: sparo-mark-ring 480ms ease-out; +.sparo-about-dialog__title-line { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; } -// ---- Identity copy ---- - .sparo-about-dialog__title { - position: relative; - z-index: var(--ds-z-local); margin: 0; font-family: var(--ds-font-family-sans); - font-size: 28px; - font-weight: 700; - letter-spacing: -0.03em; - line-height: 1.15; + font-size: 22px; + font-weight: 650; + letter-spacing: -0.025em; + line-height: 1.2; color: var(--ds-color-text-primary); - animation: sparo-about-rise 200ms var(--ds-easing-standard, ease) 40ms both; } .sparo-about-dialog__tagline { - position: relative; - z-index: var(--ds-z-local); - margin: 8px 0 0; - max-width: 280px; + margin: 5px 0 0; font-size: 13px; - font-weight: 400; - line-height: 1.45; - letter-spacing: 0.01em; + line-height: 1.5; color: var(--ds-color-text-secondary); - animation: sparo-about-rise 220ms var(--ds-easing-standard, ease) 70ms both; } .sparo-about-dialog__version-badge { - position: relative; - z-index: var(--ds-z-local); - margin-top: 16px; font-family: var(--ds-font-family-mono); - font-size: 11px; - letter-spacing: 0.03em; - animation: sparo-about-rise 240ms var(--ds-easing-standard, ease) 90ms both; -} - -// ==================== Zone B — spec sheet ==================== - -.sparo-about-dialog__specsheet { - position: relative; - z-index: var(--ds-z-local); - margin: -18px 24px 0; - border-radius: var(--ds-radius-lg); - box-shadow: var(--ds-shadow-card); - animation: sparo-about-rise 260ms var(--ds-easing-standard, ease) 120ms both; + font-size: 10px; + letter-spacing: 0.02em; } -.sparo-about-dialog__specsheet-body { - padding: 12px 16px; +.sparo-about-dialog__build { + margin-top: 26px; + padding: 16px 0; + border-block: 1px solid var(--ds-color-border-subtle); } -.sparo-about-dialog__specsheet-header { +.sparo-about-dialog__build-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; - margin-bottom: 6px; + margin-bottom: 12px; } -.sparo-about-dialog__specsheet-title { +.sparo-about-dialog__build-title { font-size: 10px; font-weight: 600; - letter-spacing: 0.08em; + letter-spacing: 0.1em; text-transform: uppercase; color: var(--ds-color-text-muted); } -.sparo-about-dialog__specsheet-copy { +.sparo-about-dialog__build-copy { color: var(--ds-color-text-muted); } -.sparo-about-dialog__specsheet-list { +.sparo-about-dialog__build-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; margin: 0; } -.sparo-about-dialog__specsheet-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 7px 0; - border-top: 1px solid var(--ds-color-border-subtle); +.sparo-about-dialog__build-item { + min-width: 0; - &:first-child { - border-top: none; + dt, + dd { + margin: 0; } dt { - display: inline-flex; - align-items: center; - gap: 6px; - margin: 0; - font-size: 12px; + margin-bottom: 5px; + font-size: 10px; font-weight: 500; - color: var(--ds-color-text-secondary); - flex-shrink: 0; - - svg { - color: var(--ds-color-text-muted); - } + color: var(--ds-color-text-muted); } dd { - margin: 0; + font-family: var(--ds-font-family-mono); font-size: 12px; - font-weight: 400; + font-weight: 500; + letter-spacing: 0.01em; color: var(--ds-color-text-primary); - text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - min-width: 0; } } -.sparo-about-dialog__specsheet-value--mono { - font-family: var(--ds-font-family-mono); - font-size: 11px; - font-weight: 500; - letter-spacing: 0.02em; +.sparo-about-dialog__links { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: stretch; + margin-top: 18px; + padding-block: 4px; + border-block: 1px solid var(--ds-color-border-subtle); } -// ==================== Zone C — link rail + footer ==================== - -.sparo-about-dialog__links { - position: relative; - z-index: var(--ds-z-local); +.sparo-about-dialog__link-cell { display: flex; - align-items: center; - justify-content: center; - gap: 4px; - margin: 18px 0 0; - animation: sparo-about-rise 280ms var(--ds-easing-standard, ease) 140ms both; + min-width: 0; + + & + & { + border-inline-start: 1px solid var(--ds-color-border-subtle); + } } -.sparo-about-dialog__link-btn { - color: var(--ds-color-text-muted); - transition: color var(--ds-motion-fast, 0.15s) var(--ds-easing-standard, ease); +.sparo-about-dialog__link { + width: 100%; + min-width: 0; + min-height: 44px; + justify-content: flex-start; + gap: 8px; + padding-inline: 10px; + color: var(--ds-color-text-secondary); - &:hover { + &:hover .sparo-about-dialog__link-icon { color: var(--ds-color-accent-500); } + + &:hover .sparo-about-dialog__link-arrow { + color: var(--ds-color-text-secondary); + transform: translate(1px, -1px); + } } -.sparo-about-dialog__footer { - position: relative; - z-index: var(--ds-z-local); - margin-top: 18px; - padding: 0 28px 28px; +.sparo-about-dialog__link-icon { display: flex; - flex-direction: column; align-items: center; - gap: 6px; - animation: sparo-about-rise 300ms var(--ds-easing-standard, ease) 150ms both; + justify-content: center; + flex: 0 0 auto; + width: 24px; + height: 24px; + border-radius: var(--ds-radius-md); + background: var(--ds-color-bg-hover); + color: var(--ds-color-text-muted); + transition: color var(--ds-motion-fast) var(--ds-easing-standard); } -.sparo-about-dialog__license { - padding: 0; - border: none; - background: none; - font-family: inherit; +.sparo-about-dialog__link-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 11px; font-weight: 500; - color: var(--ds-color-text-secondary); - text-decoration: none; - letter-spacing: 0.02em; - cursor: pointer; - transition: color var(--ds-motion-fast, 0.15s) var(--ds-easing-standard, ease); - - &:hover { - color: var(--ds-color-text-primary); - } - - &:focus-visible { - outline: 2px solid var(--ds-focus-ring); - outline-offset: 2px; - border-radius: var(--ds-radius-xs); - } } -.sparo-about-dialog__copyright { - margin: 0; - font-size: 10px; - letter-spacing: 0.02em; +.sparo-about-dialog__link-arrow { + flex: 0 0 auto; + margin-inline-start: auto; color: var(--ds-color-text-muted); - opacity: 0.75; + transition: + color var(--ds-motion-fast) var(--ds-easing-standard), + transform var(--ds-motion-fast) var(--ds-easing-standard); } -// ==================== Motion ==================== - -@keyframes sparo-about-rise { - from { - opacity: 0; - transform: translateY(6px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes sparo-about-pop { - from { - opacity: 0; - transform: scale(0.9); - } - - to { - opacity: 1; - transform: scale(1); - } +.sparo-about-dialog__footer { + margin-top: 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } -@keyframes sparo-mark-ring { - 0% { - opacity: 0.6; - transform: scale(0.85); - } - - 100% { - opacity: 0; - transform: scale(1.15); - } +.sparo-about-dialog__license { + flex: 0 0 auto; + min-height: 0; + padding: 0; + font-size: 10px; + color: var(--ds-color-text-muted); } -@media (prefers-reduced-motion: reduce) { - .sparo-about-dialog__mark, - .sparo-about-dialog__title, - .sparo-about-dialog__tagline, - .sparo-about-dialog__version-badge, - .sparo-about-dialog__specsheet, - .sparo-about-dialog__links, - .sparo-about-dialog__footer, - .sparo-about-dialog__mark-ring { - animation: none !important; - } +.sparo-about-dialog__copyright { + margin: 0; + font-size: 10px; + line-height: 1.4; + text-align: right; + color: var(--ds-color-text-muted); } -// ==================== Responsive ==================== - -@media (max-width: 580px) { - .sparo-about-dialog__stage { - padding: 32px 24px 28px; - } - - .sparo-about-dialog__title { - font-size: 22px; - } - - .sparo-about-dialog__tagline { - max-width: 240px; - font-size: 12px; - } - - .sparo-about-dialog__specsheet { - margin: -14px 16px 0; - } - - .sparo-about-dialog__footer { - padding: 0 20px 22px; +@media (max-width: 480px) { + .sparo-about-dialog { + padding: 24px; } -} -@media (max-height: 560px) { - .sparo-about-dialog__stage { - padding: 28px 24px 24px; + .sparo-about-dialog__identity { + align-items: flex-start; + gap: 14px; + padding-inline-end: 24px; } .sparo-about-dialog__mark { - width: 60px; - height: 60px; - margin-bottom: 12px; + width: 52px; + height: 52px; } .sparo-about-dialog__logo { - width: 48px; - height: 48px; + width: 38px; + height: 38px; } - .sparo-about-dialog__title { - font-size: 20px; + .sparo-about-dialog__build-grid { + grid-template-columns: 1fr; + gap: 12px; } .sparo-about-dialog__links { - margin-top: 12px; + grid-template-columns: 1fr; + } + + .sparo-about-dialog__link-cell + .sparo-about-dialog__link-cell { + border-block-start: 1px solid var(--ds-color-border-subtle); + border-inline-start: none; } .sparo-about-dialog__footer { - margin-top: 12px; + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + + .sparo-about-dialog__copyright { + text-align: left; } } diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index f56dba22..3f29c37a 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -1,13 +1,12 @@ /** - * About dialog — "ignition plaque" for Sparo OS. - * Three zones: brand stage (story), spec sheet (facts), link rail (index). + * About dialog — a compact system identity card for Sparo OS. */ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useState } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { systemAPI } from '@/infrastructure/api'; -import { Badge, Dialog, IconButton, Panel, PanelBody } from '@/design-system'; -import { Bug, BookOpen, Calendar, Check, Copy, Github, GitBranch, GitCommit } from 'lucide-react'; +import { Badge, Button, Dialog, IconButton } from '@/design-system'; +import { ArrowUpRight, Bug, BookOpen, Check, Copy, Github } from 'lucide-react'; import { getAboutInfo, formatVersion, @@ -25,17 +24,12 @@ interface AboutDialogProps { onClose: () => void; } -/** Reset delay for the click-to-pulse mark interaction. */ -const MARK_PULSE_RESET_MS = 500; - export const AboutDialog: React.FC = ({ isOpen, onClose }) => { const { t } = useI18n('common'); const [copied, setCopied] = useState(false); - const [pulsing, setPulsing] = useState(false); - const pulseTimeoutRef = useRef | null>(null); const aboutInfo = getAboutInfo(); const { version, license, links } = aboutInfo; @@ -60,17 +54,6 @@ export const AboutDialog: React.FC = ({ } }; - const handleMarkClick = useCallback(() => { - // Restart the animation even if it is already playing. - setPulsing(false); - requestAnimationFrame(() => setPulsing(true)); - - if (pulseTimeoutRef.current) { - clearTimeout(pulseTimeoutRef.current); - } - pulseTimeoutRef.current = setTimeout(() => setPulsing(false), MARK_PULSE_RESET_MS); - }, []); - const openLink = (href?: string) => { if (!href) { return; @@ -102,109 +85,98 @@ export const AboutDialog: React.FC = ({ contentClassName="sparo-about-dialog__dialog-body" >
- {/* Zone A — brand stage */} -
- - -

{t('about.productTitle')}

-

{t('about.tagline')}

- - {t('about.version', { version: versionLabel })} - -
- - {/* Zone B — spec sheet */} - - -
- {t('about.specSheetTitle')} - - {copied ? : } - +
+ +
+
+

{t('about.productTitle')}

+ + {t('about.version', { version: versionLabel })} +
+

{t('about.tagline')}

+
+ + +
+
+ + {t('about.specSheetTitle')} + + + {copied ? : } + +
-
-
-
-
-
{buildDateLabel}
+
+
+
{t('about.buildDate')}
+
{buildDateLabel}
+
+ {version.gitCommit && ( +
+
{t('about.commit')}
+
{version.gitCommit.slice(0, 7)}
- {version.gitCommit && ( -
-
-
-
- {version.gitCommit.slice(0, 7)} -
-
- )} - {version.gitBranch && ( -
-
-
-
{version.gitBranch}
-
- )} -
- - - - {/* Zone C — link rail + footer */} + )} + {version.gitBranch && ( +
+
{t('about.branch')}
+
{version.gitBranch}
+
+ )} +
+
+ {linkItems.length > 0 && (
{linkItems.map(({ key, href, icon: Icon, label }) => ( - openLink(href)} - > - +
+ +
))}
)}
- +

{t('about.copyright')}

diff --git a/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.tsx b/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.tsx index b66a5d85..6be6af49 100644 --- a/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.tsx +++ b/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.tsx @@ -1,10 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppWindow, - Boxes, FileText, FolderOpen, - Layers3, LayoutDashboard, ListChecks, MessageSquare, @@ -21,27 +19,18 @@ import type { SessionMetadata } from '@/shared/types/session-history'; import type { WorkspaceInfo } from '@/shared/types'; import { sessionAPI } from '@/infrastructure/api'; import { - appCatalogAPI, - localizeCatalogApps, - type ComponentDefinition, - type ProductAppCatalogEntry, - type WorkObjectKind, -} from '@/infrastructure/api/service-api/AppCatalogAPI'; -import { - NewWorkDialog, - launchWorkForChoice, - productAppWorkChoice, -} from '@/app/components/WorkDock/NewWorkDialog'; -import { productAppRequiresWorkspace } from '@/app/agentic-os/work/domain/productAppLaunchPolicy'; + intelligentAppAPI, + type AppSlotRecord, +} from '@/infrastructure/api/service-api/IntelligentAppAPI'; +import { NewWorkDialog } from '@/app/components/WorkDock/NewWorkDialog'; import { useWorks } from '@/app/agentic-os/work/hooks/useWorks'; import { filterWorkProjections } from '@/app/agentic-os/work/data/workSelectors'; import { openArtifactInCenter, openWorkCenterHome, openWorkInCenter } from '@/app/agentic-os/work/navigation/openWork'; import type { WorkProjection } from '@/app/agentic-os/work/projections/workProjection'; import { isSystemAgenticOsSession } from '@/flow_chat/domain/sessionDescriptor'; import { notificationService } from '@/shared/notification-system'; -import { openWorkspaceScene } from '@/app/navigation/workspaceNavigation'; -import { useAppsStore } from '@/app/scenes/apps/appsStore'; -import { openAppBuilderSession } from '@/app/scenes/apps/app-builder/openAppBuilderSession'; +import { launchActiveIntelligentApp } from '@/app/scenes/apps/intelligentAppLaunchService'; +import { systemAppScope } from '@/shared/types/app-scope'; import type { ArtifactRef, WorkRecord } from '@/app/agentic-os/work/domain/workTypes'; import { getWorkModeIcon, @@ -58,7 +47,7 @@ interface GlobalSearchDialogProps { onClose: () => void; } -type SearchResultKind = 'workspace' | 'work' | 'session' | 'app' | 'workObject' | 'component' | 'artifact'; +type SearchResultKind = 'workspace' | 'work' | 'session' | 'app' | 'artifact'; interface SearchResultItem { kind: SearchResultKind; @@ -69,9 +58,7 @@ interface SearchResultItem { workId?: string; work?: WorkProjection; appId?: string; - productApp?: ProductAppCatalogEntry; - workObject?: WorkObjectKind; - component?: ComponentDefinition; + appSlot?: AppSlotRecord; artifact?: ArtifactRef; } @@ -88,46 +75,12 @@ const matchesQuery = (query: string, ...fields: (string | undefined | null)[]): return fields.some(field => field && field.toLowerCase().includes(normalizedQuery)); }; -function matchesProductApp(query: string, app: ProductAppCatalogEntry): boolean { - return matchesQuery( - query, - app.id, - app.name, - app.description, - app.category, - app.dependencySummary, - ...(app.tags ?? []) - ); -} - -function matchesComponent(query: string, component: ComponentDefinition): boolean { +function matchesAppSlot(query: string, slot: AppSlotRecord): boolean { return matchesQuery( query, - component.id, - component.name, - component.description, - component.kind, - component.version, - component.implementationRef, - ...(component.usedByApps ?? []), - ...(component.capabilities ?? []).flatMap(capability => [ - capability.id, - capability.title, - capability.description, - ...(capability.actions ?? []), - ]) - ); -} - -function matchesWorkObject(query: string, app: ProductAppCatalogEntry, workObject: WorkObjectKind): boolean { - return matchesQuery( - query, - app.id, - app.name, - app.description, - workObject.id, - workObject.label, - workObject.scope + slot.slotId, + slot.displayName, + ...slot.variants.flatMap(({ app }) => [app.appId, app.displayName, app.description]), ); } @@ -250,16 +203,13 @@ function buildMergedSessionEntries( } const GlobalSearchDialog: React.FC = ({ open, onClose }) => { - const { t, currentLanguage } = useI18n('common'); - const { t: tApps } = useI18n('scenes/apps'); - const { openedWorkspacesList, lastUsedWorkspace, rememberWorkspace } = useWorkspaceContext(); + const { t } = useI18n('common'); + const { openedWorkspacesList, rememberWorkspace } = useWorkspaceContext(); const { works, projections } = useWorks(); const [query, setQuery] = useState(''); const [activeIndex, setActiveIndex] = useState(0); - const [productApps, setProductApps] = useState([]); - const [workspaceLaunchApp, setWorkspaceLaunchApp] = useState(null); + const [appSlots, setAppSlots] = useState([]); const [newWorkDialogOpen, setNewWorkDialogOpen] = useState(false); - const [components, setComponents] = useState([]); const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); const [persistedOpenWorkspaceSessions, setPersistedOpenWorkspaceSessions] = useState< Array<{ meta: SessionMetadata; workspace: WorkspaceInfo }> @@ -316,32 +266,20 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } useEffect(() => { if (!open) { - setProductApps([]); - setComponents([]); + setAppSlots([]); return; } let cancelled = false; - void Promise.all([ - appCatalogAPI.listAppCatalog(), - appCatalogAPI.listComponents(), - ]) - .then(([catalog, componentItems]) => { + void intelligentAppAPI.listCatalog() + .then((catalog) => { if (!cancelled) { - const appsByKey = new Map(); - for (const app of catalog.productApps.installed) { - if (!app.enabled) continue; - if ((app.catalogIssues?.length ?? 0) > 0) continue; - appsByKey.set(`${app.id}@${app.version}@${app.componentLockDigest}`, app); - } - setProductApps([...appsByKey.values()]); - setComponents(componentItems); + setAppSlots(catalog.slots.filter((slot) => intelligentAppAPI.activeRef(slot))); } }) .catch(() => { if (!cancelled) { - setProductApps([]); - setComponents([]); + setAppSlots([]); } }); @@ -354,11 +292,6 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } () => new Set(openedWorkspacesList.map(workspace => workspace.id)), [openedWorkspacesList] ); - const localizedProductApps = useMemo( - () => localizeCatalogApps(productApps, currentLanguage), - [currentLanguage, productApps], - ); - const sessionsInOpenedWorkspaces = useMemo((): Array<{ session: Session; workspace: WorkspaceInfo }> => { const result: Array<{ session: Session; workspace: WorkspaceInfo }> = []; for (const session of flowChatState.sessions.values()) { @@ -412,47 +345,19 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } }); } - const filteredProductApps = localizedProductApps - .filter(app => matchesProductApp(trimmedQuery, app)) + const filteredAppSlots = appSlots + .filter(slot => matchesAppSlot(trimmedQuery, slot)) .slice(0, MAX_PER_GROUP); - for (const app of filteredProductApps) { + for (const slot of filteredAppSlots) { + const active = intelligentAppAPI.activeRef(slot); + const activeVariant = slot.variants.find(({ app }) => app.appId === active?.appId); items.push({ kind: 'app', - id: app.id, - label: app.name, - sublabel: app.description, - productApp: app, - }); - } - - const workObjectMatches: SearchResultItem[] = []; - for (const app of localizedProductApps) { - for (const workObject of app.workObjectKinds ?? []) { - if (!matchesWorkObject(trimmedQuery, app, workObject)) continue; - const scope = tApps(`productSystem.workObjectScope.${workObject.scope}`); - workObjectMatches.push({ - kind: 'workObject', - id: `work-object:${app.id}:${workObject.id}`, - appId: app.id, - label: workObject.label || workObject.id, - sublabel: t('nav.search.workObjectHint', { app: app.name, scope }), - productApp: app, - workObject, - }); - } - } - items.push(...workObjectMatches.slice(0, MAX_PER_GROUP)); - - const filteredComponents = components - .filter(component => matchesComponent(trimmedQuery, component)) - .slice(0, MAX_PER_GROUP); - for (const component of filteredComponents) { - items.push({ - kind: 'component', - id: `${component.kind}:${component.id}`, - label: component.name, - sublabel: `${tApps(`productSystem.componentKinds.${component.kind}`)} · ${component.description}`, - component, + id: slot.slotId, + label: slot.displayName, + sublabel: activeVariant?.app.description ?? activeVariant?.app.displayName, + appId: active?.appId, + appSlot: slot, }); } @@ -502,15 +407,13 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } return items; }, [ - components, - localizedProductApps, + appSlots, openedWorkspaceIdSet, openedWorkspacesList, persistedOpenWorkspaceSessions, projections, query, t, - tApps, topLevelSessions, works, ]); @@ -531,50 +434,22 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } return; } - if (item.kind === 'app' && item.productApp) { - const app = item.productApp; + if (item.kind === 'app' && item.appSlot) { + const active = intelligentAppAPI.activeRef(item.appSlot); + if (!active) return; + const variant = item.appSlot.variants.find(({ app }) => app.appId === active.appId); try { - if (app.launch?.kind === 'applicationSurface' || app.launch?.kind === 'agentSession') { - if (productAppRequiresWorkspace(app)) { - setWorkspaceLaunchApp(app); - return; - } - await launchWorkForChoice({ - agentChoice: productAppWorkChoice(app.id), - workspace: lastUsedWorkspace, - rememberWorkspace, - title: app.name, - objective: app.description, - }); - return; - } - - if (app.launch?.kind === 'appBuilder') { - await openAppBuilderSession(); - return; - } - + await launchActiveIntelligentApp(active, { + scope: systemAppScope(), + title: item.appSlot.displayName, + objective: variant?.app.description || item.appSlot.displayName, + }); } catch (error) { - notificationService.error(error instanceof Error ? error.message : tApps('productSystem.messages.launchFailed', { - name: app.name, - error: String(error), - })); + notificationService.error(error instanceof Error ? error.message : String(error)); } return; } - if (item.kind === 'workObject' && item.appId) { - useAppsStore.getState().openAppDetail(item.appId); - openWorkspaceScene('apps'); - return; - } - - if (item.kind === 'component' && item.component) { - useAppsStore.getState().openComponentCenter(item.component.id); - openWorkspaceScene('apps'); - return; - } - if (item.kind === 'artifact' && item.workId) { if (item.artifact?.id) { openArtifactInCenter(item.workId, item.artifact.id); @@ -589,10 +464,8 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } activateWorkspace: item.workspaceId ? rememberWorkspace : undefined, }); }, [ - lastUsedWorkspace, onClose, rememberWorkspace, - tApps, ]); const handleOpenWorkCenter = useCallback(() => { @@ -640,14 +513,6 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } activeElement?.scrollIntoView({ block: 'nearest' }); }, [activeIndex]); - const workspaceLaunchDialog = ( - setWorkspaceLaunchApp(null)} - initialAgentChoice={workspaceLaunchApp ? productAppWorkChoice(workspaceLaunchApp.id) : undefined} - initialScopeRequirement={workspaceLaunchApp?.launch?.scopeRequirement} - /> - ); const newWorkDialog = ( = ({ open, onClose } if (!open) { return ( <> - {workspaceLaunchApp ? workspaceLaunchDialog : null} {newWorkDialogOpen ? newWorkDialog : null} ); @@ -667,8 +531,6 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } const workspaceItems = results.filter(result => result.kind === 'workspace'); const workItems = results.filter(result => result.kind === 'work'); const appItems = results.filter(result => result.kind === 'app'); - const workObjectItems = results.filter(result => result.kind === 'workObject'); - const componentItems = results.filter(result => result.kind === 'component'); const artifactItems = results.filter(result => result.kind === 'artifact'); const sessionItems = results.filter(result => result.kind === 'session'); const queryTrimmed = query.trim(); @@ -810,8 +672,6 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } )} {renderGroup(t('nav.search.groupWorkspaces'), workspaceItems, () => )} {renderGroup(t('nav.search.groupApps'), appItems, () => )} - {renderGroup(t('nav.search.groupWorkObjects'), workObjectItems, () => )} - {renderGroup(t('nav.search.groupComponents'), componentItems, () => )} {renderGroup(t('nav.search.groupArtifacts'), artifactItems, () => )} {queryTrimmed ? renderGroup( t('nav.search.groupSessions'), @@ -822,7 +682,6 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } )}
- {workspaceLaunchDialog} {newWorkDialog} ); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/DistributedDeviceOverview.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/DistributedDeviceOverview.tsx new file mode 100644 index 00000000..ed8b1469 --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/DistributedDeviceOverview.tsx @@ -0,0 +1,307 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + ChevronRight, + CloudCog, + CircleDashed, + Database, + Globe, + Glasses, + Headphones, + Monitor, + Network, + Plus, + Server, + ShieldCheck, + Smartphone, + Watch, + Wifi, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { Badge, Button, Dialog, DropdownMenu, Tooltip } from '@/design-system'; +import { useI18n } from '@/infrastructure/i18n'; +import { FeishuBrandIcon, TelegramBrandIcon, WeixinBrandIcon } from './RemoteConnectBrandIcons'; + +export type DeviceNetworkTransport = 'lan' | 'ngrok' | 'sparo_server' | 'custom_server'; +export type DeviceNetworkBot = 'telegram' | 'feishu' | 'weixin'; + +interface DistributedDeviceOverviewProps { + localDeviceName: string; + peerDeviceName?: string | null; + relayConnected: boolean; + connectedBot?: DeviceNetworkBot | null; + selectedTransport: DeviceNetworkTransport; + onSelectTransport: (transport: DeviceNetworkTransport) => void; + onOpenNetworkSetup: () => void; + onOpenBotSetup: (bot: DeviceNetworkBot) => void; +} + +interface TransportOption { + id: DeviceNetworkTransport; + labelKey: string; + icon: LucideIcon; +} + +interface BotOption { + id: DeviceNetworkBot; + label: string; + icon: React.ComponentType<{ size?: number; 'aria-hidden'?: boolean | 'true' | 'false' }>; +} + +const TRANSPORT_OPTIONS: TransportOption[] = [ + { id: 'lan', labelKey: 'tabLan', icon: Wifi }, + { id: 'sparo_server', labelKey: 'tabSparoServer', icon: CloudCog }, + { id: 'ngrok', labelKey: 'tabNgrok', icon: Network }, + { id: 'custom_server', labelKey: 'tabCustomServer', icon: Server }, +]; + +const StatusDot: React.FC<{ active?: boolean }> = ({ active = false }) => ( +