diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8cfe91f..a5d7e6d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,9 @@ jobs: - uses: dtolnay/rust-toolchain@stable + - name: Check Rust formatting + run: cargo fmt --all -- --check + - uses: swatinem/rust-cache@v2 with: shared-key: "ci-check-${{ runner.os }}" @@ -85,6 +88,10 @@ jobs: - name: Check compilation run: cargo check --workspace --exclude sparo-cli + - name: Check CLI compilation + if: runner.os == 'Linux' + run: cargo check -p sparo-cli + # ── Frontend: build ──────────────────────────────────────────────── frontend-build: name: Frontend Build diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 85f16578..268e5785 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -86,14 +86,6 @@ jobs: fail-fast: false matrix: platform: - - os: ubuntu-latest - name: linux-x64 - target: x86_64-unknown-linux-gnu - build_command: pnpm run desktop:build:linux -- --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage - - os: ubuntu-24.04-arm - name: linux-arm64 - target: aarch64-unknown-linux-gnu - build_command: pnpm run desktop:build:linux -- --target aarch64-unknown-linux-gnu --bundles deb,rpm,appimage - os: macos-15 name: macos-arm64 target: aarch64-apple-darwin @@ -118,41 +110,6 @@ jobs: shell: pwsh run: ./scripts/ci/setup-openssl-windows.ps1 - - name: Install Linux system dependencies (Tauri bundler) - if: runner.os == 'Linux' - shell: bash - run: | - sudo apt-get update - - if apt-cache show libwebkit2gtk-4.1-dev >/dev/null 2>&1; then - WEBKIT_PKG=libwebkit2gtk-4.1-dev - else - WEBKIT_PKG=libwebkit2gtk-4.0-dev - fi - - if apt-cache show libappindicator3-dev >/dev/null 2>&1; then - APPINDICATOR_PKG=libappindicator3-dev - else - APPINDICATOR_PKG=libayatana-appindicator3-dev - fi - - sudo apt-get install -y --no-install-recommends \ - pkg-config \ - xdg-utils \ - libglib2.0-dev \ - libgtk-3-dev \ - libxdo-dev \ - "$WEBKIT_PKG" \ - "$APPINDICATOR_PKG" \ - librsvg2-dev \ - patchelf \ - fakeroot \ - rpm \ - libleptonica-dev \ - libtesseract-dev \ - tesseract-ocr \ - tesseract-ocr-eng - - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -214,9 +171,6 @@ jobs: with: tag_name: ${{ needs.prepare.outputs.release_tag }} files: | - release-assets/**/*.AppImage - release-assets/**/*.deb release-assets/**/*.dmg - release-assets/**/*.rpm release-assets/**/*sparo-installer.exe fail_on_unmatched_files: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 83f6b376..5825d753 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -72,14 +72,6 @@ jobs: fail-fast: false matrix: platform: - - os: ubuntu-latest - name: linux-x64 - target: x86_64-unknown-linux-gnu - build_command: pnpm run desktop:build:linux -- --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage - - os: ubuntu-24.04-arm - name: linux-arm64 - target: aarch64-unknown-linux-gnu - build_command: pnpm run desktop:build:linux -- --target aarch64-unknown-linux-gnu --bundles deb,rpm,appimage - os: macos-15 name: macos-arm64 target: aarch64-apple-darwin @@ -101,41 +93,6 @@ jobs: shell: pwsh run: ./scripts/ci/setup-openssl-windows.ps1 - - name: Install Linux system dependencies (Tauri bundler) - if: runner.os == 'Linux' - shell: bash - run: | - sudo apt-get update - - if apt-cache show libwebkit2gtk-4.1-dev >/dev/null 2>&1; then - WEBKIT_PKG=libwebkit2gtk-4.1-dev - else - WEBKIT_PKG=libwebkit2gtk-4.0-dev - fi - - if apt-cache show libappindicator3-dev >/dev/null 2>&1; then - APPINDICATOR_PKG=libappindicator3-dev - else - APPINDICATOR_PKG=libayatana-appindicator3-dev - fi - - sudo apt-get install -y --no-install-recommends \ - pkg-config \ - xdg-utils \ - libglib2.0-dev \ - libgtk-3-dev \ - libxdo-dev \ - "$WEBKIT_PKG" \ - "$APPINDICATOR_PKG" \ - librsvg2-dev \ - patchelf \ - fakeroot \ - rpm \ - libleptonica-dev \ - libtesseract-dev \ - tesseract-ocr \ - tesseract-ocr-eng - - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -242,8 +199,5 @@ jobs: > **Warning**: Nightly builds are untested and may be unstable. prerelease: true files: | - release-assets/**/*.AppImage - release-assets/**/*.deb release-assets/**/*.dmg - release-assets/**/*.rpm release-assets/**/*sparo-installer.exe diff --git a/.gitignore b/.gitignore index 1b69ca76..236ea9b9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ dist-ssr *.local **/dist **/dist-ssr +**/dist-preview/ # Build outputs - Rust/Tauri target/ diff --git a/Cargo.lock b/Cargo.lock index 4d22edb0..12c68251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6426,11 +6426,9 @@ dependencies = [ "similar", "sparo-ai-adapters", "sparo-events", - "sparo-relay-server", - "sparo-transport", + "sparo-relay", "sse-stream", "tar", - "tauri", "tempfile", "thiserror 1.0.69", "tokio", @@ -6521,10 +6519,9 @@ dependencies = [ ] [[package]] -name = "sparo-relay-server" +name = "sparo-relay" version = "0.1.0" dependencies = [ - "anyhow", "axum", "base64 0.22.1", "chrono", @@ -6537,6 +6534,18 @@ dependencies = [ "tokio", "tower-http", "tracing", +] + +[[package]] +name = "sparo-relay-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "sparo-relay", + "tokio", + "tower-http", + "tracing", "tracing-subscriber", ] @@ -6547,13 +6556,10 @@ dependencies = [ "anyhow", "async-trait", "chrono", - "dashmap", "log", - "serde", "serde_json", "sparo-events", "tauri", - "tokio", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 279eabb9..6a17ed98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,9 @@ members = [ "src/crates/libs/cli-credential", "src/crates/events", "src/crates/ai-adapters", + "src/crates/relay", "src/crates/core", + "src/crates/tool-runtime", "src/crates/transport", "src/crates/webdriver", "src/apps/cli", 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 a6adf3a3..55b75cc2 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,6 @@ "builtin-remotion-live", "builtin-harmony-dev", "builtin-ppt-live", - "builtin-spark-board", "builtin-excel-live" ], "visibility": "developer", 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 deleted file mode 100644 index 2bccb836..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/app.json +++ /dev/null @@ -1,211 +0,0 @@ -{ - "id": "builtin-deep-research", - "version": "1.0.0", - "name": "Deep Research", - "description": "A deep research Agent for finding sources, comparing viewpoints, organizing evidence, and turning complex questions into clear conclusions.", - "i18n": { - "locales": { - "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" - ] - }, - "zh-CN": { - "name": "Deep Research", - "description": "面向深度研究的 Agent,适合查资料、比观点、梳理依据,把复杂问题沉淀为清晰结论。", - "tags": [ - "内置", - "研究", - "证据", - "报告" - ] - } - } - }, - "interactionModel": "conversation", - "workMultiplicity": "multiple", - "workObjectKinds": [ - { - "id": "research-question", - "label": "Research Question", - "scope": "workspace", - "identitySchema": { - "type": "object", - "properties": { - "questionId": { - "type": "string" - }, - "topic": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "scope": { - "type": "string" - }, - "sources": { - "type": "array" - }, - "openQuestions": { - "type": "array" - } - } - } - }, - { - "id": "evidence-set", - "label": "Evidence Set", - "scope": "asset", - "identitySchema": { - "type": "object", - "properties": { - "evidenceSetId": { - "type": "string" - }, - "artifactId": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "claims": { - "type": "array" - }, - "citations": { - "type": "array" - }, - "confidence": { - "type": "string" - } - } - } - }, - { - "id": "research-report", - "label": "Research Report", - "scope": "asset", - "identitySchema": { - "type": "object", - "properties": { - "artifactId": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "audience": { - "type": "string" - }, - "sections": { - "type": "array" - } - } - } - } - ], - "primarySurface": { - "componentId": "builtin-deep-research-chat-surface", - "surfaceId": "primary" - }, - "primarySurfaceMode": "chatPrimary", - "components": [ - { - "componentId": "builtin-deep-research-chat-surface", - "kind": "surface", - "source": "private", - "role": "primarySurface", - "capabilities": [ - "chatPrimary" - ], - "usesCapabilities": [ - "session.read", - "component.run" - ] - }, - { - "componentId": "builtin-deep-research-agent", - "kind": "agent", - "source": "private", - "role": "assistant", - "capabilities": [ - "parallelResearch", - "synthesizeReport" - ], - "usesCapabilities": [ - "browser.navigate", - "browser.inspect", - "session.read", - "session.write", - "memory.read", - "memory.write", - "model.invoke", - "tool.invoke" - ] - } - ], - "componentLockId": "", - "permissions": { - "fs": true, - "net": true, - "shell": true, - "gui": false, - "secrets": false, - "ai": true - }, - "installScope": "system", - "catalogVisibility": "discoverable", - "enabled": true, - "icon": { - "kind": "packageAsset", - "path": "assets/icon.svg" - }, - "category": "analysis", - "tags": [ - "built-in", - "research", - "evidence", - "report" - ], - "launch": { - "kind": "agentSession", - "targetId": "DeepResearch", - "scopeRequirement": "workspaceOptional", - "agentType": "DeepResearch", - "surfaceId": "primary" - }, - "osCapabilities": [ - "session.read", - "component.run", - "browser.navigate", - "browser.inspect", - "session.write", - "memory.read", - "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/assets/icon.svg b/bundles/product-apps/builtin-deep-research/1.0.0/assets/icon.svg deleted file mode 100644 index be5082e4..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/assets/icon.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 deleted file mode 100644 index 7ea934b0..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/compatibility.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "runtimeCompatibility": ">=0.1.0" -} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/components/agents/builtin-deep-research-agent/component.json b/bundles/product-apps/builtin-deep-research/1.0.0/components/agents/builtin-deep-research-agent/component.json deleted file mode 100644 index 4c446fca..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/components/agents/builtin-deep-research-agent/component.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "id": "builtin-deep-research-agent", - "kind": "agent", - "name": "Deep Research Agent", - "description": "Research agent for parallel investigation, source review, and synthesized report writing.", - "packageSource": "appPrivate", - "ownerApp": { - "appId": "builtin-deep-research", - "appVersion": "1.0.0" - }, - "capabilities": [ - { - "id": "parallelResearch", - "title": "Parallel research", - "description": "Dispatch and coordinate focused research subtasks.", - "actions": [ - "dispatchResearch", - "readSources" - ] - }, - { - "id": "synthesizeReport", - "title": "Synthesize report", - "description": "Compare findings and produce a cohesive research report.", - "actions": [ - "synthesize", - "writeReport" - ] - } - ], - "permissions": [ - { - "kind": "ai", - "summary": "Uses the configured Deep Research agent.", - "scopes": [ - "DeepResearch" - ] - }, - { - "kind": "net", - "summary": "Uses web research tools when the user requests current or public-source research.", - "scopes": [ - "*" - ] - } - ], - "usedByApps": [ - "builtin-deep-research" - ], - "visibility": "appDependency", - "dependencies": [], - "implementationRef": "agent://DeepResearch", - "usesCapabilities": [ - "browser.navigate", - "browser.inspect", - "session.read", - "session.write", - "memory.read", - "memory.write", - "model.invoke", - "tool.invoke" - ] -} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/component.json b/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/component.json deleted file mode 100644 index a7e0afc7..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/component.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "id": "builtin-deep-research-chat-surface", - "kind": "surface", - "name": "Deep Research Chat", - "description": "Conversation-first work surface for research planning, investigation, and synthesis.", - "packageSource": "appPrivate", - "ownerApp": { - "appId": "builtin-deep-research", - "appVersion": "1.0.0" - }, - "capabilities": [ - { - "id": "chatPrimary", - "title": "Primary chat", - "description": "Uses the Flow Chat surface as the Product App's primary workspace.", - "actions": [ - "startSession", - "continueWork" - ] - } - ], - "permissions": [], - "usedByApps": [ - "builtin-deep-research" - ], - "visibility": "appDependency", - "dependencies": [ - { - "componentId": "builtin-deep-research-agent", - "kind": "agent", - "source": "private", - "role": "assistant", - "capabilities": [ - "parallelResearch", - "synthesizeReport" - ] - } - ], - "implementationRef": "app://builtin-deep-research@1.0.0/surfaces/builtin-deep-research-chat-surface", - "usesCapabilities": [ - "session.read", - "component.run" - ] -} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/index.html b/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/index.html deleted file mode 100644 index 2ed09e5a..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/index.html +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/style.css b/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/style.css deleted file mode 100644 index 35dbf686..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/style.css +++ /dev/null @@ -1,32 +0,0 @@ -.package-surface { - min-height: 100vh; - box-sizing: border-box; - display: grid; - align-content: center; - gap: 12px; - padding: 32px; - color: var(--ds-color-text-primary); - background: var(--ds-color-bg-app); - font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} -.package-surface__eyebrow { - margin: 0; - color: var(--ds-color-text-muted); - font-size: 12px; - font-weight: 700; - letter-spacing: 0; - text-transform: uppercase; -} -.package-surface__title { - margin: 0; - font-size: 28px; - line-height: 1.2; - letter-spacing: 0; -} -.package-surface__copy { - margin: 0; - max-width: 64ch; - color: var(--ds-color-text-secondary); - font-size: 15px; - line-height: 1.55; -} diff --git a/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/ui.js b/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/ui.js deleted file mode 100644 index 49a17f4a..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/components/surfaces/builtin-deep-research-chat-surface/source/ui.js +++ /dev/null @@ -1,9 +0,0 @@ -const root = document.getElementById('app-root'); - -if (root) { - root.innerHTML = ` -

Product App package surface

-

Deep Research Chat

-

Research, synthesize, and produce source-grounded conclusions.

- `; -} 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 deleted file mode 100644 index 1587a669..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/config/data-schema.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "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 deleted file mode 100644 index 0967ef42..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/config/default.json +++ /dev/null @@ -1 +0,0 @@ -{} 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 deleted file mode 100644 index 6d8a12b9..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/tests/eval.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index eedab2b6..00000000 --- a/bundles/product-apps/builtin-deep-research/1.0.0/tests/rehearsal.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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-excel-live/1.0.0/app.json b/bundles/product-apps/builtin-excel-live/1.0.0/app.json deleted file mode 100644 index 28e91f1d..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/app.json +++ /dev/null @@ -1,265 +0,0 @@ -{ - "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 deleted file mode 100644 index 78b01d05..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/assets/icon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - 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 deleted file mode 100644 index 7ea934b0..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/compatibility.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "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 deleted file mode 100644 index 88a42ed0..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/component.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "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 deleted file mode 100644 index 722c6a35..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/agent.md +++ /dev/null @@ -1,151 +0,0 @@ -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 deleted file mode 100644 index 6b49d8d7..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/bundle.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index 3e1f4a85..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/evals/cases.json +++ /dev/null @@ -1,143 +0,0 @@ -[ - { - "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 deleted file mode 100644 index 87cf0651..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/examples.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "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 deleted file mode 100644 index a8217893..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/manifest.json +++ /dev/null @@ -1,153 +0,0 @@ -{ - "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 deleted file mode 100644 index 2abb0759..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index d588c928..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/routing.md +++ /dev/null @@ -1,31 +0,0 @@ -# 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 deleted file mode 100644 index badd7286..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/schemas/spreadsheet-focus.schema.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "$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 deleted file mode 100644 index 839c6424..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/data-cleaning.md +++ /dev/null @@ -1,24 +0,0 @@ -# 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 deleted file mode 100644 index c87740fa..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/focus-and-proposals.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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 deleted file mode 100644 index 90d00050..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/formula-engineering.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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 deleted file mode 100644 index 69d71bed..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/spreadsheet-fundamentals.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index f7357532..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/visual-table-design.md +++ /dev/null @@ -1,55 +0,0 @@ -# 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 deleted file mode 100644 index 8e8ab150..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/skills/workbook-authoring.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index f00f8953..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/test/agent-authoring-contract.test.js +++ /dev/null @@ -1,285 +0,0 @@ -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 deleted file mode 100644 index a589e860..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/accept_proposal.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 3a7c6640..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/accept_proposal.tool.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "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 deleted file mode 100644 index 722b3c5c..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/create_workbook.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index b151e91c..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/create_workbook.tool.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "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 deleted file mode 100644 index e6f64855..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/edit_workbook_structure.js +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 3e0c7b38..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/edit_workbook_structure.tool.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "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 deleted file mode 100644 index a155f2ef..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/excel_bridge.js +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 3d114da4..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_focus.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 0331666e..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_focus.tool.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "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 deleted file mode 100644 index 4ab045f2..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_history.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 604ce1e7..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_history.tool.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "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 deleted file mode 100644 index 370b6bf3..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_proposal.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 45d2a42f..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_proposal.tool.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "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 deleted file mode 100644 index 67b63c27..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_workbook_meta.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index cea15a0a..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/get_workbook_meta.tool.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "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 deleted file mode 100644 index c36b6a58..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/list_sheets.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 45b6efcc..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/list_sheets.tool.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "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 deleted file mode 100644 index f2bdf285..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/open_workbook.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 2dc99259..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/open_workbook.tool.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "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 deleted file mode 100644 index d3312e3f..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/propose_patch.js +++ /dev/null @@ -1,261 +0,0 @@ -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 deleted file mode 100644 index 4290b7cc..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/propose_patch.tool.json +++ /dev/null @@ -1,328 +0,0 @@ -{ - "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 deleted file mode 100644 index 3d1cd422..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/read_range.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 9e9ae31f..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/read_range.tool.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "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 deleted file mode 100644 index 488f75b6..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/redo_workbook.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 5fbe7074..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/redo_workbook.tool.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "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 deleted file mode 100644 index 7fd554b6..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/reject_proposal.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index ba18b80a..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/reject_proposal.tool.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "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 deleted file mode 100644 index 1581b45f..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/save_workbook.js +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index e5f8cb78..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/save_workbook.tool.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "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 deleted file mode 100644 index 4514d21a..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/summarize_range.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index aab03910..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/summarize_range.tool.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "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 deleted file mode 100644 index b745978a..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/switch_sheet.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 38a55a3b..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/switch_sheet.tool.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "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 deleted file mode 100644 index 49541efd..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/undo_workbook.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 8a567fe4..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/agents/excel-live-agent/source/tools/undo_workbook.tool.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "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 deleted file mode 100644 index 7f4c385b..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/component.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "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 deleted file mode 100644 index 266791a0..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/bundle.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index e07938c3..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/manifest.json +++ /dev/null @@ -1,411 +0,0 @@ -{ - "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 deleted file mode 100644 index c0b98fb3..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index f7e66fa1..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/a1.js +++ /dev/null @@ -1,195 +0,0 @@ -/** - * 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 deleted file mode 100644 index 1095e55e..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/csv-io.js +++ /dev/null @@ -1,168 +0,0 @@ -/** - * 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 deleted file mode 100644 index ace0ae5c..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/engine.js +++ /dev/null @@ -1,2910 +0,0 @@ -/** - * 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 deleted file mode 100644 index 976f9bc3..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/protocol.js +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index cbc31e7d..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/workbook-store.js +++ /dev/null @@ -1,580 +0,0 @@ -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 deleted file mode 100644 index 0b7ef12a..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/src/xlsx-io.js +++ /dev/null @@ -1,1997 +0,0 @@ -/** - * 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 deleted file mode 100644 index 91d3a94b..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/advanced-creation.test.js +++ /dev/null @@ -1,376 +0,0 @@ -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 deleted file mode 100644 index 858421fa..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/contract-consistency.test.js +++ /dev/null @@ -1,436 +0,0 @@ -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 deleted file mode 100644 index 91672a80..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/engine-safety.test.js +++ /dev/null @@ -1,986 +0,0 @@ -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 deleted file mode 100644 index c444c634..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/test/worker-protocol.test.js +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index ebd57754..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/bridges/builtin-excel-runtime/source/worker.js +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 12ae47f9..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/component.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "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 deleted file mode 100644 index fe51488c..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/esm_dependencies.json +++ /dev/null @@ -1 +0,0 @@ -[] 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 deleted file mode 100644 index 8cccbd9e..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/index.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
-
-

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 deleted file mode 100644 index 05606755..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/source_manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 deleted file mode 100644 index 3776346c..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/a1.js +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 045c791b..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/actions.js +++ /dev/null @@ -1,958 +0,0 @@ -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 deleted file mode 100644 index d901e961..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/backend.js +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index df342c01..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/focus.js +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index fc5fb532..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/formatting.js +++ /dev/null @@ -1,458 +0,0 @@ -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 deleted file mode 100644 index 120350df..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/grid.js +++ /dev/null @@ -1,1258 +0,0 @@ -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 deleted file mode 100644 index 643ae358..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/i18n.js +++ /dev/null @@ -1,437 +0,0 @@ -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 deleted file mode 100644 index ab534452..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/interaction.js +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index bc709213..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/model.js +++ /dev/null @@ -1,448 +0,0 @@ -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 deleted file mode 100644 index cec72400..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/proposal.js +++ /dev/null @@ -1,584 +0,0 @@ -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 deleted file mode 100644 index 941b0a5f..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/render.js +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 026d2d23..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/state.js +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 2b652efd..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/util.js +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 0164e637..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/src/views.js +++ /dev/null @@ -1,514 +0,0 @@ -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 deleted file mode 100644 index ae846903..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/style.css +++ /dev/null @@ -1,1581 +0,0 @@ -/* 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 deleted file mode 100644 index 7471dbb0..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/test/interaction.test.mjs +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index b0117b33..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/ui.js +++ /dev/null @@ -1,489 +0,0 @@ -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 deleted file mode 100644 index 7a65ed27..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/components/surfaces/builtin-excel-live-surface/source/worker.js +++ /dev/null @@ -1,13 +0,0 @@ -'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 deleted file mode 100644 index 1587a669..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/config/data-schema.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "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 deleted file mode 100644 index 0967ef42..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/config/default.json +++ /dev/null @@ -1 +0,0 @@ -{} 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 deleted file mode 100644 index 17984415..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/tests/eval.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index 1f3c0833..00000000 --- a/bundles/product-apps/builtin-excel-live/1.0.0/tests/rehearsal.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 481dcd9c..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/app.json +++ /dev/null @@ -1,316 +0,0 @@ -{ - "id": "builtin-harmony-dev", - "version": "13.0.0", - "name": "HarmonyOS Dev", - "description": "An integrated HarmonyOS app development environment that brings ArkTS, ArkUI, building, packaging, debugging, and device runtime into one workspace.", - "authors": [ - { - "name": "Sparo OS", - "url": "https://gcwing.github.io/Sparo-Agentic-OS/" - } - ], - "i18n": { - "locales": { - "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" - ] - }, - "zh-CN": { - "name": "HarmonyOS Dev", - "description": "一体化鸿蒙应用开发环境,把 ArkTS、ArkUI、构建、打包、调试和设备运行集中到一个工作空间。", - "tags": [ - "内置", - "harmonyos", - "arkui", - "设备", - "构建" - ] - } - } - }, - "interactionModel": "interactiveWorkspace", - "workMultiplicity": "multiple", - "workObjectKinds": [ - { - "id": "workspace", - "label": "Workspace", - "scope": "workspace", - "identitySchema": { - "type": "object", - "required": [ - "workspacePath" - ], - "properties": { - "workspacePath": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "projectKind": { - "type": "string" - }, - "bundleName": { - "type": "string" - } - } - } - }, - { - "id": "ability", - "label": "Ability", - "scope": "project", - "identitySchema": { - "type": "object", - "required": [ - "bundleName", - "abilityName" - ], - "properties": { - "bundleName": { - "type": "string" - }, - "abilityName": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "entryModule": { - "type": "string" - }, - "route": { - "type": "string" - } - } - } - }, - { - "id": "build-run", - "label": "Build Run", - "scope": "runtime", - "identitySchema": { - "type": "object", - "properties": { - "runId": { - "type": "string" - }, - "task": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "artifactPath": { - "type": "string" - }, - "diagnostics": { - "type": "array" - } - } - } - }, - { - "id": "device-target", - "label": "Device Target", - "scope": "device", - "identitySchema": { - "type": "object", - "required": [ - "targetId" - ], - "properties": { - "targetId": { - "type": "string" - }, - "targetType": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "online": { - "type": "boolean" - }, - "apiVersion": { - "type": "string" - }, - "emulatorName": { - "type": "string" - } - } - } - }, - { - "id": "module", - "label": "Module", - "scope": "project", - "identitySchema": { - "type": "object", - "required": [ - "moduleName" - ], - "properties": { - "moduleName": { - "type": "string" - }, - "modulePath": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "targetSdk": { - "type": "string" - }, - "mainElement": { - "type": "string" - } - } - } - } - ], - "truthSource": "runtimeFact", - "primarySurface": { - "componentId": "builtin-harmony-dev-surface", - "surfaceId": "primary" - }, - "primarySurfaceMode": "sidecarLinked", - "components": [ - { - "componentId": "builtin-harmony-dev-surface", - "kind": "surface", - "source": "private", - "role": "primarySurface", - "capabilities": [ - "interactiveSurface" - ], - "usesCapabilities": [ - "session.read", - "component.run" - ] - }, - { - "componentId": "harmonyos-dev-agent", - "kind": "agent", - "source": "private", - "role": "assistant", - "capabilities": [ - "understandHarmonyProject", - "reviseHarmonyUi", - "buildInstallVerify", - "diagnoseHarmonyFailure" - ], - "usesCapabilities": [ - "filesystem.read", - "filesystem.write", - "terminal.run", - "process.spawn", - "workspace.inspect", - "workspace.modify", - "session.read", - "model.invoke", - "tool.invoke", - "component.run" - ] - }, - { - "componentId": "builtin-harmony-runtime", - "kind": "bridge", - "source": "private", - "role": "runtime", - "capabilities": [ - "sparo.harmonyDev" - ], - "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.png" - }, - "category": "developer", - "tags": [ - "built-in", - "harmonyos", - "arkui", - "device", - "build" - ], - "launch": { - "kind": "applicationSurface", - "targetId": "builtin-harmony-dev", - "scopeRequirement": "workspaceRequired", - "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": "userManaged", - "deletion": "deleteOnUserRequest", - "migration": "schemaVersioned", - "share": "declaredWorkObjectsOnly" - } -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/assets/icon.png b/bundles/product-apps/builtin-harmony-dev/13.0.0/assets/icon.png deleted file mode 100644 index fe207e50..00000000 Binary files a/bundles/product-apps/builtin-harmony-dev/13.0.0/assets/icon.png and /dev/null differ 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 deleted file mode 100644 index 7ea934b0..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/compatibility.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "runtimeCompatibility": ">=0.1.0" -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/component.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/component.json deleted file mode 100644 index 131dade3..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/component.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "id": "harmonyos-dev-agent", - "kind": "agent", - "name": "HarmonyOS Dev Agent", - "description": "An ArkTS/ArkUI development agent that edits source through workspace tools and verifies through the HarmonyOS Dev Runtime.", - "packageSource": "appPrivate", - "ownerApp": { - "appId": "builtin-harmony-dev", - "appVersion": "13.0.0" - }, - "capabilities": [ - { - "id": "understandHarmonyProject", - "title": "Understand HarmonyOS project", - "description": "Detect the HarmonyOS workspace, bundle, module, ability, target SDK, signing status, and likely ArkUI source files.", - "actions": [ - "detectProject", - "getRuntimeContext" - ] - }, - { - "id": "reviseHarmonyUi", - "title": "Revise HarmonyOS UI", - "description": "Make scoped ArkTS/ArkUI source edits from user intent and selected preview context.", - "actions": [ - "readSource", - "editSource", - "getFileDiff" - ] - }, - { - "id": "buildInstallVerify", - "title": "Build, install, and verify", - "description": "Build the HarmonyOS project, install and launch when a target is available, then capture screenshot and hierarchy evidence.", - "actions": [ - "buildProject", - "installAndLaunch", - "captureScreen", - "dumpHierarchy" - ] - }, - { - "id": "diagnoseHarmonyFailure", - "title": "Diagnose HarmonyOS failure", - "description": "Use redacted runtime diagnostics, build logs, HDC target state, and project source to explain and repair failures.", - "actions": [ - "getRuntimeContext", - "readDiagnostics", - "detectProject" - ] - } - ], - "permissions": [ - { - "kind": "ai", - "summary": "Uses configured AI providers through Sparo OS.", - "scopes": [ - "system", - "workspace" - ] - }, - { - "kind": "fs", - "summary": "Reads and edits HarmonyOS project files through declared workspace tools.", - "scopes": [ - "{workspace}" - ] - }, - { - "kind": "shell", - "summary": "Uses scoped workspace commands and delegates HarmonyOS toolchain operations to the runtime bridge.", - "scopes": [ - "node", - "git", - "hvigorw", - "hvigorw.bat", - "ohpm", - "ohpm.bat" - ] - } - ], - "usedByApps": [ - "builtin-harmony-dev" - ], - "visibility": "appDependency", - "dependencies": [ - { - "componentId": "builtin-harmony-runtime", - "kind": "bridge", - "source": "private", - "role": "harmonyRuntime", - "capabilities": [ - "sparo.harmonyDev" - ] - } - ], - "implementationRef": "app://builtin-harmony-dev@13.0.0/agents/harmonyos-dev-agent", - "usesCapabilities": [ - "filesystem.read", - "filesystem.write", - "terminal.run", - "process.spawn", - "workspace.inspect", - "workspace.modify", - "session.read", - "model.invoke", - "tool.invoke", - "component.run" - ] -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/agent.md b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/agent.md deleted file mode 100644 index 6860c3d7..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/agent.md +++ /dev/null @@ -1,41 +0,0 @@ -You are HarmonyOS Dev Agent, a senior ArkTS/ArkUI application developer inside Sparo OS. - -Core identity: -- Treat the running HarmonyOS app on the target device or emulator as the product surface. -- Source edits are useful only when build, install, launch, screenshot, UI hierarchy, logs, or a clear blocked device state prove what happened. -- Use selected screen context as a pointer, not as proof. Inspect source before editing. -- Keep HarmonyOS work native: ArkTS, ArkUI, module config, resources, permissions, and build profiles matter. - -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 HarmonyOS toolchain and device operations through the HarmonyOS Dev Runtime: - - agentcomponent__harmonyos-dev-agent__detect_project - - agentcomponent__harmonyos-dev-agent__get_runtime_context - - agentcomponent__harmonyos-dev-agent__build_project - - agentcomponent__harmonyos-dev-agent__install_and_launch - - agentcomponent__harmonyos-dev-agent__capture_screen - - agentcomponent__harmonyos-dev-agent__dump_hierarchy -- Do not invent separate search, read, write, or workspace tools. Use the base tools. -- Do not bypass the HarmonyOS Dev Runtime to control HDC, HVigor, DevEco Emulator, or signing-sensitive operations unless the user explicitly asks for a raw local diagnostic command. - -Working principles: -- Start by detecting the HarmonyOS project and reading current runtime context. -- Locate UI source through bundle/module/ability/page metadata, selected hierarchy text, accessibility ids, component names, resource keys, and Grep/Read evidence. -- Keep edits scoped to relevant ETS, resource, module, or build-profile files. -- Never expose signing passwords, private keys, certificate material, token-like values, or full secret-bearing config lines. -- If no HDC target is online, say that install/launch/screenshot verification is blocked while still using build and static source evidence. -- Treat Hot Reload as an optional acceleration path. Build & Run is the correctness path. - -Failure behavior: -- If the workspace is not a HarmonyOS project, name the missing signal such as build-profile.json5, runtimeOS, AppScope/app.json5, or module.json5. -- If screenshot or hierarchy is unavailable, report that as an evidence gap rather than claiming visual verification. -- If a selected UI node cannot be mapped to source with confidence, explain the uncertainty and inspect nearby ArkUI files before editing. -- If build or install fails, use the redacted diagnostic and log path, make one reasoned fix, and avoid repeating the same failing command unchanged. - -Skill routing: -- Use harmony-fundamentals to understand project layout and config. -- Use arkui-component-workflow for UI edits. -- Use build-install-verify for build, install, launch, screenshot, and hierarchy loops. -- Use device-preview-context when the user points at the preview. -- Use signing-and-permissions for certificate/profile/permission questions, always redacted. -- Use troubleshooting for HDC, emulator, HVigor, launch, crash, and log failures. diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/bundle.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/bundle.json deleted file mode 100644 index 48e3c65f..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/bundle.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "schemaVersion": 1, - "id": "harmonyos-dev-agent", - "version": 1 -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/evals/cases.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/evals/cases.json deleted file mode 100644 index 3fe9d7bd..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/evals/cases.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "name": "detect-sparo-harmony", - "prompt": "Detect this HarmonyOS project and summarize bundle, module, ability, target SDK, and signing status without exposing secrets." - }, - { - "name": "selected-ui-edit", - "prompt": "Use the selected UI context as a pointer, find the likely ArkUI component, make a scoped spacing edit, and verify with build evidence." - } -] diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/manifest.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/manifest.json deleted file mode 100644 index fa26be77..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/manifest.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "schemaVersion": 1, - "id": "harmonyos-dev-agent", - "name": "HarmonyOS Dev Agent", - "description": "An ArkTS/ArkUI development agent that edits source through base workspace tools and verifies through the HarmonyOS Dev Runtime.", - "icon": "smartphone", - "category": "mobile", - "tags": [ - "harmonyos", - "arkui", - "arkts", - "device", - "preview" - ], - "level": "user", - "model": "primary", - "readonly": false, - "enabled": true, - "tools": [ - "LS", - "Glob", - "Grep", - "Read", - "Edit", - "Write", - "GetFileDiff", - "Bash", - "agentcomponent__harmonyos-dev-agent__detect_project", - "agentcomponent__harmonyos-dev-agent__get_runtime_context", - "agentcomponent__harmonyos-dev-agent__build_project", - "agentcomponent__harmonyos-dev-agent__install_and_launch", - "agentcomponent__harmonyos-dev-agent__capture_screen", - "agentcomponent__harmonyos-dev-agent__dump_hierarchy" - ], - "skills": [ - "harmony-fundamentals", - "arkui-component-workflow", - "build-install-verify", - "device-preview-context", - "signing-and-permissions", - "troubleshooting" - ], - "subagents": [], - "toolPolicies": {}, - "bridgeCapabilities": [ - { - "bridgeId": "builtin-harmony-runtime", - "capabilityId": "sparo.harmonyDev", - "alias": "harmonyRuntime", - "mode": "auto" - } - ], - "serviceActions": [ - { - "name": "understandHarmonyProject", - "description": "Detect the HarmonyOS project, bundle, module, ability, target SDK, signing status, and likely ArkUI source files.", - "toolPolicy": [ - "agentcomponent__harmonyos-dev-agent__detect_project", - "agentcomponent__harmonyos-dev-agent__get_runtime_context", - "Glob", - "Grep", - "Read" - ], - "bridgeCall": { - "bridgeId": "builtin-harmony-runtime", - "capabilityId": "sparo.harmonyDev", - "action": "detectProject", - "mode": "auto" - } - }, - { - "name": "reviseHarmonyUi", - "description": "Make scoped ArkTS/ArkUI source edits from user intent and optional selected screen context.", - "toolPolicy": [ - "Read", - "Edit", - "Write", - "GetFileDiff", - "agentcomponent__harmonyos-dev-agent__get_runtime_context" - ] - }, - { - "name": "buildInstallVerify", - "description": "Build the HarmonyOS project, install and launch when a target is available, then capture screenshot and UI hierarchy evidence.", - "toolPolicy": [ - "agentcomponent__harmonyos-dev-agent__build_project", - "agentcomponent__harmonyos-dev-agent__install_and_launch", - "agentcomponent__harmonyos-dev-agent__capture_screen", - "agentcomponent__harmonyos-dev-agent__dump_hierarchy", - "GetFileDiff" - ] - }, - { - "name": "diagnoseHarmonyFailure", - "description": "Use redacted runtime diagnostics, build logs, HDC target state, and project source to explain and repair HarmonyOS failures.", - "toolPolicy": [ - "agentcomponent__harmonyos-dev-agent__get_runtime_context", - "agentcomponent__harmonyos-dev-agent__detect_project", - "Read", - "Grep", - "Edit", - "GetFileDiff" - ] - } - ], - "examples": [ - { - "title": "Refine selected UI", - "prompt": "Using the selected region in HarmonyOS Dev, make this ArkUI button spacing feel cleaner, then build and verify it." - }, - { - "title": "Fix launch failure", - "prompt": "Diagnose why the HarmonyOS app does not launch on the current target and propose the smallest source or config fix." - }, - { - "title": "Verify after edit", - "prompt": "Build the current HarmonyOS project, install it if a target is online, capture the screen, and summarize the evidence." - } - ] -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/routing.md b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/routing.md deleted file mode 100644 index 1b05b161..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/routing.md +++ /dev/null @@ -1,8 +0,0 @@ -Use HarmonyOS Dev Agent when the user is working on a HarmonyOS, ArkTS, ArkUI, DevEco, HVigor, HDC, emulator, build/install/launch, or device-preview task. - -Prefer this agent over a generic coding agent when: -- The request references a running HarmonyOS app, target, emulator, screenshot, UI hierarchy, HAP/APP artifact, ability, bundle, or module. -- The user points at the HarmonyOS Dev Product App surface preview and asks to change "this" or diagnose "what happened here". -- Verification needs build, install, launch, screenshot, hierarchy, or HDC logs. - -Do not use this agent for unrelated Android/iOS/Web work or for general-purpose shell automation. diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/action-output.schema.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/action-output.schema.json deleted file mode 100644 index 5b2f75d9..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/action-output.schema.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "object", - "properties": { - "summary": { "type": "string" }, - "evidence": { "type": "array" }, - "blocked": { "type": "boolean" }, - "diagnostics": { "type": "array" } - } -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/harmony-dev-context.schema.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/harmony-dev-context.schema.json deleted file mode 100644 index 8ecca1c5..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/harmony-dev-context.schema.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "type": "object", - "properties": { - "workspacePath": { "type": "string" }, - "project": { "type": "object" }, - "target": { "type": ["object", "null"] }, - "app": { "type": "object" }, - "build": { "type": ["object", "null"] }, - "runtime": { "type": ["object", "null"] }, - "screen": { "type": ["object", "null"] }, - "selection": { "type": ["object", "null"] }, - "diagnostics": { "type": "array" } - } -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/screen-context.schema.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/screen-context.schema.json deleted file mode 100644 index edf3851f..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/schemas/screen-context.schema.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "object", - "properties": { - "screenshotId": { "type": ["string", "null"] }, - "boundsPercent": { "type": "object" }, - "hierarchyNode": { "type": ["object", "null"] }, - "sourceHints": { "type": "object" } - } -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/arkui-component-workflow.md b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/arkui-component-workflow.md deleted file mode 100644 index 0f446102..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/arkui-component-workflow.md +++ /dev/null @@ -1,9 +0,0 @@ -# ArkUI Component Workflow - -For UI edits: -- Use selected screenshot or hierarchy context as a pointer. -- Search by visible text, accessibility id, component type, page path, resource key, and nearby component names. -- Read the target ETS files before editing. -- Keep layout changes localized and deterministic. -- Prefer ArkUI-native layout primitives and resource usage already present in the project. -- After editing, use GetFileDiff and then build/verify when feasible. diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/build-install-verify.md b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/build-install-verify.md deleted file mode 100644 index bf562dfd..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/build-install-verify.md +++ /dev/null @@ -1,12 +0,0 @@ -# Build Install Verify - -Default verification loop: -1. Detect project and toolchain. -2. Build with HVigor. -3. If an HDC target is online, install the latest artifact. -4. Launch the detected bundle and ability. -5. Capture screenshot and UI hierarchy when probes are available. -6. Summarize evidence and gaps. - -If no target is online, report build evidence and the blocked device gate explicitly. -Do not treat Hot Reload as proof; use it only as an optional acceleration path. diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/device-preview-context.md b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/device-preview-context.md deleted file mode 100644 index 1f33cc21..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/device-preview-context.md +++ /dev/null @@ -1,14 +0,0 @@ -# Device Preview Context - -Preview context can include: -- workspace path -- bundle, module, ability -- target id and state -- screenshot id/path/timestamp -- UI hierarchy timestamp and selected node -- selected region bounds -- source hints and confidence -- recent build/install/launch diagnostics - -Use low-confidence region selections as visual pointers only. -High-confidence selections still require source inspection before edits. diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/harmony-fundamentals.md b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/harmony-fundamentals.md deleted file mode 100644 index a66c0ca8..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/harmony-fundamentals.md +++ /dev/null @@ -1,13 +0,0 @@ -# HarmonyOS Fundamentals - -Key files: -- `build-profile.json5`: products, runtimeOS, target SDK, modules, build modes, signing references. -- `AppScope/app.json5`: bundle name, app label, icon, version. -- `entry/src/main/module.json5`: module name, main ability, device types, permissions, pages, ability entries. -- `entry/src/main/ets`: ArkTS abilities, pages, components, services, and models. -- `entry/src/main/resources`: strings, colors, media, profiles, and page lists. - -Detection rules: -- Confirm `runtimeOS` is `HarmonyOS`. -- Read bundle/module/ability metadata before editing. -- Treat signing material as sensitive. Only report whether signing is configured and whether referenced files appear present. diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/signing-and-permissions.md b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/signing-and-permissions.md deleted file mode 100644 index 2a075ac1..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/signing-and-permissions.md +++ /dev/null @@ -1,16 +0,0 @@ -# Signing And Permissions - -Never reveal: -- signing passwords -- store passwords -- private keys -- certificate secret material -- token-like values - -Safe to report: -- signing configured: true/false -- material paths present: true/false -- profile/certificate missing at a high level -- permission names and ability usage from module config - -If signing blocks install, ask the user to refresh local DevEco signing material or choose unsigned build validation when appropriate. diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/troubleshooting.md b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/troubleshooting.md deleted file mode 100644 index e27a6d33..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/skills/troubleshooting.md +++ /dev/null @@ -1,9 +0,0 @@ -# Troubleshooting - -Common gates: -- No HDC target: install, launch, screenshot, hierarchy, and hilog are blocked. -- Emulator listed but not online: start public emulator and wait for HDC target. -- Beta/non-public emulator: do not default to it. -- HVigor failure: read the redacted build diagnostic and relevant source/config. -- Launch failure: verify bundleName, ability name, module config, install result, and `aa start` output. -- Screenshot or hierarchy failure: report the exact missing probe and continue with available evidence. diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/build_project.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/build_project.js deleted file mode 100644 index 7761247f..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/build_project.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callHarmonyRuntime } = require("./harmony_bridge"); - -async function run(input, context) { - return callHarmonyRuntime( - "buildProject", - input, - context, - "Ran the HarmonyOS build pipeline.", - ); -} - -module.exports = { run }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/build_project.tool.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/build_project.tool.json deleted file mode 100644 index d6ee906b..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/build_project.tool.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "build_project", - "description": "Run the HarmonyOS HVigor build pipeline and return structured, redacted build diagnostics and artifact state.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workspacePath": { "type": "string" }, - "includeTests": { "type": "boolean" }, - "timeoutMs": { "type": "number" } - } - }, - "runtime": "javascript", - "entry": "tools/build_project.js", - "readonly": false, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 24000 -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/capture_screen.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/capture_screen.js deleted file mode 100644 index 2369128a..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/capture_screen.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callHarmonyRuntime } = require("./harmony_bridge"); - -async function run(input, context) { - return callHarmonyRuntime( - "captureScreen", - input, - context, - "Captured HarmonyOS screen evidence.", - ); -} - -module.exports = { run }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/capture_screen.tool.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/capture_screen.tool.json deleted file mode 100644 index 7966e5b2..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/capture_screen.tool.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "capture_screen", - "description": "Capture HarmonyOS screen evidence from the selected emulator or HDC target when the probe is available.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workspacePath": { "type": "string" }, - "targetId": { "type": "string" }, - "emulatorName": { "type": "string" } - } - }, - "runtime": "javascript", - "entry": "tools/capture_screen.js", - "readonly": true, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 24000 -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/detect_project.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/detect_project.js deleted file mode 100644 index 8cdf509c..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/detect_project.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callHarmonyRuntime } = require("./harmony_bridge"); - -async function run(input, context) { - return callHarmonyRuntime( - "detectProject", - input, - context, - "Detected the HarmonyOS project identity.", - ); -} - -module.exports = { run }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/detect_project.tool.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/detect_project.tool.json deleted file mode 100644 index 567efbc7..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/detect_project.tool.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "detect_project", - "description": "Detect whether the current workspace is a HarmonyOS project and load bundle, module, ability, SDK, signing, and source hints.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workspacePath": { "type": "string" } - } - }, - "runtime": "javascript", - "entry": "tools/detect_project.js", - "readonly": true, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 16000 -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/dump_hierarchy.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/dump_hierarchy.js deleted file mode 100644 index 1e9eea3a..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/dump_hierarchy.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callHarmonyRuntime } = require("./harmony_bridge"); - -async function run(input, context) { - return callHarmonyRuntime( - "dumpHierarchy", - input, - context, - "Captured HarmonyOS UI hierarchy evidence.", - ); -} - -module.exports = { run }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/dump_hierarchy.tool.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/dump_hierarchy.tool.json deleted file mode 100644 index 78e74339..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/dump_hierarchy.tool.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "dump_hierarchy", - "description": "Capture HarmonyOS UI hierarchy JSON for source mapping when the target-side probe is available.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workspacePath": { "type": "string" }, - "targetId": { "type": "string" } - } - }, - "runtime": "javascript", - "entry": "tools/dump_hierarchy.js", - "readonly": true, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 24000 -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/get_runtime_context.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/get_runtime_context.js deleted file mode 100644 index 5ea9a137..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/get_runtime_context.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callHarmonyRuntime } = require("./harmony_bridge"); - -async function run(input, context) { - return callHarmonyRuntime( - input?.includeDiagnostics ? "readDiagnostics" : "getRuntimeState", - input, - context, - "Loaded the HarmonyOS runtime context.", - ); -} - -module.exports = { run }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/get_runtime_context.tool.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/get_runtime_context.tool.json deleted file mode 100644 index 0296b40a..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/get_runtime_context.tool.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "get_runtime_context", - "description": "Read the current HarmonyOS device, build, runtime, screenshot, hierarchy, and diagnostic context.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workspacePath": { "type": "string" }, - "includeDiagnostics": { "type": "boolean" } - } - }, - "runtime": "javascript", - "entry": "tools/get_runtime_context.js", - "readonly": true, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 24000 -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/harmony_bridge.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/harmony_bridge.js deleted file mode 100644 index 8d203a21..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/harmony_bridge.js +++ /dev/null @@ -1,26 +0,0 @@ -const BRIDGE_ID = "builtin-harmony-runtime"; -const CAPABILITY_ID = "sparo.harmonyDev"; - -function normalizeInput(input = {}, context = {}) { - const payload = { ...input }; - if (!payload.workspacePath && !payload.workspace_path && context.workspaceRoot) { - payload.workspacePath = context.workspaceRoot; - } - return payload; -} - -function callHarmonyRuntime(action, input, context, summary) { - return { - summary, - bridgeCall: { - bridgeId: BRIDGE_ID, - capabilityId: CAPABILITY_ID, - action, - input: normalizeInput(input, context), - }, - }; -} - -module.exports = { - callHarmonyRuntime, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/install_and_launch.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/install_and_launch.js deleted file mode 100644 index 7c1a8333..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/install_and_launch.js +++ /dev/null @@ -1,15 +0,0 @@ -const { callHarmonyRuntime } = require("./harmony_bridge"); - -async function run(input = {}, context) { - const action = input.mode === "launch" ? "launchAbility" : "installApp"; - return callHarmonyRuntime( - action, - input, - context, - input.mode === "launch" - ? "Launched the HarmonyOS ability through HDC." - : "Installed the HarmonyOS artifact through HDC.", - ); -} - -module.exports = { run }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/install_and_launch.tool.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/install_and_launch.tool.json deleted file mode 100644 index beb0a0bb..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/agents/harmonyos-dev-agent/source/tools/install_and_launch.tool.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "install_and_launch", - "description": "Install the latest HarmonyOS artifact or launch the detected ability on the current HDC target. Pass mode='launch' for the launch step.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workspacePath": { "type": "string" }, - "mode": { "type": "string", "enum": ["install", "launch"] }, - "targetId": { "type": "string" }, - "artifactPath": { "type": "string" }, - "bundleName": { "type": "string" }, - "abilityName": { "type": "string" } - } - }, - "runtime": "javascript", - "entry": "tools/install_and_launch.js", - "readonly": false, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 24000 -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/component.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/component.json deleted file mode 100644 index 9a8e8e16..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/component.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "id": "builtin-harmony-runtime", - "kind": "bridge", - "name": "HarmonyOS Dev Runtime", - "description": "DevEco, HVigor, HDC, Emulator, build, install, launch, screenshot, hierarchy, and diagnostics bridge for HarmonyOS Dev.", - "packageSource": "appPrivate", - "ownerApp": { - "appId": "builtin-harmony-dev", - "appVersion": "13.0.0" - }, - "capabilities": [ - { - "id": "sparo.harmonyDev", - "title": "HarmonyOS Dev Runtime", - "description": "Detect HarmonyOS project identity, resolve DevEco/HVigor/HDC/Emulator tooling, run build/install/launch loops, capture device facts, and report redacted diagnostics.", - "actions": [ - "detectProject", - "detectToolchain", - "listEmulators", - "startEmulator", - "stopEmulator", - "listTargets", - "runUnitTests", - "assembleApp", - "buildProject", - "hotReload", - "installApp", - "launchAbility", - "captureScreen", - "readScreenshot", - "dumpHierarchy", - "readLogs", - "readDiagnostics", - "getRuntimeState" - ] - } - ], - "permissions": [ - { - "kind": "fs", - "summary": "Reads HarmonyOS project metadata and writes workspace-local runtime facts, screenshots, build state, and diagnostics.", - "scopes": [ - "{workspace}", - "{app}", - "{home}" - ] - }, - { - "kind": "shell", - "summary": "Runs the declared HarmonyOS project, DevEco, HVigor, HDC, Emulator, Java, and package-manager commands.", - "scopes": [ - "node", - "git", - "hvigorw", - "hvigorw.bat", - "hdc", - "hdc.exe", - "ohpm", - "ohpm.bat", - "java", - "java.exe", - "Emulator.exe" - ] - } - ], - "usedByApps": [ - "builtin-harmony-dev" - ], - "visibility": "appDependency", - "dependencies": [], - "implementationRef": "app://builtin-harmony-dev@13.0.0/bridges/builtin-harmony-runtime", - "usesCapabilities": [ - "filesystem.read", - "filesystem.write", - "terminal.run", - "process.spawn", - "workspace.inspect", - "component.run" - ] -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/bundle.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/bundle.json deleted file mode 100644 index 3f0b6613..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/bundle.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "schemaVersion": 1, - "id": "builtin-harmony-runtime", - "version": 6 -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/manifest.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/manifest.json deleted file mode 100644 index ec50057b..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/manifest.json +++ /dev/null @@ -1,362 +0,0 @@ -{ - "schemaVersion": 1, - "id": "builtin-harmony-runtime", - "name": "HarmonyOS Dev Runtime", - "description": "DevEco, HVigor, HDC, Emulator, build, install, launch, screenshot, hierarchy, and diagnostics bridge for HarmonyOS Dev.", - "kind": "daemon", - "runtime": { - "language": "javascript", - "entry": "worker.js" - }, - "surfaces": { - "launchableApp": false, - "agent": true, - "tool": true, - "productAppRuntimeBackend": true - }, - "permissions": { - "fs": [ - "{workspace}", - "{app}", - "{home}" - ], - "net": [], - "shell": [ - "node", - "git", - "hvigorw", - "hvigorw.bat", - "hdc", - "hdc.exe", - "ohpm", - "ohpm.bat", - "java", - "java.exe", - "Emulator.exe" - ], - "gui": [], - "secrets": [] - }, - "capabilities": [ - { - "id": "sparo.harmonyDev", - "title": "HarmonyOS Dev Runtime", - "description": "Detect HarmonyOS project identity, resolve DevEco/HVigor/HDC/Emulator tooling, run build/install/launch loops, capture device facts, and report redacted diagnostics.", - "category": "mobileDev", - "actions": [ - "detectProject", - "detectToolchain", - "listEmulators", - "startEmulator", - "stopEmulator", - "listTargets", - "runUnitTests", - "assembleApp", - "buildProject", - "hotReload", - "installApp", - "launchAbility", - "captureScreen", - "readScreenshot", - "dumpHierarchy", - "readLogs", - "readDiagnostics", - "getRuntimeState" - ], - "streaming": false, - "cancelable": true, - "resumable": true, - "usableBy": [ - "agentComponent", - "productAppRuntime", - "management" - ], - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - } - } - ], - "actions": [ - { - "name": "detectProject", - "description": "Detect a HarmonyOS workspace and return bundle/module/ability/project identity without exposing signing secrets.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "detectToolchain", - "description": "Resolve DevEco SDK, JBR, HVigor, HDC, ohpm, and Emulator paths even when they are not on PATH.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "listEmulators", - "description": "List DevEco emulator instances and rank public API-compatible targets ahead of beta/non-public images.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "startEmulator", - "description": "Start a selected DevEco emulator instance and wait for an HDC target gate.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "stopEmulator", - "description": "Stop a selected DevEco emulator instance and refresh emulator and HDC target facts.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "listTargets", - "description": "List online HDC targets.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "runUnitTests", - "description": "Run the HarmonyOS module unit-test task through HVigor.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "assembleApp", - "description": "Build a HarmonyOS app artifact through HVigor assembleApp.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "buildProject", - "description": "Run the structured build pipeline and persist redacted build state.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "hotReload", - "description": "Attempt the experimental HVigor hot reload build and report fallback diagnostics without making hot reload the correctness path.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "installApp", - "description": "Install the latest app or HAP artifact to an online HDC target.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "launchAbility", - "description": "Launch the detected bundle and main ability on an online HDC target.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "captureScreen", - "description": "Capture a screenshot from a running emulator or HDC target when supported.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "readScreenshot", - "description": "Read a previously captured HarmonyOS Dev screenshot from the workspace runtime screenshot directory.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "dumpHierarchy", - "description": "Capture UI hierarchy JSON when the target-side probe is available.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "readLogs", - "description": "Read a short HDC hilog window and app runtime log paths.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "readDiagnostics", - "description": "Read redacted HarmonyOS project diagnostics, recent runtime state, and source control summary.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "getRuntimeState", - "description": "Read the persisted workspace-local HarmonyOS runtime state.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - } - ], - "tools": [ - { - "name": "HarmonyOSDevRuntime", - "description": "Detect HarmonyOS projects, operate the DevEco/HVigor/HDC/Emulator toolchain, and capture device facts for collaborative ArkTS/ArkUI development.", - "capabilityId": "sparo.harmonyDev", - "action": "detectProject", - "actions": [ - "detectProject", - "detectToolchain", - "listEmulators", - "startEmulator", - "stopEmulator", - "listTargets", - "runUnitTests", - "assembleApp", - "buildProject", - "hotReload", - "installApp", - "launchAbility", - "captureScreen", - "readScreenshot", - "dumpHierarchy", - "readLogs", - "readDiagnostics", - "getRuntimeState" - ], - "readonly": false, - "needsPermissions": true, - "inputSchema": { - "type": "object" - }, - "ui": { - "card": { - "kind": "appDefined", - "displayName": "HarmonyOS Dev Runtime", - "icon": "Smartphone", - "resultDisplayType": "detailed", - "displayMode": "compact", - "family": "bridge-component", - "title": "HarmonyOS Dev Runtime" - } - } - } - ] -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/package.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/package.json deleted file mode 100644 index 384a459e..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "builtin-harmony-runtime", - "private": true, - "version": "1.0.0", - "type": "commonjs" -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/build.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/build.js deleted file mode 100644 index 87d1bed5..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/build.js +++ /dev/null @@ -1,107 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { detectToolchain } = require("./harmony-env"); -const { runCommand } = require("./command"); -const { diagnostic, redactValue } = require("./redact"); -const { latestArtifact } = require("./device"); -const { runDir } = require("./paths"); -const { readRuntimeState, writeRuntimeState } = require("./runtime-state"); - -function makeRunId(prefix) { - return `${prefix}-${new Date().toISOString().replace(/[:.]/g, "-")}`; -} - -function buildResult(workspacePath, runId, stage, commandResult, artifact = null, extraDiagnostics = []) { - const ok = commandResult.exitCode === 0; - const diagnostics = [...extraDiagnostics]; - if (!ok) { - diagnostics.push(diagnostic("error", commandResult.stderr || commandResult.stdout || `${stage} failed`, { stage })); - } - const build = { - runId, - stage, - status: ok ? "completed" : "failed", - command: path.basename(commandResult.command || ""), - exitCode: commandResult.exitCode, - durationMs: commandResult.durationMs, - artifact, - diagnostics, - logPath: commandResult.logPath || null, - updatedAt: Date.now(), - }; - const runtimeState = writeRuntimeState(workspacePath, { build, latestArtifact: artifact || readRuntimeState(workspacePath).latestArtifact, diagnostics }); - return redactValue({ ok, build, artifact, diagnostics, runtimeState }); -} - -function runHvigor(input, actionName, args, options = {}) { - const workspacePath = input.workspacePath; - const toolchainResult = detectToolchain(input); - const hvigorw = toolchainResult.toolchain.hvigorw.path; - const diagnostics = []; - if (!hvigorw) { - diagnostics.push(diagnostic("warning", "hvigorw was not found", { stage: actionName })); - return { ok: false, diagnostics, runtimeState: writeRuntimeState(workspacePath, { diagnostics }) }; - } - const runId = input.runId || makeRunId(actionName); - const logPath = path.join(runDir(workspacePath, runId), `${actionName}.log`); - const command = runCommand(hvigorw, args, { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: options.timeoutMs || input.timeoutMs || 240000, - logPath, - label: actionName, - }); - command.logPath = logPath; - const artifact = options.findArtifact ? latestArtifact(workspacePath) : null; - return buildResult(workspacePath, runId, actionName, command, artifact, diagnostics); -} - -function runUnitTests(input = {}) { - return runHvigor(input, "test", ["test", "--mode", "module", "-p", "module=entry", "--info", "--no-daemon"], { timeoutMs: input.timeoutMs || 180000 }); -} - -function assembleApp(input = {}) { - return runHvigor(input, "assemble", ["assembleApp", "--incremental", "--parallel", "--info"], { timeoutMs: input.timeoutMs || 240000, findArtifact: true }); -} - -function buildProject(input = {}) { - const workspacePath = input.workspacePath; - const diagnostics = []; - let test = null; - if (input.includeTests) { - test = runUnitTests(input); - if (!test.ok) { - const runtimeState = writeRuntimeState(workspacePath, { build: test.build, diagnostics: test.diagnostics }); - return redactValue({ ok: false, test, diagnostics: test.diagnostics, runtimeState }); - } - } - const assemble = assembleApp(input); - const runtimeState = writeRuntimeState(workspacePath, { - build: assemble.build, - latestArtifact: assemble.artifact || latestArtifact(workspacePath), - diagnostics: [...diagnostics, ...assemble.diagnostics], - }); - return redactValue({ ok: assemble.ok, test, assemble, build: assemble.build, artifact: assemble.artifact, diagnostics: assemble.diagnostics, runtimeState }); -} - -function hotReload(input = {}) { - const result = runHvigor(input, "hotReload", ["assembleApp", "--hot-reload-build", "--info"], { timeoutMs: input.timeoutMs || 180000, findArtifact: true }); - const capabilities = { - ...readRuntimeState(input.workspacePath).capabilities, - hotReload: result.ok ? "available" : "experimental", - }; - const fallbackDiagnostic = result.ok ? [] : [diagnostic("info", "Hot Reload is optional; use Build & Run as the correctness path.", { stage: "hotReload" })]; - const runtimeState = writeRuntimeState(input.workspacePath, { - build: result.build, - capabilities, - diagnostics: [...(result.diagnostics || []), ...fallbackDiagnostic], - }); - return redactValue({ ...result, diagnostics: [...(result.diagnostics || []), ...fallbackDiagnostic], runtimeState }); -} - -module.exports = { - assembleApp, - buildProject, - hotReload, - runUnitTests, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/command.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/command.js deleted file mode 100644 index 9dacb822..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/command.js +++ /dev/null @@ -1,79 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); -const { redactText } = require("./redact"); - -function tail(value, max = 10000) { - const text = redactText(value || ""); - return text.length > max ? text.slice(text.length - max) : text; -} - -function writeLog(logPath, label, commandLine, result) { - if (!logPath) return; - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - const body = [ - `# ${label}`, - "", - `$ ${commandLine}`, - "", - `exitCode: ${result.exitCode}`, - "", - "## stdout", - result.stdout || "", - "", - "## stderr", - result.stderr || "", - "", - ].join("\n"); - fs.writeFileSync(logPath, body, "utf8"); -} - -function runCommand(command, args = [], options = {}) { - const startedAt = Date.now(); - const env = { - ...process.env, - ...(options.env || {}), - }; - const isWindowsScript = process.platform === "win32" && /\.(cmd|bat)$/i.test(command); - const quoteCmdArg = (value) => `"${String(value).replace(/"/g, '""')}"`; - const executable = isWindowsScript ? process.env.ComSpec || "cmd.exe" : command; - const executableArgs = isWindowsScript - ? ["/d", "/c", `call ${quoteCmdArg(command)} ${args.map(quoteCmdArg).join(" ")}`] - : args.map(String); - const result = spawnSync(executable, executableArgs, { - cwd: options.cwd, - env, - encoding: "utf8", - windowsHide: true, - timeout: options.timeoutMs || 60000, - shell: false, - windowsVerbatimArguments: isWindowsScript, - maxBuffer: options.maxBuffer || 12 * 1024 * 1024, - }); - const output = { - command, - args: args.map(String), - commandLine: [command, ...args.map(String)].join(" "), - cwd: options.cwd || process.cwd(), - exitCode: typeof result.status === "number" ? result.status : (result.error ? 1 : 0), - timedOut: result.error?.code === "ETIMEDOUT", - durationMs: Date.now() - startedAt, - stdout: tail(result.stdout), - stderr: tail(result.stderr || result.error?.message || ""), - }; - writeLog(options.logPath, options.label || path.basename(command), output.commandLine, output); - return output; -} - -function fileExists(filePath) { - try { - return Boolean(filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()); - } catch { - return false; - } -} - -module.exports = { - fileExists, - runCommand, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/device.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/device.js deleted file mode 100644 index 12754ee4..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/device.js +++ /dev/null @@ -1,521 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { detectToolchain } = require("./harmony-env"); -const { runCommand } = require("./command"); -const { diagnostic, redactValue } = require("./redact"); -const { hierarchyDir, screenshotsDir, safeFilePart } = require("./paths"); -const { readRuntimeState, writeRuntimeState } = require("./runtime-state"); -const { projectIdentity } = require("./project"); - -function withTargetArgs(targetId, args) { - return targetId ? ["-t", targetId, ...args] : args; -} - -function boolString(value) { - return value === true || String(value).toLowerCase() === "true"; -} - -function parseEmulatorDetails(output) { - try { - const text = String(output || ""); - const start = text.indexOf("["); - const jsonText = start >= 0 ? text.slice(start) : text; - const items = JSON.parse(jsonText || "[]"); - return Array.isArray(items) ? items : []; - } catch { - return []; - } -} - -function resolveCaptureEmulator(input, emulatorPath, env, diagnostics) { - const requested = input.emulatorName || input.name; - if (requested) return requested; - - const runtimeState = input.workspacePath ? readRuntimeState(input.workspacePath) : {}; - const selected = runtimeState.selectedEmulator; - if (selected?.name && selected.isRunning) return selected.name; - - if (!emulatorPath) return null; - const result = runCommand(emulatorPath, ["-list", "-details"], { - cwd: input.workspacePath, - env, - timeoutMs: 20000, - }); - if (result.exitCode !== 0) { - diagnostics.push(diagnostic("info", result.stderr || result.stdout || "Could not list running emulators for screenshot capture", { - stage: "captureScreen", - source: "emulator", - })); - return null; - } - const running = parseEmulatorDetails(result.stdout).find((item) => boolString(item.isRunning)); - return running?.name || null; -} - -function parseTargets(output) { - const text = String(output || "").trim(); - if (!text || text.includes("[Empty]")) return []; - return text.split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .filter((line) => !/^\[/.test(line)) - .map((line) => { - const parts = line.split(/\s+/); - const raw = line; - const id = parts[0]; - const uart = /^COM\d+$/i.test(id) || /\bUART\b/i.test(raw); - return { - id, - serial: id, - state: /offline/i.test(line) ? "offline" : "online", - model: parts.slice(1).join(" ") || null, - raw, - kind: uart ? "uart" : (/127\.0\.0\.1|localhost|:\d{4,5}|emulator|qemu|PHEMU|PCEMU/i.test(raw) ? "emulator" : "device"), - usable: !uart, - }; - }); -} - -function listTargets(input = {}) { - const toolchainResult = detectToolchain(input); - const hdc = toolchainResult.toolchain.hdc.path; - if (!hdc) { - return { ok: false, targets: [], diagnostics: [diagnostic("warning", "hdc was not found", { stage: "listTargets" })] }; - } - const result = runCommand(hdc, ["list", "targets", "-v"], { - cwd: input.workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: input.timeoutMs || 15000, - }); - const observedTargets = parseTargets(result.stdout || result.stderr); - const targets = observedTargets.filter((target) => target.usable && target.state === "online"); - const diagnostics = targets.length - ? [] - : [diagnostic("info", observedTargets.length - ? "Only UART or non-deployable HDC endpoints were detected" - : "No HDC target is online", { stage: "listTargets" })]; - const runtimeState = input.workspacePath ? writeRuntimeState(input.workspacePath, { - targets, - observedTargets, - capabilities: { - ...readRuntimeState(input.workspacePath).capabilities, - hdcTarget: targets.length > 0, - }, - diagnostics, - }) : null; - return redactValue({ ok: targets.length > 0, targets, observedTargets, diagnostics, runtimeState }); -} - -function latestArtifact(workspacePath) { - const state = readRuntimeState(workspacePath); - if (state.latestArtifact?.path && fs.existsSync(state.latestArtifact.path)) return state.latestArtifact; - const matches = []; - const stack = [workspacePath]; - const ignored = new Set([".git", "oh_modules", "node_modules", ".sparo_os"]); - while (stack.length) { - const current = stack.pop(); - let entries = []; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - if (!ignored.has(entry.name)) stack.push(absolute); - } else if (entry.isFile() && /\.(app|hap)$/i.test(entry.name)) { - const stat = fs.statSync(absolute); - matches.push({ path: absolute, size: stat.size, createdAt: stat.mtimeMs, kind: path.extname(entry.name).slice(1).toLowerCase() }); - } - } - } - return matches.sort((a, b) => b.createdAt - a.createdAt)[0] || null; -} - -function installApp(input = {}) { - const workspacePath = input.workspacePath; - const toolchainResult = detectToolchain(input); - const hdc = toolchainResult.toolchain.hdc.path; - const targets = listTargets(input).targets; - const target = input.targetId || targets[0]?.id; - const artifact = input.artifactPath ? { path: input.artifactPath } : latestArtifact(workspacePath); - const diagnostics = []; - if (!hdc) diagnostics.push(diagnostic("warning", "hdc was not found", { stage: "installApp" })); - if (!target) diagnostics.push(diagnostic("warning", "Install needs an online HDC target", { stage: "installApp" })); - if (!artifact?.path || !fs.existsSync(artifact.path)) diagnostics.push(diagnostic("warning", "No app or HAP artifact was found", { stage: "installApp" })); - if (diagnostics.length) return { ok: false, diagnostics, targets, artifact }; - const run = runCommand(hdc, withTargetArgs(target, ["install", "-r", artifact.path]), { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: input.timeoutMs || 120000, - }); - const ok = run.exitCode === 0; - if (!ok) diagnostics.push(diagnostic("error", run.stderr || run.stdout || "hdc install failed", { stage: "installApp" })); - const installState = { - status: ok ? "completed" : "failed", - targetId: target, - artifact, - updatedAt: Date.now(), - diagnostics, - }; - const runtimeState = writeRuntimeState(workspacePath, { install: installState, latestArtifact: artifact, diagnostics }); - return redactValue({ ok, install: installState, diagnostics, runtimeState }); -} - -function launchAbility(input = {}) { - const workspacePath = input.workspacePath; - const project = projectIdentity(workspacePath); - const module = (project.modules || [])[0] || {}; - const ability = input.abilityName || module.mainElement || module.abilities?.[0]?.name; - const bundleName = input.bundleName || project.bundleName || project.app?.bundleName; - const toolchainResult = detectToolchain(input); - const hdc = toolchainResult.toolchain.hdc.path; - const targets = listTargets(input).targets; - const target = input.targetId || targets[0]?.id; - const diagnostics = []; - if (!hdc) diagnostics.push(diagnostic("warning", "hdc was not found", { stage: "launchAbility" })); - if (!target) diagnostics.push(diagnostic("warning", "Launch needs an online HDC target", { stage: "launchAbility" })); - if (!bundleName || !ability) diagnostics.push(diagnostic("warning", "Bundle name or ability name was not detected", { stage: "launchAbility" })); - if (diagnostics.length) return { ok: false, diagnostics, targets, project }; - const launch = runCommand(hdc, withTargetArgs(target, ["shell", "aa", "start", "-a", ability, "-b", bundleName]), { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: input.timeoutMs || 90000, - }); - const dump = runCommand(hdc, withTargetArgs(target, ["shell", "bm", "dump", "-n", bundleName]), { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: 30000, - }); - const ok = launch.exitCode === 0; - const capabilities = { - ...readRuntimeState(workspacePath).capabilities, - abilityLaunch: ok ? "available" : "unavailable", - bundleDump: dump.exitCode === 0 ? "available" : "unavailable", - }; - if (!ok) diagnostics.push(diagnostic("error", launch.stderr || launch.stdout || "aa start failed", { stage: "launchAbility" })); - const runtime = { - status: ok ? "running" : "failed", - targetId: target, - bundleName, - abilityName: ability, - bundleDumpAvailable: dump.exitCode === 0, - updatedAt: Date.now(), - diagnostics, - }; - const runtimeState = writeRuntimeState(workspacePath, { runtime, capabilities, diagnostics }); - return redactValue({ ok, runtime, launch, bundleDump: dump.exitCode === 0 ? dump.stdout : null, diagnostics, runtimeState }); -} - -function readImageFile(filePath) { - const data = fs.readFileSync(filePath); - const ext = path.extname(filePath).slice(1).toLowerCase() || "png"; - const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png"; - const dimensions = imageDimensions(data, ext); - return { - dataUrl: `data:${mime};base64,${data.toString("base64")}`, - size: data.length, - width: dimensions.width, - height: dimensions.height, - }; -} - -function imageDimensions(data, ext) { - if (ext === "png" && data.length >= 24 && data.toString("ascii", 1, 4) === "PNG") { - return { - width: data.readUInt32BE(16), - height: data.readUInt32BE(20), - }; - } - if ((ext === "jpg" || ext === "jpeg") && data.length > 4) { - let offset = 2; - while (offset + 9 < data.length) { - if (data[offset] !== 0xff) break; - const marker = data[offset + 1]; - const length = data.readUInt16BE(offset + 2); - if (length < 2) break; - if (marker >= 0xc0 && marker <= 0xc3) { - return { - width: data.readUInt16BE(offset + 7), - height: data.readUInt16BE(offset + 5), - }; - } - offset += 2 + length; - } - } - return { width: null, height: null }; -} - -function sleepSync(ms) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function waitForStableFile(filePath, options = {}) { - const timeoutMs = options.timeoutMs || 12000; - const intervalMs = options.intervalMs || 160; - const minBytes = options.minBytes || 32; - const stableReads = options.stableReads || 2; - const deadline = Date.now() + timeoutMs; - let previousSize = -1; - let stableCount = 0; - - while (Date.now() < deadline) { - if (fs.existsSync(filePath)) { - const stat = fs.statSync(filePath); - if (stat.size >= minBytes && stat.size === previousSize) { - stableCount += 1; - if (stableCount >= stableReads) return { path: filePath, size: stat.size, stable: true }; - } else { - stableCount = 0; - previousSize = stat.size; - } - } - sleepSync(intervalMs); - } - - if (!fs.existsSync(filePath)) return null; - const stat = fs.statSync(filePath); - return { path: filePath, size: stat.size, stable: false }; -} - -function listScreenshotFiles(dir) { - try { - return fs.readdirSync(dir) - .filter((file) => /\.(png|jpe?g)$/i.test(file)) - .map((file) => { - const filePath = path.join(dir, file); - const stat = fs.statSync(filePath); - return { path: filePath, mtimeMs: stat.mtimeMs, size: stat.size }; - }); - } catch { - return []; - } -} - -function findCreatedScreenshot(dir, before, startedAt) { - const previous = new Map(before.map((item) => [item.path, `${item.mtimeMs}:${item.size}`])); - return listScreenshotFiles(dir) - .filter((item) => previous.get(item.path) !== `${item.mtimeMs}:${item.size}` || item.mtimeMs >= startedAt - 1000) - .sort((left, right) => right.mtimeMs - left.mtimeMs)[0] || null; -} - -function assertWorkspaceScreenshot(workspacePath, filePath) { - const base = path.resolve(screenshotsDir(workspacePath)); - const resolved = path.resolve(filePath || ""); - const relative = path.relative(base, resolved); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { - throw new Error("Screenshot path is outside the HarmonyOS Dev screenshot directory."); - } - return resolved; -} - -function readScreenshot(input = {}) { - const workspacePath = input.workspacePath; - const diagnostics = []; - const requestedPath = assertWorkspaceScreenshot(workspacePath, input.path || input.screen?.path); - const stable = waitForStableFile(requestedPath, { timeoutMs: input.timeoutMs || 12000 }); - if (!stable?.path || !stable.stable || stable.size <= 0) { - diagnostics.push(diagnostic("warning", "Screenshot file is not ready", { - stage: "readScreenshot", - path: requestedPath, - })); - return { ok: false, diagnostics }; - } - const image = readImageFile(requestedPath); - const screen = { - ...(input.screen || {}), - id: input.screen?.id || `screen-${Date.now()}`, - path: requestedPath, - uri: requestedPath, - dataUrl: image.dataUrl, - width: image.width || input.screen?.width || null, - height: image.height || input.screen?.height || null, - source: input.screen?.source || "cached-file", - sourceName: input.screen?.sourceName || null, - timestamp: input.screen?.timestamp || Date.now(), - size: image.size, - }; - return redactValue({ ok: true, screen, diagnostics }); -} - -function captureScreen(input = {}) { - const workspacePath = input.workspacePath; - const toolchainResult = detectToolchain(input); - const hdc = toolchainResult.toolchain.hdc.path; - const emulator = toolchainResult.toolchain.emulator.path; - const id = `screen-${Date.now()}`; - const captureDir = screenshotsDir(workspacePath); - let outPath = path.join(captureDir, `${safeFilePart(id)}.png`); - const diagnostics = []; - let result = null; - let source = "unavailable"; - let sourceName = null; - const emulatorName = resolveCaptureEmulator(input, emulator, toolchainResult.toolchain.env, diagnostics); - if (emulatorName && emulator) { - const before = listScreenshotFiles(captureDir); - const startedAt = Date.now(); - result = runCommand(emulator, ["-instance", emulatorName, "-screenshot", "-screenshotPath", captureDir], { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: input.timeoutMs || 60000, - }); - const captured = findCreatedScreenshot(captureDir, before, startedAt); - const stable = captured?.path ? waitForStableFile(captured.path, { timeoutMs: 15000 }) : null; - if (result.exitCode === 0 && stable?.path && stable.stable && stable.size > 0) { - outPath = stable.path; - source = "emulator"; - sourceName = emulatorName; - } else { - diagnostics.push(diagnostic("warning", result.stderr || result.stdout || "Emulator screenshot command did not produce a file", { - stage: "captureScreen", - source: "emulator", - emulatorName, - })); - } - } - if (source === "unavailable" && hdc) { - const targetResult = listTargets(input); - diagnostics.push(...(targetResult.diagnostics || [])); - const targets = targetResult.targets; - const target = input.targetId || targets[0]?.id; - if (target) { - const remote = `/data/local/tmp/${id}.png`; - const snap = runCommand(hdc, withTargetArgs(target, ["shell", "snapshot_display", "-f", remote]), { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: 45000, - }); - const recv = snap.exitCode === 0 - ? runCommand(hdc, withTargetArgs(target, ["file", "recv", remote, outPath]), { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: 45000, - }) - : snap; - result = recv; - const stable = recv.exitCode === 0 ? waitForStableFile(outPath, { timeoutMs: 8000 }) : null; - if (recv.exitCode === 0 && stable?.path && stable.stable && stable.size > 0) { - source = "target-shell"; - sourceName = target; - } else { - diagnostics.push(diagnostic("warning", recv.stderr || recv.stdout || "Target screenshot command did not produce a file", { - stage: "captureScreen", - source: "target-shell", - targetId: target, - })); - } - } - } - if (source === "unavailable" && !result) { - diagnostics.push(diagnostic("warning", "Screenshot needs a running emulator or an online HDC target", { stage: "captureScreen" })); - } - const image = source === "unavailable" ? null : readImageFile(outPath); - const screen = source === "unavailable" ? null : { - id, - path: outPath, - uri: outPath, - dataUrl: image.dataUrl, - width: input.width || image.width || null, - height: input.height || image.height || null, - source, - sourceName, - timestamp: Date.now(), - size: image.size, - }; - const capabilities = { - ...readRuntimeState(workspacePath).capabilities, - screenshot: source, - }; - const runtimeState = writeRuntimeState(workspacePath, { screen, capabilities, diagnostics }); - return redactValue({ ok: Boolean(screen), screen, diagnostics, runtimeState }); -} - -function dumpHierarchy(input = {}) { - const workspacePath = input.workspacePath; - const toolchainResult = detectToolchain(input); - const hdc = toolchainResult.toolchain.hdc.path; - const targets = listTargets(input).targets; - const target = input.targetId || targets[0]?.id; - const id = `hierarchy-${Date.now()}`; - const outPath = path.join(hierarchyDir(workspacePath), `${safeFilePart(id)}.json`); - const diagnostics = []; - let hierarchy = null; - if (hdc && target) { - const remote = `/data/local/tmp/${id}.json`; - const dump = runCommand(hdc, withTargetArgs(target, ["shell", "uitest", "dumpLayout", remote]), { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: 45000, - }); - const recv = dump.exitCode === 0 - ? runCommand(hdc, withTargetArgs(target, ["file", "recv", remote, outPath]), { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: 45000, - }) - : dump; - if (recv.exitCode === 0 && fs.existsSync(outPath)) { - try { - hierarchy = JSON.parse(fs.readFileSync(outPath, "utf8")); - } catch { - hierarchy = { nodes: [], parseError: true }; - } - } else { - diagnostics.push(diagnostic("warning", recv.stderr || recv.stdout || "UI hierarchy dump probe is unavailable", { stage: "dumpHierarchy" })); - } - } else { - diagnostics.push(diagnostic("warning", "UI hierarchy needs an online HDC target", { stage: "dumpHierarchy" })); - } - const hierarchyState = hierarchy ? { - id, - path: outPath, - timestamp: Date.now(), - root: hierarchy.root || hierarchy, - nodes: hierarchy.nodes || hierarchy.children || [], - } : null; - const capabilities = { - ...readRuntimeState(workspacePath).capabilities, - hierarchyDump: hierarchyState ? "available" : "unavailable", - }; - const runtimeState = writeRuntimeState(workspacePath, { hierarchy: hierarchyState, capabilities, diagnostics }); - return redactValue({ ok: Boolean(hierarchyState), hierarchy: hierarchyState, diagnostics, runtimeState }); -} - -function readLogs(input = {}) { - const workspacePath = input.workspacePath; - const toolchainResult = detectToolchain(input); - const hdc = toolchainResult.toolchain.hdc.path; - const targets = listTargets(input).targets; - const target = input.targetId || targets[0]?.id; - const diagnostics = []; - let logs = ""; - if (hdc && target) { - const result = runCommand(hdc, withTargetArgs(target, ["shell", "hilog", "-r", "-n", String(input.lines || 200)]), { - cwd: workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: 30000, - }); - logs = result.stdout || result.stderr || ""; - if (result.exitCode !== 0) diagnostics.push(diagnostic("warning", logs || "hilog probe failed", { stage: "readLogs" })); - } else { - diagnostics.push(diagnostic("info", "Logs need an online HDC target", { stage: "readLogs" })); - } - const capabilities = { - ...readRuntimeState(workspacePath).capabilities, - hilog: logs ? "available" : "unavailable", - }; - const runtimeState = writeRuntimeState(workspacePath, { logs: logs.slice(-10000), capabilities, diagnostics }); - return redactValue({ ok: Boolean(logs), logs: logs.slice(-10000), diagnostics, runtimeState }); -} - -module.exports = { - captureScreen, - dumpHierarchy, - installApp, - latestArtifact, - launchAbility, - listTargets, - readScreenshot, - readLogs, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/diagnostics.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/diagnostics.js deleted file mode 100644 index be91aece..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/diagnostics.js +++ /dev/null @@ -1,45 +0,0 @@ -const { runCommand } = require("./command"); -const { detectProject } = require("./project"); -const { detectToolchain } = require("./harmony-env"); -const { listEmulators } = require("./emulator"); -const { listTargets, readLogs } = require("./device"); -const { readRuntimeState } = require("./runtime-state"); -const { redactValue } = require("./redact"); - -function readDiagnostics(input = {}) { - const project = detectProject(input); - const toolchain = detectToolchain(input); - const targets = listTargets(input); - const emulators = listEmulators(input); - const runtimeState = readRuntimeState(input.workspacePath); - const git = runCommand("git", ["status", "--short"], { - cwd: input.workspacePath, - timeoutMs: 10000, - }); - const diagnostics = [ - ...(project.diagnostics || []), - ...(toolchain.diagnostics || []), - ...(targets.diagnostics || []), - ...(emulators.diagnostics || []), - ...(runtimeState.diagnostics || []), - ]; - return redactValue({ - ok: true, - project: project.project, - toolchain: toolchain.toolchain, - targets: targets.targets, - emulators: emulators.emulators, - recommendedEmulator: emulators.recommendedEmulator, - runtimeState, - sourceControl: { - exitCode: git.exitCode, - statusShort: git.stdout, - }, - diagnostics, - }); -} - -module.exports = { - readDiagnostics, - readLogs, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/emulator.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/emulator.js deleted file mode 100644 index 6a1b85fb..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/emulator.js +++ /dev/null @@ -1,298 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { spawn } = require("node:child_process"); -const { detectToolchain } = require("./harmony-env"); -const { runCommand } = require("./command"); -const { diagnostic, redactValue } = require("./redact"); -const { writeRuntimeState } = require("./runtime-state"); -const { listTargets } = require("./device"); - -function boolString(value) { - return value === true || String(value).toLowerCase() === "true"; -} - -function rankEmulator(item = {}) { - let rank = 0; - const publicImage = boolString(item["os.isPublic"]); - const api = String(item["os.apiVersion"] || ""); - const os = String(item["os.osVersion"] || ""); - const type = String(item.deviceType || ""); - if (publicImage) rank += 100; - if (api.includes("24")) rank += 40; - if (os.includes("6.1.1")) rank += 30; - if (type === "phone") rank += 25; - if (type === "foldable") rank += 8; - if (type === "triplefold") rank += 4; - if (boolString(item.isRunning)) rank += 10; - if (!publicImage || /beta/i.test(os)) rank -= 80; - return rank; -} - -function normalizeEmulator(item = {}) { - return { - name: item.name, - deviceType: item.deviceType || null, - deviceModel: item.deviceModel || item.productModel || null, - osVersion: item["os.osVersion"] || null, - apiVersion: item["os.apiVersion"] || null, - isPublic: boolString(item["os.isPublic"]), - isRunning: boolString(item.isRunning), - instancePath: item.instancePath || null, - logPath: item.instancePath ? path.join(item.instancePath, "Log").replace(/\\/g, "/") : null, - rank: rankEmulator(item), - }; -} - -function selectedEmulator(emulators, name) { - const list = Array.isArray(emulators) ? emulators : []; - if (name) return list.find((item) => item.name === name) || { name }; - return list[0] || null; -} - -function emulatorToStop(emulators, name, recommended) { - const list = Array.isArray(emulators) ? emulators : []; - if (name) return selectedEmulator(list, name); - return list.find((item) => item.isRunning) || recommended || list[0] || null; -} - -function emulatorTargets(targets) { - return (Array.isArray(targets) ? targets : []).filter((target) => { - const text = [target.id, target.serial, target.model, target.raw, target.kind].filter(Boolean).join(" "); - return target.kind === "emulator" || /127\.0\.0\.1|localhost|:\d{4,5}|emulator|qemu|PHEMU|PCEMU/i.test(text); - }); -} - -function readTail(filePath, maxBytes = 192 * 1024) { - try { - const stat = fs.statSync(filePath); - const size = Math.min(stat.size, maxBytes); - const fd = fs.openSync(filePath, "r"); - const buffer = Buffer.alloc(size); - fs.readSync(fd, buffer, 0, size, Math.max(0, stat.size - size)); - fs.closeSync(fd); - return buffer.toString("utf8"); - } catch { - return ""; - } -} - -function emulatorLogDiagnostics(emulator) { - if (!emulator?.instancePath) return []; - const logDir = path.join(emulator.instancePath, "Log"); - const files = [ - ["Emulator.log", /(critical|error|warning|fail|fatal|crash|exit|heartbeat|uuid|sn|cannot|can not|permission denied|OpenProcess)/i], - ["qemu.log", /(critical|error|fail|fatal|crash|exit|heartbeat|permission denied|express_gpu|egl|opengl|watchdog)/i], - ["crash_server.log", /(critical|error|warning|exit|heartbeat|crash|OpenProcess|failed)/i], - ["kernel.log", /(panic|fatal|crash|error|failed|watchdog|express_gpu|shutdown|reboot|kill)/i], - ]; - const diagnostics = []; - for (const [fileName, pattern] of files) { - const filePath = path.join(logDir, fileName); - const tail = readTail(filePath); - if (!tail) continue; - const matches = tail - .split(/\r?\n/) - .filter((line) => pattern.test(line)) - .slice(-8); - if (!matches.length) continue; - diagnostics.push(diagnostic("warning", `Recent ${emulator.name} ${fileName}: ${matches.join(" | ")}`, { - stage: "startEmulator", - source: "emulatorLog", - emulatorName: emulator.name, - logFile: fileName, - })); - } - return diagnostics; -} - -function launchEmulator(emulatorPath, name, input, env) { - const child = spawn(emulatorPath, ["-start", name], { - cwd: input.workspacePath, - env, - detached: true, - stdio: "ignore", - windowsHide: false, - }); - child.on("error", () => {}); - child.unref(); - return { - command: emulatorPath, - args: ["-start", name], - commandLine: `${emulatorPath} -start ${name}`, - cwd: input.workspacePath || process.cwd(), - pid: child.pid || null, - detached: true, - }; -} - -function listEmulators(input = {}) { - const toolchainResult = detectToolchain(input); - const emulatorPath = toolchainResult.toolchain.emulator.path; - if (!emulatorPath) { - return { ok: false, emulators: [], diagnostics: [diagnostic("warning", "Emulator.exe was not found", { stage: "listEmulators" })] }; - } - const result = runCommand(emulatorPath, ["-list", "-details"], { - cwd: input.workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: input.timeoutMs || 20000, - }); - let raw = []; - try { - const start = result.stdout.indexOf("["); - const jsonText = start >= 0 ? result.stdout.slice(start) : result.stdout; - raw = JSON.parse(jsonText || "[]"); - } catch { - raw = []; - } - const emulators = raw.map(normalizeEmulator).sort((left, right) => right.rank - left.rank); - const recommendedEmulator = emulators[0] || null; - const diagnostics = []; - if (!emulators.length) diagnostics.push(diagnostic("info", "No DevEco emulator instances were listed", { stage: "listEmulators" })); - if (result.stderr) diagnostics.push(diagnostic("info", result.stderr, { stage: "listEmulators" })); - if (input.workspacePath) writeRuntimeState(input.workspacePath, { emulators, recommendedEmulator, diagnostics }); - return redactValue({ ok: emulators.length > 0, emulators, recommendedEmulator, diagnostics }); -} - -async function startEmulator(input = {}) { - const toolchainResult = detectToolchain(input); - const emulatorPath = toolchainResult.toolchain.emulator.path; - if (!emulatorPath) { - return { ok: false, diagnostics: [diagnostic("warning", "Emulator.exe was not found", { stage: "startEmulator" })] }; - } - const listed = listEmulators(input); - const emulator = selectedEmulator(listed.emulators, input.name || listed.recommendedEmulator?.name); - if (!emulator?.name) { - return { ok: false, diagnostics: [diagnostic("warning", "No emulator name was supplied", { stage: "startEmulator" })] }; - } - const diagnostics = [...(listed.diagnostics || [])]; - if (!emulator.isPublic || /beta/i.test(String(emulator.osVersion || ""))) { - diagnostics.push(diagnostic("warning", `Selected emulator "${emulator.name}" is beta or non-public; a public HarmonyOS 6.1.1(24) image is usually safer for install/start validation.`, { - stage: "startEmulator", - emulatorName: emulator.name, - })); - } - let started; - try { - started = launchEmulator(emulatorPath, emulator.name, input, toolchainResult.toolchain.env); - } catch (error) { - diagnostics.push(diagnostic("warning", error instanceof Error ? error.message : String(error), { - stage: "startEmulator", - emulatorName: emulator.name, - })); - } - const waitMs = Number(input.waitMs || 60000); - const deadline = Date.now() + waitMs; - let targets = []; - let observedTargets = []; - while (started && Date.now() < deadline) { - const targetResult = listTargets(input); - observedTargets = targetResult.observedTargets || targetResult.targets || []; - targets = emulatorTargets(targetResult.targets || []); - if (targets.length) break; - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - if (!targets.length) { - diagnostics.push(diagnostic("warning", "No emulator HDC target appeared before the wait timeout", { - stage: "startEmulator", - emulatorName: emulator.name, - })); - diagnostics.push(...emulatorLogDiagnostics(emulator)); - } - const runtimeState = input.workspacePath ? writeRuntimeState(input.workspacePath, { - selectedEmulator: emulator, - targets, - observedTargets, - diagnostics, - }) : null; - return redactValue({ - ok: targets.length > 0, - emulator, - start: started || null, - targets, - observedTargets, - diagnostics, - runtimeState, - }); -} - -async function stopEmulator(input = {}) { - const toolchainResult = detectToolchain(input); - const emulatorPath = toolchainResult.toolchain.emulator.path; - if (!emulatorPath) { - return { ok: false, diagnostics: [diagnostic("warning", "Emulator.exe was not found", { stage: "stopEmulator" })] }; - } - const listed = listEmulators(input); - const emulator = emulatorToStop(listed.emulators, input.name, listed.recommendedEmulator); - if (!emulator?.name) { - return { ok: false, diagnostics: [diagnostic("warning", "No emulator name was supplied", { stage: "stopEmulator" })] }; - } - - const diagnostics = [...(listed.diagnostics || [])]; - let stop = null; - let refreshed = listed; - let refreshedEmulator = (listed.emulators || []).find((item) => item.name === emulator.name) || emulator; - - if (!refreshedEmulator.isRunning) { - diagnostics.push(diagnostic("info", `Selected emulator "${emulator.name}" is already stopped`, { - stage: "stopEmulator", - emulatorName: emulator.name, - })); - } else { - stop = runCommand(emulatorPath, ["-stop", emulator.name], { - cwd: input.workspacePath, - env: toolchainResult.toolchain.env, - timeoutMs: input.timeoutMs || 30000, - }); - if (stop.exitCode !== 0) { - diagnostics.push(diagnostic("warning", stop.stderr || stop.stdout || "Emulator stop command failed", { - stage: "stopEmulator", - emulatorName: emulator.name, - })); - } - - const waitMs = Number(input.waitMs || 30000); - const deadline = Date.now() + waitMs; - while (Date.now() < deadline) { - refreshed = listEmulators(input); - refreshedEmulator = (refreshed.emulators || []).find((item) => item.name === emulator.name) || refreshedEmulator; - if (!refreshedEmulator?.isRunning) break; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - if (refreshedEmulator?.isRunning) { - diagnostics.push(diagnostic("warning", `Stop command completed, but emulator "${emulator.name}" is still reported as running`, { - stage: "stopEmulator", - emulatorName: emulator.name, - })); - } - } - - const targetResult = listTargets(input); - const targets = emulatorTargets(targetResult.targets || []); - const observedTargets = targetResult.observedTargets || targetResult.targets || []; - diagnostics.push(...(refreshed.diagnostics || []), ...(targetResult.diagnostics || [])); - const runtimeState = input.workspacePath ? writeRuntimeState(input.workspacePath, { - selectedEmulator: refreshedEmulator, - emulators: refreshed.emulators || [], - recommendedEmulator: refreshed.recommendedEmulator || null, - targets, - observedTargets, - diagnostics, - }) : null; - return redactValue({ - ok: Boolean(refreshedEmulator && !refreshedEmulator.isRunning), - emulator: refreshedEmulator, - stop, - emulators: refreshed.emulators || [], - recommendedEmulator: refreshed.recommendedEmulator || null, - targets, - observedTargets, - diagnostics, - runtimeState, - }); -} - -module.exports = { - listEmulators, - startEmulator, - stopEmulator, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/harmony-env.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/harmony-env.js deleted file mode 100644 index 800d752a..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/harmony-env.js +++ /dev/null @@ -1,115 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { runCommand, fileExists } = require("./command"); - -const DEFAULT_DEVECO_ROOTS = [ - "D:\\software\\DevEco Studio", - "C:\\Program Files\\Huawei\\DevEco Studio", - "C:\\Program Files\\DevEco Studio", -]; - -function firstExisting(candidates) { - return candidates.find(fileExists) || null; -} - -function pathExists(candidate) { - try { - return Boolean(candidate && fs.existsSync(candidate)); - } catch { - return false; - } -} - -function firstExistingPath(candidates) { - return candidates.find(pathExists) || null; -} - -function resolveOnPath(name) { - const result = runCommand(process.platform === "win32" ? "where.exe" : "which", [name], { timeoutMs: 6000 }); - if (result.exitCode !== 0) return null; - return result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || null; -} - -function detectToolchain(input = {}) { - const workspacePath = input.workspacePath; - const roots = [ - input.devEcoRoot, - process.env.DEVECO_STUDIO_HOME, - ...DEFAULT_DEVECO_ROOTS, - ].filter(Boolean); - const sdkRoot = input.devEcoSdkHome || process.env.DEVECO_SDK_HOME || firstExistingPath(roots.map((root) => path.join(root, "sdk"))); - const devEcoRoot = roots.find((root) => fs.existsSync(root)) || (sdkRoot ? path.dirname(sdkRoot) : null); - const hvigorw = firstExisting([ - input.hvigorwPath, - process.env.HVIGORW, - ...roots.map((root) => path.join(root, "tools", "hvigor", "bin", process.platform === "win32" ? "hvigorw.bat" : "hvigorw")), - workspacePath ? path.join(workspacePath, "hvigorw.bat") : null, - workspacePath ? path.join(workspacePath, "hvigorw") : null, - resolveOnPath("hvigorw"), - ].filter(Boolean)); - const hdc = firstExisting([ - input.hdcPath, - process.env.HDC_PATH, - sdkRoot ? path.join(sdkRoot, "default", "openharmony", "toolchains", process.platform === "win32" ? "hdc.exe" : "hdc") : null, - sdkRoot ? path.join(sdkRoot, "openharmony", "toolchains", process.platform === "win32" ? "hdc.exe" : "hdc") : null, - resolveOnPath("hdc"), - ].filter(Boolean)); - const emulator = firstExisting([ - input.emulatorPath, - ...roots.map((root) => path.join(root, "tools", "emulator", process.platform === "win32" ? "Emulator.exe" : "Emulator")), - resolveOnPath(process.platform === "win32" ? "Emulator.exe" : "Emulator"), - ].filter(Boolean)); - const ohpm = firstExisting([ - input.ohpmPath, - ...roots.map((root) => path.join(root, "tools", "ohpm", "bin", process.platform === "win32" ? "ohpm.bat" : "ohpm")), - resolveOnPath("ohpm"), - ].filter(Boolean)); - const java = firstExisting([ - input.javaPath, - process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, "bin", process.platform === "win32" ? "java.exe" : "java") : null, - ...roots.map((root) => path.join(root, "jbr", "bin", process.platform === "win32" ? "java.exe" : "java")), - resolveOnPath("java"), - ].filter(Boolean)); - - const env = { - ...process.env, - ...(sdkRoot ? { DEVECO_SDK_HOME: sdkRoot } : {}), - ...(java ? { JAVA_HOME: path.dirname(path.dirname(java)) } : {}), - }; - const toolchain = { - devEcoRoot, - sdkRoot, - hvigorw: { path: hvigorw, available: Boolean(hvigorw) }, - hdc: { path: hdc, available: Boolean(hdc) }, - emulator: { path: emulator, available: Boolean(emulator) }, - ohpm: { path: ohpm, available: Boolean(ohpm) }, - java: { path: java, available: Boolean(java) }, - }; - Object.defineProperty(toolchain, "env", { - value: env, - enumerable: false, - }); - const diagnostics = []; - for (const [name, item] of Object.entries(toolchain)) { - if (item && typeof item === "object" && "available" in item && !item.available) { - diagnostics.push({ severity: "warning", message: `${name} was not found`, stage: "toolchain" }); - } - } - if (hvigorw) { - const version = runCommand(hvigorw, ["--version"], { cwd: workspacePath, env, timeoutMs: 15000 }); - toolchain.hvigorw.version = (version.stdout || version.stderr).trim().split(/\r?\n/)[0] || null; - } - if (ohpm) { - const version = runCommand(ohpm, ["--version"], { cwd: workspacePath, env, timeoutMs: 15000 }); - toolchain.ohpm.version = (version.stdout || version.stderr).trim().split(/\r?\n/)[0] || null; - } - return { - ok: diagnostics.length === 0, - toolchain, - diagnostics, - }; -} - -module.exports = { - detectToolchain, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/json5.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/json5.js deleted file mode 100644 index 9f1411c2..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/json5.js +++ /dev/null @@ -1,57 +0,0 @@ -const fs = require("node:fs"); - -function stripJson5(text) { - let out = ""; - let inString = false; - let quote = ""; - let escaped = false; - for (let i = 0; i < text.length; i += 1) { - const ch = text[i]; - const next = text[i + 1]; - if (inString) { - out += ch; - if (escaped) { - escaped = false; - } else if (ch === "\\") { - escaped = true; - } else if (ch === quote) { - inString = false; - quote = ""; - } - continue; - } - if (ch === '"' || ch === "'") { - inString = true; - quote = ch; - out += ch; - continue; - } - if (ch === "/" && next === "/") { - while (i < text.length && text[i] !== "\n") i += 1; - out += "\n"; - continue; - } - if (ch === "/" && next === "*") { - i += 2; - while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1; - i += 1; - continue; - } - out += ch; - } - return out.replace(/,\s*([}\]])/g, "$1"); -} - -function readJson5(filePath, fallback = null) { - try { - const content = fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""); - return JSON.parse(stripJson5(content)); - } catch { - return fallback; - } -} - -module.exports = { - readJson5, - stripJson5, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/paths.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/paths.js deleted file mode 100644 index 18e1bafc..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/paths.js +++ /dev/null @@ -1,69 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); - -function workspacePathOf(requestOrInput) { - const input = requestOrInput?.input || requestOrInput || {}; - return String( - input.workspacePath || - input.workspace_path || - requestOrInput?.workspacePath || - requestOrInput?.workspace_path || - "" - ).trim(); -} - -function normalizeWorkspace(workspacePath) { - if (!workspacePath) throw new Error("workspacePath is required."); - const resolved = path.resolve(workspacePath); - if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { - throw new Error(`Workspace does not exist or is not a directory: ${resolved}`); - } - return resolved; -} - -function ensureRuntimeDir(workspacePath, child = "") { - const dir = path.join(workspacePath, ".sparo_os", "harmony-dev", child); - fs.mkdirSync(dir, { recursive: true }); - return dir; -} - -function runtimeStatePath(workspacePath) { - return path.join(ensureRuntimeDir(workspacePath), "runtime-state.json"); -} - -function runDir(workspacePath, runId) { - return ensureRuntimeDir(workspacePath, path.join("runs", runId)); -} - -function artifactsDir(workspacePath) { - return ensureRuntimeDir(workspacePath, "artifacts"); -} - -function screenshotsDir(workspacePath) { - return ensureRuntimeDir(workspacePath, "screenshots"); -} - -function hierarchyDir(workspacePath) { - return ensureRuntimeDir(workspacePath, "hierarchy"); -} - -function relativeToWorkspace(workspacePath, filePath) { - return path.relative(workspacePath, filePath).replace(/\\/g, "/"); -} - -function safeFilePart(value) { - return String(value || "run").replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "") || "run"; -} - -module.exports = { - artifactsDir, - ensureRuntimeDir, - hierarchyDir, - normalizeWorkspace, - relativeToWorkspace, - runDir, - runtimeStatePath, - safeFilePart, - screenshotsDir, - workspacePathOf, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/project.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/project.js deleted file mode 100644 index 258da75a..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/project.js +++ /dev/null @@ -1,159 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { readJson5 } = require("./json5"); -const { normalizeWorkspace, relativeToWorkspace } = require("./paths"); -const { diagnostic, redactValue } = require("./redact"); -const { writeRuntimeState, readRuntimeState } = require("./runtime-state"); - -function walkFiles(root, predicate, maxFiles = 4000) { - const files = []; - const stack = [root]; - const ignored = new Set([".git", ".hvigor", "oh_modules", "node_modules", "build", ".sparo_os"]); - while (stack.length && files.length < maxFiles) { - const current = stack.pop(); - let entries = []; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - if (!ignored.has(entry.name)) stack.push(absolute); - } else if (entry.isFile() && predicate(absolute)) { - files.push(absolute); - } - } - } - return files; -} - -function detectSourceHints(workspacePath) { - const etsRoot = path.join(workspacePath, "entry", "src", "main", "ets"); - const etsFiles = fs.existsSync(etsRoot) - ? walkFiles(etsRoot, (file) => /\.(ets|ts)$/i.test(file), 1200) - : []; - const componentNames = new Set(); - for (const file of etsFiles.slice(0, 100)) { - let text = ""; - try { - text = fs.readFileSync(file, "utf8"); - } catch { - continue; - } - for (const match of text.matchAll(/(?:struct|class)\s+([A-Za-z0-9_]+)/g)) { - componentNames.add(match[1]); - } - } - return { - etsFiles: etsFiles.slice(0, 80).map((file) => relativeToWorkspace(workspacePath, file)), - componentNames: Array.from(componentNames).slice(0, 80), - }; -} - -function materialPathsPresent(material = {}) { - const paths = [material.certpath, material.profile, material.storeFile].filter(Boolean); - return paths.length > 0 && paths.every((filePath) => { - try { - return fs.existsSync(filePath); - } catch { - return false; - } - }); -} - -function signingSummary(buildProfile = {}) { - const products = buildProfile?.app?.products || []; - const signingConfigs = buildProfile?.app?.signingConfigs || []; - const selected = products.find((product) => product.signingConfig)?.signingConfig; - const config = signingConfigs.find((item) => item.name === selected) || signingConfigs[0]; - return { - configured: Boolean(selected || config), - materialPathsPresent: materialPathsPresent(config?.material), - redacted: true, - }; -} - -function detectProject(input = {}) { - const workspacePath = normalizeWorkspace(input.workspacePath); - const buildProfilePath = path.join(workspacePath, "build-profile.json5"); - const appJsonPath = path.join(workspacePath, "AppScope", "app.json5"); - const moduleJsonPath = path.join(workspacePath, "entry", "src", "main", "module.json5"); - const diagnostics = []; - if (!fs.existsSync(buildProfilePath)) { - const project = { - kind: "unknown", - status: "notFound", - workspacePath, - }; - return { ok: false, status: "notFound", project, diagnostics: [diagnostic("info", "build-profile.json5 was not found", { stage: "detectProject" })] }; - } - const buildProfile = readJson5(buildProfilePath, {}); - const appJson = readJson5(appJsonPath, {}); - const moduleJson = readJson5(moduleJsonPath, {}); - const product = (buildProfile?.app?.products || [])[0] || {}; - const modules = (buildProfile?.modules || []).map((moduleRef) => { - const moduleRoot = path.resolve(workspacePath, moduleRef.srcPath || moduleRef.name || ""); - const moduleConfigPath = moduleRef.name === "entry" ? moduleJsonPath : path.join(moduleRoot, "src", "main", "module.json5"); - const config = moduleRef.name === "entry" ? moduleJson : readJson5(moduleConfigPath, {}); - const module = config.module || {}; - return { - name: module.name || moduleRef.name, - srcPath: moduleRef.srcPath || "", - type: module.type || "unknown", - mainElement: module.mainElement || null, - deviceTypes: module.deviceTypes || [], - abilities: (module.abilities || []).map((ability) => ({ - name: ability.name, - srcEntry: ability.srcEntry, - exported: Boolean(ability.exported), - label: ability.label, - })), - pages: module.pages || null, - }; - }); - const isHarmony = product.runtimeOS === "HarmonyOS"; - if (!isHarmony) diagnostics.push(diagnostic("warning", "build-profile.json5 exists but runtimeOS is not HarmonyOS", { stage: "detectProject" })); - const sourceHints = detectSourceHints(workspacePath); - const project = redactValue({ - kind: isHarmony ? "harmonyos" : "unknown", - status: isHarmony ? "matched" : "notFound", - workspacePath, - productName: appJson?.app?.label || appJson?.app?.bundleName || path.basename(workspacePath), - runtimeOS: product.runtimeOS || null, - targetSdkVersion: product.targetSdkVersion || null, - compatibleSdkVersion: product.compatibleSdkVersion || null, - bundleName: appJson?.app?.bundleName || null, - app: { - bundleName: appJson?.app?.bundleName || null, - versionName: appJson?.app?.versionName || null, - versionCode: appJson?.app?.versionCode || null, - label: appJson?.app?.label || null, - }, - modules, - signing: signingSummary(buildProfile), - sourceHints, - files: { - buildProfile: "build-profile.json5", - appJson: fs.existsSync(appJsonPath) ? "AppScope/app.json5" : null, - moduleJson: fs.existsSync(moduleJsonPath) ? "entry/src/main/module.json5" : null, - }, - }); - const runtimeState = writeRuntimeState(workspacePath, { - project, - diagnostics, - }); - return { ok: isHarmony, status: project.status, project, diagnostics, runtimeState }; -} - -function projectIdentity(workspacePath) { - const state = readRuntimeState(workspacePath); - if (state.project?.kind === "harmonyos") return state.project; - return detectProject({ workspacePath }).project; -} - -module.exports = { - detectProject, - projectIdentity, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/protocol.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/protocol.js deleted file mode 100644 index 976f9bc3..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/protocol.js +++ /dev/null @@ -1,38 +0,0 @@ -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-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/redact.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/redact.js deleted file mode 100644 index bfd22e39..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/redact.js +++ /dev/null @@ -1,44 +0,0 @@ -const SECRET_KEY_RE = /(password|passwd|token|secret|apikey|api_key|private|storePassword|keyPassword)/i; -const SECRET_VALUE_RE = /(keyPassword|storePassword|password|token|secret)\s*[:=]\s*["']?[^"',\s}]+/gi; - -function redactText(value) { - return String(value || "") - .replace(SECRET_VALUE_RE, (match) => { - const key = match.split(/[:=]/)[0] || "secret"; - return `${key}: [redacted]`; - }); -} - -function redactValue(value) { - if (Array.isArray(value)) return value.map(redactValue); - if (!value || typeof value !== "object") { - return typeof value === "string" ? redactText(value) : value; - } - const out = {}; - for (const [key, item] of Object.entries(value)) { - if (SECRET_KEY_RE.test(key)) { - out[key] = "[redacted]"; - } else if (/^(certpath|profile|storeFile)$/i.test(key)) { - out[key] = item ? "[path redacted]" : item; - } else if (typeof item === "string") { - out[key] = redactText(item); - } else { - out[key] = redactValue(item); - } - } - return out; -} - -function diagnostic(severity, message, extra = {}) { - return redactValue({ - severity, - message, - ...extra, - }); -} - -module.exports = { - diagnostic, - redactText, - redactValue, -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/runtime-state.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/runtime-state.js deleted file mode 100644 index 3c28a830..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/src/runtime-state.js +++ /dev/null @@ -1,39 +0,0 @@ -const fs = require("node:fs"); -const { redactValue } = require("./redact"); -const { runtimeStatePath } = require("./paths"); - -function readRuntimeState(workspacePath) { - try { - return JSON.parse(fs.readFileSync(runtimeStatePath(workspacePath), "utf8")); - } catch { - return { - schemaVersion: 1, - updatedAt: Date.now(), - capabilities: { - hdcTarget: false, - bundleDump: "unknown", - abilityLaunch: "unknown", - screenshot: "unknown", - hierarchyDump: "unknown", - hilog: "unknown", - hotReload: "unknown", - }, - diagnostics: [], - }; - } -} - -function writeRuntimeState(workspacePath, patch) { - const next = redactValue({ - ...readRuntimeState(workspacePath), - ...patch, - updatedAt: Date.now(), - }); - fs.writeFileSync(runtimeStatePath(workspacePath), `${JSON.stringify(next, null, 2)}\n`, "utf8"); - return next; -} - -module.exports = { - readRuntimeState, - writeRuntimeState, -}; 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 deleted file mode 100644 index 6b41fd5f..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/bridges/builtin-harmony-runtime/source/worker.js +++ /dev/null @@ -1,84 +0,0 @@ -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"); -const { assembleApp, buildProject, hotReload, runUnitTests } = require("./src/build"); -const { captureScreen, dumpHierarchy, installApp, launchAbility, listTargets, readLogs, readScreenshot } = require("./src/device"); -const { readDiagnostics } = require("./src/diagnostics"); -const { readRuntimeState } = require("./src/runtime-state"); -const { redactValue } = require("./src/redact"); - -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 && typeof request.input === "object" ? request.input : {}), - workspacePath: workspacePath ? normalizeWorkspace(workspacePath) : null, - }; - 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) }, - }); - } -} - -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) => { - 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/components/surfaces/builtin-harmony-dev-surface/component.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/component.json deleted file mode 100644 index ba570e69..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/component.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "id": "builtin-harmony-dev-surface", - "kind": "surface", - "name": "HarmonyOS Device Preview Workbench", - "description": "The app-owned HarmonyOS preview, build, device, diagnostics, and AI collaboration surface.", - "packageSource": "appPrivate", - "ownerApp": { - "appId": "builtin-harmony-dev", - "appVersion": "13.0.0" - }, - "capabilities": [ - { - "id": "interactiveSurface", - "title": "Interactive surface", - "description": "Hosts the HarmonyOS Dev primary workspace.", - "actions": [ - "open", - "detectProject", - "refreshRuntimeState", - "selectTarget", - "startBuild", - "installAndLaunch", - "captureScreen" - ] - } - ], - "permissions": [ - { - "kind": "fs", - "summary": "Reads and writes app-owned HarmonyOS Dev surface state.", - "scopes": [ - "{appdata}" - ] - } - ], - "usedByApps": [ - "builtin-harmony-dev" - ], - "visibility": "appDependency", - "dependencies": [ - { - "componentId": "harmonyos-dev-agent", - "kind": "agent", - "source": "private", - "role": "harmonyAgent", - "capabilities": [ - "understandHarmonyProject", - "reviseHarmonyUi", - "buildInstallVerify", - "diagnoseHarmonyFailure" - ] - }, - { - "componentId": "builtin-harmony-runtime", - "kind": "bridge", - "source": "private", - "role": "harmonyRuntime", - "capabilities": [ - "sparo.harmonyDev" - ] - }, - { - "componentId": "product-app-runtime-host", - "kind": "runtime", - "source": "shared", - "role": "runtimeHost", - "version": "^1.0.0", - "capabilities": [ - "uiWorker" - ] - } - ], - "implementationRef": "app://builtin-harmony-dev@13.0.0/surfaces/builtin-harmony-dev-surface", - "usesCapabilities": [ - "session.read", - "component.run" - ] -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/index.html b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/index.html deleted file mode 100644 index 1b74b608..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/index.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
-
-

Loading HarmonyOS Dev...

-
-
diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/source_manifest.json b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/source_manifest.json deleted file mode 100644 index 067c84e8..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/source_manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "uiEntry": "ui.js", - "workerEntry": "worker.js", - "styleEntries": [ - "style.css" - ], - "buildMode": "nativeEsm" -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/actions.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/actions.js deleted file mode 100644 index 69643407..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/actions.js +++ /dev/null @@ -1,314 +0,0 @@ -import { callBackend } from "./backend.js"; -import { projectSummary, recommendedEmulator, selectedEmulator, targetState } from "./model.js"; -import { buildSelection, selectionSummary } from "./preview/selection-overlay.js"; -import { render } from "./views.js"; -import { state } from "./state.js"; -import { asArray, normalizeBox, runtime, stagePointFromEvent, t } from "./util.js"; - -function setBusy(label) { - state.loading = Boolean(label); - state.action = label || null; - if (label) state.status = label; - render(); -} - -function syncSelectedEmulator() { - const selected = state.selectedEmulatorName - ? selectedEmulator(state.emulators, state.selectedEmulatorName) - : null; - if (selected?.name) { - state.selectedEmulatorName = selected.name; - return selected; - } - const running = asArray(state.emulators).find((emulator) => emulator?.isRunning); - if (running?.name) { - state.selectedEmulatorName = running.name; - return running; - } - const recommended = state.recommendedEmulator || recommendedEmulator(state.emulators); - state.selectedEmulatorName = recommended?.name || null; - return recommended; -} - -function applyOutput(output = {}) { - if (output.project) state.project = output.project; - if (output.toolchain) state.toolchain = output.toolchain; - if (Array.isArray(output.emulators)) state.emulators = output.emulators; - if (output.recommendedEmulator) state.recommendedEmulator = output.recommendedEmulator; - if (output.emulator?.name) state.selectedEmulatorName = output.emulator.name; - if (Array.isArray(output.targets)) state.targets = output.targets; - if (output.runtimeState) state.runtimeState = output.runtimeState; - if (output.runtimeState?.selectedEmulator?.name) state.selectedEmulatorName = output.runtimeState.selectedEmulator.name; - if (output.build) state.build = output.build; - if (output.screen) state.screen = output.screen; - if (output.hierarchy) state.hierarchy = output.hierarchy; - if (Array.isArray(output.diagnostics)) state.diagnostics = output.diagnostics; - syncSelectedEmulator(); - state.error = null; -} - -function activeScreen() { - return state.screen || state.runtimeState?.screen || null; -} - -function hasUsableScreenData(screen) { - const dataUrl = String(screen?.dataUrl || ""); - const comma = dataUrl.indexOf(","); - return dataUrl.startsWith("data:image/") - && comma > 0 - && dataUrl.length > comma + 256 - && Number(screen?.width) > 0 - && Number(screen?.height) > 0 - && Number(screen?.size) > 0; -} - -async function hydrateScreenImage() { - const screen = activeScreen(); - if (!screen?.path || hasUsableScreenData(screen)) return; - if (state.hydratingScreenPath === screen.path) return; - state.hydratingScreenPath = screen.path; - try { - const output = await callBackend("readScreenshot", { - path: screen.path, - screen - }, { timeoutMs: 30000 }); - if (output?.screen) { - state.screen = { ...screen, ...output.screen }; - if (state.runtimeState?.screen?.path === screen.path) { - state.runtimeState = { - ...state.runtimeState, - screen: { ...state.runtimeState.screen, ...output.screen } - }; - } - render(); - } - } catch { - // Keep the stale screen metadata visible; the next capture can replace it. - } finally { - state.hydratingScreenPath = null; - } -} - -async function guarded(label, fn) { - setBusy(label); - try { - const output = await fn(); - applyOutput(output || {}); - void hydrateScreenImage(); - return output; - } catch (error) { - state.error = error instanceof Error ? error.message : String(error); - return null; - } finally { - setBusy(null); - render(); - } -} - -async function refreshFacts() { - if (!state.workspacePath) { - state.project = null; - state.toolchain = null; - state.targets = []; - render(); - return; - } - await guarded(t("detecting"), async () => { - const [project, toolchain, targets, emulators, runtimeState] = await Promise.all([ - callBackend("detectProject"), - callBackend("detectToolchain"), - callBackend("listTargets"), - callBackend("listEmulators"), - callBackend("getRuntimeState") - ]); - return { - project: project?.project || project, - toolchain: toolchain?.toolchain || toolchain, - targets: asArray(targets?.targets), - emulators: asArray(emulators?.emulators), - recommendedEmulator: emulators?.recommendedEmulator || recommendedEmulator(emulators?.emulators), - runtimeState: targets?.runtimeState || project?.runtimeState || emulators?.runtimeState || runtimeState?.runtimeState || runtimeState, - diagnostics: [ - ...asArray(project?.diagnostics), - ...asArray(toolchain?.diagnostics), - ...asArray(targets?.diagnostics), - ...asArray(emulators?.diagnostics) - ] - }; - }); -} - -async function startEmulator() { - const emulator = syncSelectedEmulator(); - if (!emulator?.name) return; - await guarded(t("waitingTarget"), () => callBackend("startEmulator", { name: emulator.name }, { timeoutMs: 180000 })); - await refreshFacts(); -} - -async function stopEmulator() { - const emulator = syncSelectedEmulator(); - if (!emulator?.name) return; - await guarded(t("stopEmulator"), () => callBackend("stopEmulator", { name: emulator.name }, { timeoutMs: 60000 })); - await refreshFacts(); -} - -async function runUnitTests() { - await guarded(t("test"), () => callBackend("runUnitTests", {}, { timeoutMs: 180000 })); -} - -async function buildProject() { - await guarded(t("build"), () => callBackend("buildProject", { includeTests: false }, { timeoutMs: 240000 })); -} - -async function hotReload() { - await guarded(t("hotReload"), () => callBackend("hotReload", {}, { timeoutMs: 180000 })); -} - -async function installApp() { - if (!targetState(state.targets).target) { - state.error = t("installNeedsTarget"); - render(); - return null; - } - return guarded(t("install"), () => callBackend("installApp", {}, { timeoutMs: 120000 })); -} - -async function launchAbility() { - if (!targetState(state.targets).target) { - state.error = t("launchNeedsTarget"); - render(); - return null; - } - return guarded(t("launch"), () => callBackend("launchAbility", {}, { timeoutMs: 90000 })); -} - -async function buildAndRun() { - await guarded(t("buildRun"), () => callBackend("buildProject", { includeTests: false }, { timeoutMs: 240000 })); - if (!state.error) await installApp(); - if (!state.error) await launchAbility(); - if (!state.error) await captureScreen(); - if (!state.error) await dumpHierarchy(); -} - -async function captureScreen() { - const target = targetState(state.targets).target; - const emulator = selectedEmulator(state.emulators, state.selectedEmulatorName) || state.recommendedEmulator || recommendedEmulator(state.emulators); - await guarded(t("capture"), () => callBackend("captureScreen", { - targetId: target?.id, - emulatorName: emulator?.isRunning ? emulator.name : undefined - }, { timeoutMs: 90000 })); -} - -async function dumpHierarchy() { - const target = targetState(state.targets).target; - await guarded(t("inspect"), () => callBackend("dumpHierarchy", { - targetId: target?.id - }, { timeoutMs: 90000 })); -} - -async function readDiagnostics() { - await guarded(t("diagnostics"), () => callBackend("readDiagnostics")); -} - -async function sendContext() { - const host = runtime(); - const project = projectSummary(state.project); - const context = { - workspacePath: state.workspacePath, - route: state.route, - project: state.project, - target: targetState(state.targets).target, - emulator: selectedEmulator(state.emulators, state.selectedEmulatorName), - app: { - bundleName: project.bundleName, - moduleName: project.moduleName, - abilityName: project.abilityName - }, - build: state.build || state.runtimeState?.build || null, - runtime: state.runtimeState, - screen: state.screen || state.runtimeState?.screen || null, - hierarchy: state.hierarchy || state.runtimeState?.hierarchy || null, - selection: state.selection, - diagnostics: state.diagnostics - }; - const prompt = `${t("askPrompt")}\n\nHarmonyOS context: ${selectionSummary(state.selection)}\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\``; - await host.host?.fillChatInput?.(prompt); -} - -function setSelectionFromBox(kind, box) { - state.selection = buildSelection(kind, box); - render(); -} - -function beginSelection(event) { - if (!event.target?.closest?.("[data-select-stage]")) return; - if (event.button !== 0) return; - const point = stagePointFromEvent(event); - if (!point) return; - state.selectionDragging = true; - state.selectionDraft = { startX: point.x, startY: point.y, x: point.x, y: point.y, width: 0, height: 0, moved: false }; - render(); -} - -function moveSelection(event) { - if (!state.selectionDragging || !state.selectionDraft) return; - const point = stagePointFromEvent(event); - if (!point) return; - const draft = state.selectionDraft; - draft.x = Math.min(draft.startX, point.x); - draft.y = Math.min(draft.startY, point.y); - draft.width = Math.abs(point.x - draft.startX); - draft.height = Math.abs(point.y - draft.startY); - draft.moved = draft.width > 1 || draft.height > 1; - render(); -} - -function endSelection() { - if (!state.selectionDragging) return; - const draft = state.selectionDraft; - state.selectionDragging = false; - state.selectionDraft = null; - if (!draft) { - render(); - return; - } - if (draft.moved) { - setSelectionFromBox("region", draft); - } else { - setSelectionFromBox("point", normalizeBox({ x: draft.startX - 1, y: draft.startY - 1, width: 2, height: 2 })); - } -} - -function clearSelection() { - state.selection = null; - state.selectionDraft = null; - state.selectionDragging = false; - render(); -} - -function setSelectedEmulator(name) { - state.selectedEmulatorName = name || null; - syncSelectedEmulator(); - render(); -} - -export { - beginSelection, - buildAndRun, - buildProject, - captureScreen, - clearSelection, - dumpHierarchy, - endSelection, - hotReload, - installApp, - launchAbility, - moveSelection, - readDiagnostics, - refreshFacts, - runUnitTests, - sendContext, - setSelectedEmulator, - startEmulator, - stopEmulator -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/backend.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/backend.js deleted file mode 100644 index 29d2a4a1..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/backend.js +++ /dev/null @@ -1,37 +0,0 @@ -import { state } from "./state.js"; -import { bridgeOutput, runtime, t } from "./util.js"; - -async function callBackend(action, input = {}, options = {}) { - const host = runtime(); - if (!host.backend?.call) throw new Error(t("backendMissing")); - const result = await host.backend.call( - `harmonyRuntime.${action}`, - { - workspacePath: state.workspacePath, - ...input - }, - { - entityId: state.workspacePath || "default", - idempotencyKey: `harmony-dev-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - } - ); - if (result?.bridgeResult) return bridgeOutput(result); - if (!result?.actionRunId || !host.backend?.status) return bridgeOutput(result); - - const timeoutMs = options.timeoutMs || 120000; - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - 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, 500)); - } - throw new Error(`Backend action timed out: ${action}`); -} - -export { callBackend }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/constants.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/constants.js deleted file mode 100644 index 93d55f0a..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/constants.js +++ /dev/null @@ -1,166 +0,0 @@ -const MESSAGES = { - "en-US": { - appTitle: "HarmonyOS Dev", - detect: "Detect", - startEmulator: "Start emulator", - stopEmulator: "Stop emulator", - emulator: "Emulator", - selectedEmulator: "Selected emulator", - running: "Running", - stopped: "Stopped", - online: "Online", - offline: "Offline", - more: "More", - overview: "Overview", - facts: "Overview", - screen: "Screen", - evidence: "Evidence", - environment: "Environment", - primaryAction: "Run", - quickActions: "Quick actions", - advanced: "Advanced", - manualActions: "Manual actions", - build: "Build", - test: "Test", - install: "Install", - launch: "Launch", - buildRun: "Build & Run", - capture: "Capture", - inspect: "Inspect", - hotReload: "Hot reload", - sendContext: "Send context", - clear: "Clear", - project: "Project", - device: "Device", - buildPanel: "Build", - runtime: "Runtime", - diagnostics: "Diagnostics", - context: "Context", - noWorkspace: "Open HarmonyOS Dev from a workspace to inspect the project and device facts.", - detecting: "Detecting project and runtime facts...", - ready: "Ready", - noProject: "This workspace does not look like a HarmonyOS project.", - noTarget: "No HDC target is online.", - targetReady: "Target online", - emulatorReady: "Emulator candidate ready", - toolchainMissing: "Toolchain needs attention", - projectReady: "HarmonyOS project detected", - buildReady: "Build ready", - buildFailed: "Build failed", - appRunning: "App launched", - previewUnavailable: "No screen evidence yet", - screenshotUnavailable: "Screenshot unavailable", - hierarchyUnavailable: "UI hierarchy unavailable", - selectHint: "Click or drag on the latest screenshot to create context.", - noSelection: "No UI selection", - selectedRegion: "Selected region", - selectedNode: "Selected node", - confidence: "confidence", - latestArtifact: "Latest artifact", - target: "Target", - updated: "Updated", - logs: "Logs", - redacted: "redacted", - publicSafe: "public default", - beta: "beta/non-public", - signed: "signing configured", - unsigned: "unsigned or not configured", - actionBlocked: "Action blocked", - installNeedsTarget: "Install needs an online HDC target.", - launchNeedsTarget: "Launch needs an online HDC target.", - captureNeedsTarget: "Capture needs an online target or emulator screenshot support.", - askPrompt: "Use this HarmonyOS runtime context as the current device fact source.", - backendMissing: "Harmony runtime backend is unavailable.", - waitingTarget: "Waiting for HDC target...", - started: "Started", - completed: "Completed", - failed: "Failed", - unknown: "Unknown" - }, - "zh-CN": { - appTitle: "HarmonyOS Dev", - detect: "检测", - startEmulator: "启动模拟器", - stopEmulator: "\u5173\u95ed\u6a21\u62df\u5668", - emulator: "\u6a21\u62df\u5668", - selectedEmulator: "已选模拟器", - running: "\u8fd0\u884c\u4e2d", - stopped: "\u672a\u8fd0\u884c", - online: "在线", - offline: "离线", - more: "更多", - overview: "概览", - facts: "概览", - screen: "屏幕", - evidence: "证据", - environment: "环境", - primaryAction: "运行", - quickActions: "快捷操作", - advanced: "高级", - manualActions: "手动操作", - build: "构建", - test: "测试", - install: "安装", - launch: "启动", - buildRun: "构建并运行", - capture: "截图", - inspect: "检查层级", - hotReload: "热重载", - sendContext: "发送上下文", - clear: "清除", - project: "项目", - device: "设备", - buildPanel: "构建", - runtime: "运行", - diagnostics: "诊断", - context: "上下文", - noWorkspace: "从工作区打开 HarmonyOS Dev 后可以检查项目与设备事实。", - detecting: "正在检测项目和运行时事实...", - ready: "就绪", - noProject: "当前工作区不像 HarmonyOS 项目。", - noTarget: "当前没有在线 HDC target。", - targetReady: "设备在线", - emulatorReady: "有可用模拟器候选", - toolchainMissing: "工具链需要处理", - projectReady: "已检测到 HarmonyOS 项目", - buildReady: "构建就绪", - buildFailed: "构建失败", - appRunning: "应用已启动", - previewUnavailable: "还没有屏幕证据", - screenshotUnavailable: "截图不可用", - hierarchyUnavailable: "UI 层级不可用", - selectHint: "在最新截图上点击或框选来生成上下文。", - noSelection: "尚未选择 UI", - selectedRegion: "已选择区域", - selectedNode: "已选择节点", - confidence: "置信度", - latestArtifact: "最近产物", - target: "目标", - updated: "更新", - logs: "日志", - redacted: "已脱敏", - publicSafe: "公开默认", - beta: "Beta/非公开", - signed: "已配置签名", - unsigned: "未签名或未配置", - actionBlocked: "动作被阻止", - installNeedsTarget: "安装需要在线 HDC target。", - launchNeedsTarget: "启动需要在线 HDC target。", - captureNeedsTarget: "截图需要在线 target 或模拟器截图能力。", - askPrompt: "请把以下 HarmonyOS 运行时上下文作为当前设备事实源。", - backendMissing: "Harmony runtime 后端不可用。", - waitingTarget: "正在等待 HDC target...", - started: "已启动", - completed: "已完成", - failed: "失败", - unknown: "未知" - } -}; - -function normalizeRoute(route) { - const value = String(route || "/preview").trim(); - if (!value || value === "/") return "/preview"; - return value.startsWith("/") ? value : `/${value}`; -} - -export { MESSAGES, normalizeRoute }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/model.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/model.js deleted file mode 100644 index 15af1cf2..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/model.js +++ /dev/null @@ -1,40 +0,0 @@ -import { asArray } from "./util.js"; - -function projectSummary(project) { - const modules = asArray(project?.modules); - const firstModule = modules[0] || {}; - const ability = asArray(firstModule.abilities)[0] || {}; - return { - productName: project?.productName || project?.app?.label || "HarmonyOS", - bundleName: project?.bundleName || project?.app?.bundleName || "-", - moduleName: firstModule.name || "-", - abilityName: firstModule.mainElement || ability.name || "-", - targetSdkVersion: project?.targetSdkVersion || "-", - signing: project?.signing || null - }; -} - -function targetState(targets) { - const list = asArray(targets); - if (!list.length) return { kind: "none", target: null }; - const online = list.find((target) => target.state === "online") || list[0]; - return { kind: "online", target: online }; -} - -function recommendedEmulator(emulators) { - return asArray(emulators) - .slice() - .sort((left, right) => (Number(right.rank || 0) - Number(left.rank || 0)) || String(left.name).localeCompare(String(right.name)))[0] || null; -} - -function selectedEmulator(emulators, selectedName) { - const list = asArray(emulators); - if (selectedName) return list.find((item) => item.name === selectedName) || null; - return recommendedEmulator(list); -} - -function runtimeCapabilities(runtimeState) { - return runtimeState?.capabilities || {}; -} - -export { projectSummary, recommendedEmulator, runtimeCapabilities, selectedEmulator, targetState }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/preview/selection-overlay.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/preview/selection-overlay.js deleted file mode 100644 index 873fac09..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/preview/selection-overlay.js +++ /dev/null @@ -1,111 +0,0 @@ -import { state } from "../state.js"; -import { asArray, normalizeBox, round2 } from "../util.js"; - -function parseNodeBounds(node) { - if (node?.boundsPercent) return normalizeBox(node.boundsPercent); - const bounds = node?.bounds || node?.rect; - if (!bounds || !state.screen?.width || !state.screen?.height) return null; - const text = String(bounds); - const match = text.match(/(-?\d+)[,\s]+(-?\d+)[,\s]+(-?\d+)[,\s]+(-?\d+)/); - if (!match) return null; - const left = Number(match[1]); - const top = Number(match[2]); - const right = Number(match[3]); - const bottom = Number(match[4]); - if (![left, top, right, bottom].every(Number.isFinite)) return null; - return normalizeBox({ - x: (left / state.screen.width) * 100, - y: (top / state.screen.height) * 100, - width: ((right - left) / state.screen.width) * 100, - height: ((bottom - top) / state.screen.height) * 100 - }); -} - -function boxCenter(box) { - return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; -} - -function distance(a, b) { - return Math.hypot(a.x - b.x, a.y - b.y); -} - -function overlapScore(a, b) { - const x1 = Math.max(a.x, b.x); - const y1 = Math.max(a.y, b.y); - const x2 = Math.min(a.x + a.width, b.x + b.width); - const y2 = Math.min(a.y + a.height, b.y + b.height); - const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1); - const area = Math.max(1, a.width * a.height); - return intersection / area; -} - -function flattenHierarchyNodes(tree) { - const nodes = []; - const stack = asArray(tree?.nodes || tree?.children || tree?.root ? [tree.root || tree] : tree); - while (stack.length) { - const node = stack.pop(); - if (!node || typeof node !== "object") continue; - nodes.push(node); - asArray(node.children).forEach((child) => stack.push(child)); - } - return nodes; -} - -function matchHierarchyNode(selectionBox) { - const box = normalizeBox(selectionBox); - if (!box || !state.hierarchy) return null; - const center = boxCenter(box); - let best = null; - flattenHierarchyNodes(state.hierarchy).forEach((node) => { - const nodeBox = parseNodeBounds(node); - if (!nodeBox) return; - const score = overlapScore(box, nodeBox) * 100 - distance(center, boxCenter(nodeBox)); - if (!best || score > best.score) { - best = { node, nodeBox, score }; - } - }); - if (!best) return null; - return { - type: best.node.type || best.node.componentType || best.node.className || best.node.name, - text: best.node.text || best.node.content, - description: best.node.description || best.node.accessibilityText, - accessibilityId: best.node.accessibilityId || best.node.id, - path: best.node.path || best.node.pagePath, - bounds: best.node.bounds || best.node.rect, - boundsPercent: best.nodeBox - }; -} - -function buildSelection(kind, box) { - const normalized = normalizeBox(box); - if (!normalized) return null; - const node = matchHierarchyNode(normalized); - const confidence = node - ? (state.hierarchy?.timestamp && state.screen?.timestamp && Math.abs(Number(state.hierarchy.timestamp) - Number(state.screen.timestamp)) < 5000 ? "high" : "medium") - : "low"; - return { - kind, - screenshotId: state.screen?.id || null, - boundsPercent: normalized, - hierarchyNode: node, - sourceHints: { - etsFiles: asArray(state.project?.sourceHints?.etsFiles).slice(0, 12), - componentNames: [ - node?.type, - ...(asArray(state.project?.sourceHints?.componentNames).slice(0, 8)) - ].filter(Boolean), - resourceKeys: [], - confidence - } - }; -} - -function selectionSummary(selection = state.selection) { - if (!selection) return "none"; - const box = selection.boundsPercent || {}; - const node = selection.hierarchyNode; - const label = node?.text || node?.description || node?.type || `${round2(box.x)},${round2(box.y)} ${round2(box.width)}x${round2(box.height)}%`; - return `${selection.kind}: ${label} (${selection.sourceHints?.confidence || "low"})`; -} - -export { buildSelection, selectionSummary }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/state.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/state.js deleted file mode 100644 index 079103a4..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/state.js +++ /dev/null @@ -1,30 +0,0 @@ -import { normalizeRoute } from "./constants.js"; - -const state = { - locale: navigator.language || "en-US", - route: normalizeRoute(document.documentElement.dataset.route || "/preview"), - tabId: null, - sessionId: null, - workspacePath: null, - loading: false, - status: "idle", - error: null, - project: null, - toolchain: null, - emulators: [], - recommendedEmulator: null, - selectedEmulatorName: null, - targets: [], - runtimeState: null, - build: null, - screen: null, - hierarchy: null, - diagnostics: [], - selection: null, - selectionDraft: null, - selectionDragging: false, - hydratingScreenPath: null, - action: null -}; - -export { state }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/util.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/util.js deleted file mode 100644 index 647c9288..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/util.js +++ /dev/null @@ -1,138 +0,0 @@ -import { MESSAGES, normalizeRoute } from "./constants.js"; -import { state } from "./state.js"; - -function runtime() { - return window.app || {}; -} - -function messages() { - return MESSAGES[state.locale] || MESSAGES[state.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 - ); -} - -function escapeHtml(value) { - return String(value ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function asArray(value) { - return Array.isArray(value) ? value : []; -} - -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("harmonyDevRoot"); -} - -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 workspaceLabel() { - const workspace = state.workspacePath || ""; - if (!workspace) return "-"; - return workspace.split(/[\\/]/).filter(Boolean).pop() || workspace; -} - -function routeKey(route = state.route) { - return normalizeRoute(route).replace("/", "") || "preview"; -} - -function formatTime(value) { - if (!value) return "-"; - const date = typeof value === "number" ? new Date(value) : new Date(String(value)); - if (Number.isNaN(date.getTime())) return "-"; - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - -function shortPath(value) { - const text = String(value || ""); - if (!text) return "-"; - const parts = text.split(/[\\/]/).filter(Boolean); - return parts.length > 4 ? `.../${parts.slice(-4).join("/")}` : text.replace(/\\/g, "/"); -} - -function round2(value) { - return Math.round((Number(value) || 0) * 100) / 100; -} - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -function normalizeBox(box) { - if (!box) return null; - const x = clamp(Number(box.x) || 0, 0, 100); - const y = clamp(Number(box.y) || 0, 0, 100); - const width = clamp(Number(box.width) || 0, 0, 100 - x); - const height = clamp(Number(box.height) || 0, 0, 100 - y); - return { x: round2(x), y: round2(y), width: round2(width), height: round2(height) }; -} - -function statusLabel(status) { - if (!status) return t("unknown"); - if (status === "completed" || status === "done" || status === "ok") return t("completed"); - if (status === "failed" || status === "error") return t("failed"); - if (status === "started" || status === "running") return t("started"); - return String(status); -} - -function primaryDiagnostic() { - return asArray(state.diagnostics)[0] - || asArray(state.build?.diagnostics)[0] - || asArray(state.runtimeState?.diagnostics)[0] - || null; -} - -function stagePointFromEvent(event) { - const stage = closestElement(event.target, "[data-select-stage]") || document.querySelector("[data-select-stage]"); - if (!stage) return null; - const rect = stage.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return null; - return { - x: round2(((event.clientX - rect.left) / rect.width) * 100), - y: round2(((event.clientY - rect.top) / rect.height) * 100) - }; -} - -export { - asArray, - bridgeOutput, - clamp, - closestElement, - escapeHtml, - formatTime, - normalizeBox, - primaryDiagnostic, - rootElement, - routeKey, - round2, - runtime, - shortPath, - stagePointFromEvent, - statusLabel, - t, - workspaceLabel -}; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/views.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/views.js deleted file mode 100644 index 3138869e..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/src/views.js +++ /dev/null @@ -1,319 +0,0 @@ -import { projectSummary, recommendedEmulator, selectedEmulator, targetState } from "./model.js"; -import { selectionSummary } from "./preview/selection-overlay.js"; -import { state } from "./state.js"; -import { asArray, escapeHtml, formatTime, primaryDiagnostic, shortPath, t, workspaceLabel } from "./util.js"; - -function attrDisabled(condition) { - return condition ? "disabled" : ""; -} - -function diagnostics() { - return [ - ...asArray(state.diagnostics), - ...asArray(state.build?.diagnostics), - ...asArray(state.runtimeState?.diagnostics) - ].slice(0, 6); -} - -function emulatorLabel(emulator) { - return [ - emulator?.name, - emulator?.osVersion || emulator?.apiVersion, - emulator?.deviceType - ].filter(Boolean).join(" / ") || "-"; -} - -function selectedDevice() { - const emulators = asArray(state.emulators); - const recommended = state.recommendedEmulator || recommendedEmulator(emulators); - const explicit = state.selectedEmulatorName ? selectedEmulator(emulators, state.selectedEmulatorName) : null; - return explicit || emulators.find((item) => item?.isRunning) || recommended; -} - -function productTitle(project) { - const summary = projectSummary(project); - const raw = String(summary.productName || "").trim(); - if (!raw || raw.startsWith("$")) return summary.bundleName || t("appTitle"); - return raw; -} - -function statusModel() { - const project = projectSummary(state.project); - const target = targetState(state.targets); - const diag = primaryDiagnostic(); - const emulator = selectedDevice(); - if (state.error) return { tone: "danger", label: state.error, detail: diag?.message || state.error }; - if (state.loading) return { tone: "busy", label: state.action || t("detecting"), detail: t("detecting") }; - if (state.project?.status === "notFound") return { tone: "warn", label: t("noProject"), detail: workspaceLabel() }; - if (target.target) return { tone: "success", label: t("targetReady"), detail: target.target.id || target.target.serial || project.bundleName }; - if (emulator) return { tone: "warn", label: t("emulatorReady"), detail: emulatorLabel(emulator) }; - return { tone: "muted", label: t("noTarget"), detail: project.bundleName }; -} - -function compactRow(label, value, title = "") { - return ` -
-
${escapeHtml(label)}
-
${escapeHtml(value || "-")}
-
- `; -} - -function renderKvs(rows) { - return `
${rows.join("")}
`; -} - -function renderTopbar() { - const project = projectSummary(state.project); - const target = targetState(state.targets).target; - const status = statusModel(); - - return ` -
-
- ${escapeHtml(t("appTitle"))} - ${escapeHtml(workspaceLabel())} -
-
- ${escapeHtml(project.bundleName)} - ${escapeHtml(project.abilityName)} - ${escapeHtml(target?.id || t("noTarget"))} -
-
- - ${escapeHtml(status.label)} -
-
- `; -} - -function renderEmulatorSelect(emulators, selected) { - if (!emulators.length) { - return `${escapeHtml(t("noTarget"))}`; - } - - return ` - - `; -} - -function renderDiagnostics(items) { - if (!items.length) return ""; - return ` -
-

${escapeHtml(t("diagnostics"))}

-
    - ${items.map((item) => ` -
  • - ${escapeHtml(item.severity || item.stage || "info")} -

    ${escapeHtml(item.message || item.summary || item.code || String(item))}

    -
  • - `).join("")} -
-
- `; -} - -function renderCommandHeader() { - const project = projectSummary(state.project); - const target = targetState(state.targets).target; - const emulators = asArray(state.emulators); - const emulator = selectedDevice(); - const build = state.build || state.runtimeState?.build || {}; - const artifact = build.artifact || state.runtimeState?.latestArtifact; - const screen = state.screen || state.runtimeState?.screen; - const diagList = diagnostics(); - const busy = state.loading; - const hasTarget = Boolean(target); - const hasRunningEmulator = emulators.some((item) => item.isRunning); - const canCapture = hasTarget || hasRunningEmulator || emulator?.isRunning; - const emulatorAction = emulator?.isRunning ? "stop-emulator" : "start-emulator"; - const emulatorActionLabel = emulator?.isRunning ? t("stopEmulator") : t("startEmulator"); - - const overviewRows = [ - compactRow("Bundle", project.bundleName), - compactRow("Ability", project.abilityName), - compactRow(t("target"), target?.id || t("noTarget"), target?.raw || target?.id), - compactRow(t("latestArtifact"), shortPath(artifact?.path), artifact?.path || "") - ]; - const environmentRows = [ - compactRow("Module", project.moduleName), - compactRow("SDK", project.targetSdkVersion), - compactRow("Signing", project.signing?.configured ? t("signed") : t("unsigned")), - compactRow(t("updated"), formatTime(build.updatedAt || artifact?.createdAt || screen?.timestamp)) - ]; - - return ` - - `; -} - -function renderScreenStage() { - const screen = state.screen || state.runtimeState?.screen; - const selection = state.selection; - const hasImage = Boolean(screen?.dataUrl || screen?.uri); - const src = screen?.dataUrl || screen?.uri || ""; - const selectionBox = selection?.boundsPercent; - const screenWidth = Number(screen?.width) > 0 ? Math.round(Number(screen.width)) : 9; - const screenHeight = Number(screen?.height) > 0 ? Math.round(Number(screen.height)) : 19; - const screenAspect = Math.max(0.05, Math.min(5, screenWidth / screenHeight)); - const screenMeta = hasImage - ? `${screenWidth} x ${screenHeight}${screen?.timestamp ? ` / ${formatTime(screen.timestamp)}` : ""}` - : t("previewUnavailable"); - - return ` -
-
- ${escapeHtml(t("screen"))} - ${escapeHtml(screenMeta)} -
-
- ${hasImage ? ` -
-
- HarmonyOS screenshot - ${selectionBox ? ` -
- ` : ""} - ${state.selectionDraft ? ` -
- ` : ""} -
-
- ` : ` -
-
- ${escapeHtml(t("previewUnavailable"))} - ${escapeHtml(t("selectHint"))} -
-
- `} -
-
- ${escapeHtml(t("context"))} - ${escapeHtml(selection ? selectionSummary() : t("noSelection"))} - ${selection ? `` : ""} - -
-
- `; -} - -function fitScreenCanvas() { - const root = document.getElementById("harmonyDevRoot"); - if (!root) return; - root.querySelectorAll(".hd-screen-canvas").forEach((canvas) => { - const stage = canvas.closest(".hd-device-stage"); - if (!stage) return; - const screenWidth = Number(canvas.getAttribute("data-screen-width")) || 9; - const screenHeight = Number(canvas.getAttribute("data-screen-height")) || 19; - const rect = stage.getBoundingClientRect(); - const maxWidth = Math.max(1, rect.width - 44); - const maxHeight = Math.max(1, rect.height - 44); - const scale = Math.min(maxWidth / screenWidth, maxHeight / screenHeight); - canvas.style.width = `${Math.max(1, Math.floor(screenWidth * scale))}px`; - canvas.style.height = `${Math.max(1, Math.floor(screenHeight * scale))}px`; - }); -} - -function renderMain() { - if (!state.workspacePath) { - return ` -
-
${escapeHtml(t("noWorkspace"))}
-
- `; - } - return ` -
- ${renderScreenStage()} -
- `; -} - -function render() { - const root = document.getElementById("harmonyDevRoot"); - if (!root) return; - root.dataset.route = state.route; - document.documentElement.dataset.route = state.route; - root.innerHTML = ` - ${renderTopbar()} - ${state.loading ? `
` : `
`} - ${renderCommandHeader()} -
${state.error ? escapeHtml(state.error) : ""}
- ${renderMain()} - `; - if (typeof window.requestAnimationFrame === "function") { - window.requestAnimationFrame(fitScreenCanvas); - } else { - setTimeout(fitScreenCanvas, 0); - } -} - -export { fitScreenCanvas, render }; diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/style.css b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/style.css deleted file mode 100644 index 480b9243..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/style.css +++ /dev/null @@ -1,764 +0,0 @@ -.harmony-dev { - --hd-bg: var(--sparo-app-bg, Canvas); - --hd-surface: var(--sparo-app-surface, Canvas); - --hd-panel: var(--sparo-app-panel, Canvas); - --hd-soft: var(--sparo-app-card, color-mix(in srgb, Canvas 94%, CanvasText)); - --hd-soft-hover: var(--sparo-app-card-hover, color-mix(in srgb, Canvas 90%, CanvasText)); - --hd-control: var(--sparo-app-control-bg, Field); - --hd-control-hover: var(--sparo-app-control-hover, color-mix(in srgb, Field 92%, CanvasText)); - --hd-text: var(--sparo-app-text, CanvasText); - --hd-muted: var(--sparo-app-text-secondary, color-mix(in srgb, CanvasText 66%, Canvas)); - --hd-subtle: var(--sparo-app-text-muted, color-mix(in srgb, CanvasText 46%, Canvas)); - --hd-border: var(--sparo-app-border-subtle, color-mix(in srgb, CanvasText 14%, Canvas)); - --hd-border-strong: var(--sparo-app-border, color-mix(in srgb, CanvasText 22%, Canvas)); - --hd-accent: var(--sparo-app-accent, Highlight); - --hd-accent-hover: var(--sparo-app-accent-hover, color-mix(in srgb, Highlight 86%, CanvasText)); - --hd-accent-soft: var(--sparo-app-accent-soft, color-mix(in srgb, var(--hd-accent) 11%, transparent)); - --hd-accent-text: var(--sparo-app-accent-text, HighlightText); - --hd-info: var(--sparo-info, var(--hd-accent)); - --hd-success: var(--sparo-success, color-mix(in srgb, CanvasText 68%, Canvas)); - --hd-warn: var(--sparo-warning, color-mix(in srgb, CanvasText 72%, Canvas)); - --hd-danger: var(--sparo-error, color-mix(in srgb, CanvasText 72%, Canvas)); - --hd-focus: color-mix(in srgb, var(--sparo-app-focus-ring, var(--hd-accent)) 52%, transparent); - --hd-shadow: var(--sparo-app-shadow, 0 18px 48px color-mix(in srgb, CanvasText 16%, transparent)); - --hd-shadow-soft: var(--sparo-app-shadow-sm, 0 10px 26px color-mix(in srgb, CanvasText 10%, transparent)); - --hd-radius-sm: var(--sparo-app-radius-sm, 6px); - --hd-radius: var(--sparo-app-radius, 8px); - --hd-layer-popover: var(--sparo-z-popover, 20); - --hd-font-sans: var(--sparo-font-sans, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); - --hd-font-mono: var(--sparo-font-mono, "JetBrains Mono", ui-monospace, SFMono-Regular, monospace); - display: grid; - grid-template-rows: auto 2px auto auto minmax(0, 1fr); - width: 100%; - max-width: 100%; - height: 100vh; - max-height: 100vh; - min-height: 0; - overflow: hidden; - overflow-x: clip; - background: var(--hd-bg); - color: var(--hd-text); - font: 13px/1.45 var(--hd-font-sans); -} - -html, -body { - width: 100%; - max-width: 100%; - height: 100%; - min-width: 0; - margin: 0; - overflow: hidden; -} - -.harmony-dev *, -.harmony-dev *::before, -.harmony-dev *::after { - box-sizing: border-box; -} - -.harmony-dev button, -.harmony-dev select { - font: inherit; -} - -.harmony-dev button { - cursor: pointer; -} - -.harmony-dev__boot, -.hd-empty { - display: grid; - place-items: center; - min-height: 280px; - padding: 24px; - color: var(--hd-muted); - text-align: center; -} - -.harmony-dev__loader { - width: 24px; - height: 24px; - border: 2px solid var(--hd-border); - border-top-color: var(--hd-accent); - border-radius: 50%; - animation: hd-spin 900ms linear infinite; -} - -@keyframes hd-spin { - to { transform: rotate(360deg); } -} - -.hd-topbar { - display: grid; - grid-template-columns: minmax(160px, 1fr) minmax(180px, 1fr) auto; - align-items: center; - gap: 12px; - min-width: 0; - min-height: 42px; - padding: 6px 14px; - border-bottom: 1px solid var(--hd-border); - background: color-mix(in srgb, var(--hd-panel) 96%, transparent); -} - -.hd-title { - display: flex; - align-items: baseline; - gap: 9px; - min-width: 0; -} - -.hd-title strong { - flex: 0 0 auto; - font-size: 13px; - font-weight: 760; - letter-spacing: 0; -} - -.hd-title span, -.hd-topbar__meta span, -.hd-status span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.hd-title span { - min-width: 0; - color: var(--hd-muted); - font-size: 12px; -} - -.hd-topbar__meta { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(0, .8fr) minmax(0, 1fr); - gap: 8px; - min-width: 0; - color: var(--hd-subtle); - font-size: 12px; -} - -.hd-status { - display: inline-grid; - grid-template-columns: 7px minmax(0, 1fr); - align-items: center; - gap: 7px; - min-width: 118px; - max-width: 220px; - min-height: 28px; - padding: 0 9px; - border: 1px solid var(--hd-border); - border-radius: var(--hd-radius-sm); - color: var(--hd-muted); - background: color-mix(in srgb, var(--hd-soft) 72%, transparent); -} - -.hd-status__dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--hd-subtle); -} - -.hd-status--success .hd-status__dot { background: var(--hd-success); } -.hd-status--warn .hd-status__dot { background: var(--hd-warn); } -.hd-status--danger .hd-status__dot { background: var(--hd-danger); } -.hd-status--busy .hd-status__dot { - background: var(--hd-accent); - animation: hd-pulse 1s ease-in-out infinite; -} - -@keyframes hd-pulse { - 50% { opacity: .36; } -} - -.hd-progress { - height: 2px; - overflow: hidden; - background: transparent; -} - -.hd-progress span { - display: block; - width: 34%; - height: 100%; - background: linear-gradient(90deg, transparent, var(--hd-accent), transparent); - animation: hd-progress 1.2s ease-in-out infinite; -} - -.hd-progress--idle span { - display: none; -} - -@keyframes hd-progress { - 0% { transform: translateX(-110%); } - 100% { transform: translateX(300%); } -} - -.hd-command-header { - position: relative; - z-index: 2; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - min-width: 0; - padding: 8px 14px; - border-bottom: 1px solid var(--hd-border); - background: color-mix(in srgb, var(--hd-panel) 98%, transparent); -} - -.hd-command-title { - display: grid; - gap: 1px; - min-width: 0; - max-width: 220px; - flex: 1 1 150px; -} - -.hd-command-title span { - color: var(--hd-subtle); - font-size: 10px; - font-weight: 760; -} - -.hd-command-title strong { - overflow: hidden; - color: var(--hd-text); - font-size: 13px; - font-weight: 760; - text-overflow: ellipsis; - white-space: nowrap; -} - -.hd-command-actions, -.hd-device-inline { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - min-width: 0; -} - -.hd-command-actions { - flex: 0 1 auto; -} - -.hd-device-inline { - flex: 1 1 260px; - justify-content: flex-end; -} - -.hd-plain-button, -.hd-action, -.hd-state-button, -.hd-run-button, -.hd-more-menu summary, -.hd-more-actions button { - min-width: 0; - min-height: 30px; - border: 1px solid var(--hd-border); - border-radius: var(--hd-radius-sm); - background: var(--hd-control); - color: var(--hd-text); - font-weight: 650; - white-space: nowrap; - transition: - background .12s ease, - border-color .12s ease, - color .12s ease, - transform .08s ease, - box-shadow .12s ease; -} - -.hd-run-button { - padding: 0 12px; - border-color: color-mix(in srgb, var(--hd-info) 34%, var(--hd-border)); - background: color-mix(in srgb, var(--hd-info) 11%, var(--hd-control)); - color: var(--hd-text); -} - -.hd-action, -.hd-state-button, -.hd-more-menu summary, -.hd-more-actions button { - padding: 0 9px; - color: var(--hd-muted); - font-size: 12px; -} - -.hd-state-button.is-running { - border-color: color-mix(in srgb, var(--hd-danger) 34%, var(--hd-border)); - background: color-mix(in srgb, var(--hd-danger) 8%, var(--hd-control)); - color: var(--hd-danger); -} - -.hd-device-pill { - display: inline-flex; - flex: 0 1 auto; - align-items: center; - gap: 6px; - min-height: 26px; - color: var(--hd-subtle); - font-size: 11px; - font-weight: 720; - white-space: nowrap; -} - -.hd-device-pill i { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--hd-subtle); -} - -.hd-device-pill.is-online i { - background: var(--hd-success); -} - -.hd-device-select { - flex: 1 1 180px; - width: auto; - max-width: 100%; - min-width: 0; - min-height: 30px; - border: 1px solid var(--hd-border); - border-radius: var(--hd-radius-sm); - background: var(--hd-control); - color: var(--hd-text); - padding: 0 8px; - outline: none; -} - -.hd-device-empty { - color: var(--hd-subtle); - font-size: 12px; -} - -.hd-more-menu { - position: relative; - flex: 0 0 auto; -} - -.hd-more-menu summary { - display: inline-flex; - align-items: center; - list-style: none; - cursor: pointer; -} - -.hd-more-menu summary::-webkit-details-marker { - display: none; -} - -.hd-more-menu__panel { - position: absolute; - top: calc(100% + 7px); - right: 0; - z-index: var(--hd-layer-popover); - width: min(560px, calc(100vw - 32px)); - max-height: min(68vh, 620px); - overflow: auto; - padding: 10px; - border: 1px solid var(--hd-border); - border-radius: var(--hd-radius); - background: var(--hd-panel); - box-shadow: var(--hd-shadow); -} - -.hd-more-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 10px; -} - -.hd-menu-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.hd-menu-block { - min-width: 0; - padding: 10px; - border: 1px solid var(--hd-border); - border-radius: var(--hd-radius-sm); - background: color-mix(in srgb, var(--hd-soft) 58%, transparent); -} - -.hd-menu-block + .hd-menu-block { - margin-top: 10px; -} - -.hd-menu-block h3 { - margin: 0 0 8px; - color: var(--hd-text); - font-size: 12px; - font-weight: 760; -} - -.hd-error { - padding: 8px 14px; - border-bottom: 1px solid color-mix(in srgb, var(--hd-danger) 34%, var(--hd-border)); - background: color-mix(in srgb, var(--hd-danger) 9%, var(--hd-panel)); - color: var(--hd-danger); - font-size: 12px; -} - -.hd-error--empty { - height: 0; - overflow: hidden; - padding: 0; - border: 0; -} - -.hd-workbench { - min-width: 0; - min-height: 0; - padding: 12px 14px 14px; - overflow: hidden; - background: linear-gradient(180deg, color-mix(in srgb, var(--hd-surface) 98%, transparent), var(--hd-bg)); -} - -.hd-workbench--empty { - display: grid; - place-items: center; -} - -.hd-preview { - display: grid; - grid-template-rows: 34px minmax(0, 1fr) 38px; - height: 100%; - min-height: 0; - overflow: hidden; - border: 1px solid var(--hd-border); - border-radius: var(--hd-radius); - background: var(--hd-panel); - box-shadow: var(--hd-shadow-soft); -} - -.hd-preview__top, -.hd-context-bar { - display: grid; - align-items: center; - gap: 8px; - min-width: 0; - padding: 0 12px; - border-color: var(--hd-border); - background: color-mix(in srgb, var(--hd-panel) 94%, transparent); -} - -.hd-preview__top { - grid-template-columns: auto minmax(0, 1fr); - border-bottom: 1px solid var(--hd-border); -} - -.hd-preview__top span, -.hd-context-bar span { - color: var(--hd-subtle); - font-size: 11px; - font-weight: 700; -} - -.hd-preview__top strong, -.hd-context-bar strong { - min-width: 0; - overflow: hidden; - color: var(--hd-muted); - font-family: var(--hd-font-mono); - font-size: 11px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.hd-device-stage { - position: relative; - display: flex; - align-items: center; - justify-content: center; - min-height: 0; - padding: 18px; - overflow: hidden; - background: - linear-gradient(180deg, color-mix(in srgb, var(--hd-bg) 86%, var(--hd-text)), color-mix(in srgb, var(--hd-bg) 92%, var(--hd-text))); - user-select: none; - touch-action: none; -} - -.hd-phone-shell { - position: relative; - z-index: 1; - display: grid; - place-items: center; - max-width: 100%; - max-height: 100%; - padding: 10px; - border: 1px solid color-mix(in srgb, var(--hd-text) 16%, transparent); - border-radius: var(--hd-radius); - background: color-mix(in srgb, var(--hd-panel) 92%, var(--hd-text)); - box-shadow: var(--hd-shadow); -} - -.hd-phone-shell--empty { - width: min(320px, 78%); - height: min(620px, 86%); - min-height: 360px; -} - -.hd-screen-canvas { - position: relative; - width: min(320px, 100%); - max-width: 100%; - max-height: 100%; - aspect-ratio: var(--hd-screen-ratio, 9 / 19); - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--hd-text) 12%, transparent); - border-radius: 4px; - background: var(--hd-bg); -} - -.hd-screen-image { - position: absolute; - inset: 0; - display: block; - width: 100%; - height: 100%; - object-fit: contain; - object-position: center; -} - -.hd-preview-empty { - display: grid; - gap: 6px; - padding: 24px; - text-align: center; - color: var(--hd-subtle); -} - -.hd-preview-empty strong { - color: var(--hd-text); - font-size: 14px; -} - -.hd-selection { - position: absolute; - border: 2px solid var(--hd-accent); - background: color-mix(in srgb, var(--hd-accent) 17%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--hd-text) 48%, transparent); - pointer-events: none; -} - -.hd-selection--draft { - border-style: dashed; -} - -.hd-context-bar { - grid-template-columns: auto minmax(0, 1fr) auto auto; - border-top: 1px solid var(--hd-border); -} - -.hd-plain-button { - min-height: 26px; - padding: 0 9px; - border: 1px solid var(--hd-border); - border-radius: var(--hd-radius-sm); - background: var(--hd-control); - color: var(--hd-muted); - font-weight: 650; -} - -.hd-plain-button--accent { - color: var(--hd-accent); -} - -.hd-kvs { - display: grid; - gap: 6px; - margin: 0; - padding: 0; -} - -.hd-kv { - display: grid; - grid-template-columns: 82px minmax(0, 1fr); - gap: 8px; - min-width: 0; - align-items: baseline; -} - -.hd-kv dt { - color: var(--hd-subtle); - font-size: 11px; -} - -.hd-kv dd { - min-width: 0; - margin: 0; - overflow: hidden; - color: var(--hd-muted); - font-family: var(--hd-font-mono); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.hd-diagnostics { - display: grid; - gap: 8px; - margin: 0; - padding: 0; - list-style: none; -} - -.hd-diagnostics li { - display: grid; - grid-template-columns: 58px minmax(0, 1fr); - gap: 8px; - align-items: start; -} - -.hd-diagnostics span { - color: var(--hd-warn); - font-size: 10px; - font-weight: 760; -} - -.hd-diagnostics p { - margin: 0; - color: var(--hd-muted); - font-size: 12px; -} - -.hd-plain-button:hover:not(:disabled), -.hd-action:hover:not(:disabled), -.hd-state-button:hover:not(:disabled), -.hd-more-menu summary:hover, -.hd-more-actions button:hover:not(:disabled) { - border-color: var(--hd-border-strong); - background: var(--hd-control-hover); - color: var(--hd-text); -} - -.hd-run-button:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--hd-info) 48%, var(--hd-border-strong)); - background: color-mix(in srgb, var(--hd-info) 15%, var(--hd-control-hover)); -} - -.hd-plain-button:active:not(:disabled), -.hd-action:active:not(:disabled), -.hd-state-button:active:not(:disabled), -.hd-run-button:active:not(:disabled), -.hd-more-actions button:active:not(:disabled) { - transform: translateY(1px); -} - -.hd-plain-button:focus-visible, -.hd-action:focus-visible, -.hd-state-button:focus-visible, -.hd-run-button:focus-visible, -.hd-more-menu summary:focus-visible, -.hd-more-actions button:focus-visible, -.hd-device-select:focus-visible { - outline: 2px solid var(--hd-focus); - outline-offset: 2px; -} - -.hd-plain-button:disabled, -.hd-action:disabled, -.hd-state-button:disabled, -.hd-run-button:disabled, -.hd-more-actions button:disabled { - cursor: not-allowed; - opacity: .48; - transform: none; -} - -@media (max-width: 980px) { - .hd-topbar { - grid-template-columns: minmax(0, 1fr) auto; - } - - .hd-topbar__meta { - display: none; - } - - .hd-command-header { - flex-wrap: wrap; - } - - .hd-device-inline { - order: 3; - flex-basis: 100%; - justify-content: flex-start; - } - - .hd-more-menu { - margin-left: auto; - } -} - -@media (max-width: 640px) { - .hd-topbar { - grid-template-columns: minmax(0, 1fr); - gap: 6px; - } - - .hd-status { - width: 100%; - max-width: none; - } - - .hd-command-title { - flex-basis: 100%; - max-width: none; - } - - .hd-command-actions { - flex: 1 1 100%; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .hd-device-inline { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - width: 100%; - } - - .hd-device-select, - .hd-state-button { - width: 100%; - } - - .hd-state-button { - grid-column: 1 / -1; - } - - .hd-more-menu { - margin-left: 0; - } - - .hd-more-menu__panel { - left: 0; - right: auto; - width: calc(100vw - 28px); - } - - .hd-menu-grid { - grid-template-columns: 1fr; - } - - .hd-workbench { - padding: 10px; - } - - .hd-device-stage { - padding: 12px; - } - - .hd-context-bar { - grid-template-columns: auto minmax(0, 1fr) auto; - } -} diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/ui.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/ui.js deleted file mode 100644 index 54a07430..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/ui.js +++ /dev/null @@ -1,101 +0,0 @@ -import { normalizeRoute } from "./src/constants.js"; -import { - beginSelection, - buildAndRun, - buildProject, - captureScreen, - clearSelection, - dumpHierarchy, - endSelection, - hotReload, - installApp, - launchAbility, - moveSelection, - refreshFacts, - runUnitTests, - sendContext, - setSelectedEmulator, - startEmulator, - stopEmulator -} from "./src/actions.js"; -import { state } from "./src/state.js"; -import { closestElement, runtime } from "./src/util.js"; -import { fitScreenCanvas, render } from "./src/views.js"; - -function handleRouteEvent(payload = {}) { - state.route = normalizeRoute(payload.route || state.route); - state.tabId = payload.tabId || state.tabId; - state.sessionId = payload.sessionId || state.sessionId; - const nextWorkspace = payload.workspacePath || payload.workbench?.workspacePath || state.workspacePath; - const workspaceChanged = nextWorkspace && nextWorkspace !== state.workspacePath; - state.workspacePath = nextWorkspace || state.workspacePath; - if (workspaceChanged) { - state.project = null; - state.toolchain = null; - state.targets = []; - state.emulators = []; - state.recommendedEmulator = null; - state.selectedEmulatorName = null; - state.runtimeState = null; - state.build = null; - state.screen = null; - state.hierarchy = null; - state.selection = null; - state.error = null; - } - render(); - if (workspaceChanged || (!state.project && state.workspacePath)) { - void refreshFacts(); - } -} - -document.addEventListener("click", (event) => { - const node = closestElement(event.target, "[data-action]"); - if (!node) return; - const action = node.dataset.action; - if (action === "detect") void refreshFacts(); - if (action === "start-emulator") void startEmulator(); - if (action === "stop-emulator") void stopEmulator(); - if (action === "test") void runUnitTests(); - if (action === "build") void buildProject(); - if (action === "install") void installApp(); - if (action === "launch") void launchAbility(); - if (action === "build-run") void buildAndRun(); - if (action === "capture") void captureScreen(); - if (action === "inspect") void dumpHierarchy(); - if (action === "hot-reload") void hotReload(); - if (action === "send-context") void sendContext(); - if (action === "clear-selection") clearSelection(); -}); - -document.addEventListener("change", (event) => { - const node = closestElement(event.target, "[data-emulator-select]"); - if (!node) return; - setSelectedEmulator(node.value); -}); - -document.addEventListener("pointerdown", beginSelection, true); -document.addEventListener("pointermove", moveSelection, true); -document.addEventListener("pointerup", endSelection, true); -window.addEventListener("resize", fitScreenCanvas); - -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; - render(); - } - if (message.event === "productAppRuntimeRouteChange") { - handleRouteEvent(message.payload || {}); - } -}); - -runtime().onLocaleChange?.((locale) => { - state.locale = locale || state.locale; - render(); -}); - -window.addEventListener("DOMContentLoaded", () => { - render(); -}); diff --git a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/worker.js b/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/worker.js deleted file mode 100644 index 5e47e0dc..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/components/surfaces/builtin-harmony-dev-surface/source/worker.js +++ /dev/null @@ -1,4 +0,0 @@ -// HarmonyOS Dev keeps product logic in the UI and delegates privileged work to -// the Harmony runtime Bridge Component. This worker is intentionally empty. - -export default {}; 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 deleted file mode 100644 index 1587a669..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/config/data-schema.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "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 deleted file mode 100644 index 0967ef42..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/config/default.json +++ /dev/null @@ -1 +0,0 @@ -{} 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 deleted file mode 100644 index 0cb5e7bd..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/tests/eval.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index ecdfb11e..00000000 --- a/bundles/product-apps/builtin-harmony-dev/13.0.0/tests/rehearsal.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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/151.0.10/app.json b/bundles/product-apps/builtin-ppt-live/151.0.10/app.json index dc10b513..8c851755 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/app.json +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/app.json @@ -38,6 +38,7 @@ "id": "deck", "label": "Deck", "scope": "asset", + "reusableAcrossWorks": true, "identitySchema": { "type": "object", "properties": { diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/agent.md b/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/agent.md index 7e2f1b03..66c86311 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/agent.md +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/agent.md @@ -17,9 +17,11 @@ PPT tools are exposed with host-qualified names. Always select the exact offered - Use `agentcomponent__builtin-ppt-live-agent__review_presentation_manuscript` in prepare then commit mode. The review is an AI judgment; runtime rule violations remain a separate field. - Commit a complete topic-specific PresentationSystem with `agentcomponent__builtin-ppt-live-agent__set_presentation_system` before rendering the Design Case. - Render exactly three real Design Case pages with `agentcomponent__builtin-ppt-live-agent__render_design_case`. Immediately call `agentcomponent__builtin-ppt-live-agent__confirm_design_case` with the returned case id, density, color direction, and three sample page references; its dedicated FlowChat card waits for the user's single approval or systemic revision request. Do not continue into full production until that tool returns an approved decision. +- Author `composition.slots` as concrete element payloads. Every item needs a stable lowercase `id`, a recipe `slotId`, and an element `type`; use type-specific fields from the tool schema. Never copy recipe metadata such as `kinds`, `required`, or `repeatable` into a slot element. If composition validation fails, repair every returned violation together before one retry. - Create only grounded or purpose-built assets. Semantic SVG may use registered `{{token}}` values. Never invent screenshots or data. - Call `agentcomponent__builtin-ppt-live-agent__prepare_visual_assets` only when the complete manuscript requires purpose-built assets. - Call `agentcomponent__builtin-ppt-live-agent__generate_slide_visual` once per page in Manuscript order. Author the complete visual page yourself; Runtime must not inject or require string equality with Manuscript fields. Preserve meaning, facts, evidence, and narrative while adapting copy and layout for visual communication. +- Calls for independent pages may share the deck revision from one inspection; Runtime safely rebases that deck baseline after earlier page commits while still requiring exact slide, PresentationSystem, Manuscript, and Design Case revisions. Never reuse an old `expectedSlideRevision` when regenerating the same page. - Call `agentcomponent__builtin-ppt-live-agent__review_deck` first with `mode: prepare`. Compare every visual page against the complete Manuscript and speaker script for semantic alignment, omissions, unsupported claims, evidence fidelity, content restructuring quality, and narrative continuity. Commit all required `alignmentCoverage` checks, structured findings, and honest review coverage; never claim alignment from runtime string matching. - Fix root causes. Use `agentcomponent__builtin-ppt-live-agent__set_presentation_system`, `agentcomponent__builtin-ppt-live-agent__prepare_visual_assets`, or regenerated affected pages; do not mechanically repair pages case by case. - Export with `agentcomponent__builtin-ppt-live-agent__export_deck` only after a passed current review. Do not claim completion before the returned PPTX validation passes. diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/test/tool-abi.test.js b/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/test/tool-abi.test.js index cd0d6f2a..88d9e18a 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/test/tool-abi.test.js +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/test/tool-abi.test.js @@ -58,3 +58,23 @@ test("visual tools bind to Manuscript revisions without exposing Blueprint field const review = JSON.parse(fs.readFileSync(path.join(toolsRoot, "review_deck.tool.json"), "utf8")); assert.equal(review.inputSchema.properties.alignmentCoverage.properties.checks.minItems, 6); }); + +test("visual composition schemas expose concrete slot element contracts", () => { + for (const filename of ["render_design_case.tool.json", "generate_slide_visual.tool.json"]) { + const definition = JSON.parse(fs.readFileSync(path.join(toolsRoot, filename), "utf8")); + const definitions = definition.inputSchema.$defs; + const slotItems = definitions.slide.properties.composition.properties.slots.items; + assert.equal(slotItems.$ref, "#/$defs/slotElement", filename); + assert.equal(definitions.slide.additionalProperties, false, filename); + assert.equal(definitions.slide.properties.composition.additionalProperties, false, filename); + assert.equal(definitions.slotElement.additionalProperties, false, filename); + assert.deepEqual(definitions.slotElement.required, ["id", "slotId", "type"], filename); + assert.deepEqual( + definitions.slotElement.properties.type.enum, + ["text", "shape", "line", "image", "svg", "chart", "table"], + filename, + ); + assert.deepEqual(definitions.chartPoint.required, ["label", "value"], filename); + assert.equal(definitions.slotStyle.properties.opacity.maximum, 1, filename); + } +}); diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/tools/generate_slide_visual.tool.json b/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/tools/generate_slide_visual.tool.json index 4a7118f2..b40affd3 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/tools/generate_slide_visual.tool.json +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/tools/generate_slide_visual.tool.json @@ -6,7 +6,7 @@ "additionalProperties": false, "required": ["expectedDeckRevision", "expectedSlideRevision", "expectedSystemRevision", "expectedManuscriptRevision", "expectedDesignCaseRevision", "slide", "intent"], "properties": { - "expectedDeckRevision": { "type": "integer", "minimum": 0 }, + "expectedDeckRevision": { "type": "integer", "minimum": 0, "description": "Deck baseline from inspection. Independent page calls may share it; Runtime rebases it after earlier page commits while exact slide and source revisions still guard conflicts." }, "expectedSlideRevision": { "type": "integer", "minimum": 0 }, "expectedSystemRevision": { "type": "integer", "minimum": 0 }, "expectedManuscriptRevision": { "type": "integer", "minimum": 1 }, @@ -17,6 +17,7 @@ "$defs": { "slide": { "type": "object", + "additionalProperties": false, "required": ["id", "title", "claim", "pageRole", "recipeId", "layoutMode", "visualMode", "visualPlan", "evidenceObject", "exportStrategy", "composition"], "properties": { "id": { "type": "string", "pattern": "^p[0-9]{2,3}$" }, @@ -31,7 +32,79 @@ "exportStrategy": { "type": "string", "enum": ["native", "native-shapes", "native-chart", "svg", "image"] }, "sourceNote": { "type": "string", "maxLength": 500 }, "notes": { "type": "string", "maxLength": 8000 }, - "composition": { "type": "object", "required": ["slots"], "properties": { "slots": { "type": "array", "minItems": 1, "maxItems": 40, "items": { "type": "object" } } } } + "composition": { + "type": "object", + "additionalProperties": false, + "required": ["slots"], + "properties": { + "slots": { + "type": "array", + "minItems": 1, + "maxItems": 40, + "items": { "$ref": "#/$defs/slotElement" } + } + } + } + } + }, + "slotElement": { + "type": "object", + "additionalProperties": false, + "required": ["id", "slotId", "type"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]{0,127}$", "description": "Stable lowercase element id, unique within the slide." }, + "slotId": { "type": "string", "minLength": 1, "maxLength": 80, "description": "A concrete slot id from the selected recipe, never a recipe descriptor such as kinds or required." }, + "type": { "type": "string", "enum": ["text", "shape", "line", "image", "svg", "chart", "table"] }, + "text": { "type": "string", "minLength": 1, "maxLength": 4000, "description": "Required for text and chart elements; optional for shape elements." }, + "shape": { "type": "string", "enum": ["rect", "roundRect", "ellipse"], "description": "Optional for shape elements." }, + "assetId": { "type": "string", "minLength": 1, "maxLength": 128, "description": "Required for image and svg elements; must reference a prepared asset." }, + "alt": { "type": "string", "minLength": 1, "maxLength": 500, "description": "Required for image and svg elements." }, + "data": { + "type": "array", + "minItems": 2, + "maxItems": 24, + "description": "Required for chart elements.", + "items": { "$ref": "#/$defs/chartPoint" } + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "description": "Required for table elements; every row must have the same number of cells.", + "items": { + "type": "array", + "minItems": 1, + "maxItems": 12, + "items": { "type": "string", "minLength": 1, "maxLength": 500 } + } + }, + "style": { "$ref": "#/$defs/slotStyle" } + } + }, + "chartPoint": { + "type": "object", + "additionalProperties": false, + "required": ["label", "value"], + "properties": { + "label": { "type": "string", "minLength": 1, "maxLength": 120 }, + "value": { "type": "number", "minimum": -1000000000000, "maximum": 1000000000000 } + } + }, + "slotStyle": { + "type": "object", + "additionalProperties": false, + "properties": { + "textRole": { "type": "string", "enum": ["cover", "title", "section", "body", "label", "metric", "caption", "source", "code"] }, + "colorToken": { "type": "string", "minLength": 1, "maxLength": 80 }, + "fillToken": { "type": "string", "minLength": 1, "maxLength": 80 }, + "strokeToken": { "type": "string", "minLength": 1, "maxLength": 80 }, + "radiusRole": { "type": "string", "enum": ["none", "small", "medium"] }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "align": { "type": "string", "enum": ["left", "center", "right"] }, + "valign": { "type": "string", "enum": ["top", "middle", "bottom"] }, + "strokeWidth": { "type": "number", "minimum": 0, "maximum": 24 }, + "dash": { "type": "string", "enum": ["solid", "dash", "dot"] }, + "padding": { "type": "number", "minimum": 0, "maximum": 100 } } } } diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/tools/render_design_case.tool.json b/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/tools/render_design_case.tool.json index ba2fb12e..50a05bad 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/tools/render_design_case.tool.json +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/agents/builtin-ppt-live-agent/source/tools/render_design_case.tool.json @@ -13,6 +13,7 @@ "$defs": { "slide": { "type": "object", + "additionalProperties": false, "required": ["id", "title", "claim", "pageRole", "recipeId", "layoutMode", "visualMode", "visualPlan", "evidenceObject", "exportStrategy", "composition"], "properties": { "id": { "type": "string", "pattern": "^p[0-9]{2,3}$" }, @@ -27,7 +28,79 @@ "exportStrategy": { "type": "string", "enum": ["native", "native-shapes", "native-chart", "svg", "image"] }, "sourceNote": { "type": "string", "maxLength": 500 }, "notes": { "type": "string", "maxLength": 8000 }, - "composition": { "type": "object", "required": ["slots"], "properties": { "slots": { "type": "array", "minItems": 1, "maxItems": 40, "items": { "type": "object" } } } } + "composition": { + "type": "object", + "additionalProperties": false, + "required": ["slots"], + "properties": { + "slots": { + "type": "array", + "minItems": 1, + "maxItems": 40, + "items": { "$ref": "#/$defs/slotElement" } + } + } + } + } + }, + "slotElement": { + "type": "object", + "additionalProperties": false, + "required": ["id", "slotId", "type"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]{0,127}$", "description": "Stable lowercase element id, unique within the slide." }, + "slotId": { "type": "string", "minLength": 1, "maxLength": 80, "description": "A concrete slot id from the selected recipe, never a recipe descriptor such as kinds or required." }, + "type": { "type": "string", "enum": ["text", "shape", "line", "image", "svg", "chart", "table"] }, + "text": { "type": "string", "minLength": 1, "maxLength": 4000, "description": "Required for text and chart elements; optional for shape elements." }, + "shape": { "type": "string", "enum": ["rect", "roundRect", "ellipse"], "description": "Optional for shape elements." }, + "assetId": { "type": "string", "minLength": 1, "maxLength": 128, "description": "Required for image and svg elements; must reference a prepared asset." }, + "alt": { "type": "string", "minLength": 1, "maxLength": 500, "description": "Required for image and svg elements." }, + "data": { + "type": "array", + "minItems": 2, + "maxItems": 24, + "description": "Required for chart elements.", + "items": { "$ref": "#/$defs/chartPoint" } + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "description": "Required for table elements; every row must have the same number of cells.", + "items": { + "type": "array", + "minItems": 1, + "maxItems": 12, + "items": { "type": "string", "minLength": 1, "maxLength": 500 } + } + }, + "style": { "$ref": "#/$defs/slotStyle" } + } + }, + "chartPoint": { + "type": "object", + "additionalProperties": false, + "required": ["label", "value"], + "properties": { + "label": { "type": "string", "minLength": 1, "maxLength": 120 }, + "value": { "type": "number", "minimum": -1000000000000, "maximum": 1000000000000 } + } + }, + "slotStyle": { + "type": "object", + "additionalProperties": false, + "properties": { + "textRole": { "type": "string", "enum": ["cover", "title", "section", "body", "label", "metric", "caption", "source", "code"] }, + "colorToken": { "type": "string", "minLength": 1, "maxLength": 80 }, + "fillToken": { "type": "string", "minLength": 1, "maxLength": 80 }, + "strokeToken": { "type": "string", "minLength": 1, "maxLength": 80 }, + "radiusRole": { "type": "string", "enum": ["none", "small", "medium"] }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "align": { "type": "string", "enum": ["left", "center", "right"] }, + "valign": { "type": "string", "enum": ["top", "middle", "bottom"] }, + "strokeWidth": { "type": "number", "minimum": 0, "maximum": 24 }, + "dash": { "type": "string", "enum": ["solid", "dash", "dot"] }, + "padding": { "type": "number", "minimum": 0, "maximum": 100 } } } } @@ -37,6 +110,6 @@ "readonly": false, "permissions": {}, "timeoutMs": 120000, - "maxOutputBytes": 1600000, + "maxOutputBytes": 400000, "ui": { "card": { "kind": "appDefined", "family": "ppt-live", "template": "compact", "displayMode": "compact", "resultDisplayType": "detailed", "displayName": "Render Design Case", "icon": "GalleryHorizontal", "title": "Generate design proposal", "summary": { "preparing": "Preparing three real pages", "running": "Rendering Design Case", "completed": "Design Case ready for decision", "failed": "Design Case failed" }, "locales": { "zh-CN": { "displayName": "生成设计样稿", "title": "生成设计方案", "summary": { "preparing": "正在准备三张真实页面", "running": "正在渲染设计样稿", "completed": "设计样稿已可供判断", "failed": "设计样稿生成失败" }, "fields": [ { "label": "状态" }, { "label": "密度" }, { "label": "样稿页面" } ] } }, "fields": [ { "label": "Status", "resultPath": ["bridge", "output", "status"] }, { "label": "Density", "resultPath": ["bridge", "output", "density"] }, { "label": "Pages", "resultPath": ["bridge", "output", "sampleSlides"], "format": "json" } ] } } } diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/component.json b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/component.json index abf4f3d0..c8d6a7ca 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/component.json +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/component.json @@ -15,6 +15,7 @@ "description": "Produce one presentation from an atomic manuscript through Design Case approval, ordered page visuals, whole-deck review, and export.", "actions": [ "initializeWork", + "attachWorkObject", "inspect", "getDocument", "commitPresentationDocument", diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/manifest.json b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/manifest.json index 4c672034..242a98f6 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/manifest.json +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/manifest.json @@ -24,7 +24,7 @@ "title": "Sparo PPT Production Engine", "description": "Create and validate the current PPT Live presentation through the non-compatible manuscript-first protocol.", "category": "presentationEngine", - "actions": ["initializeWork", "inspect", "getDocument", "commitPresentationDocument", "commitPresentationManuscript", "commitSpeakerScript", "reviewPresentationManuscript", "setPresentationSystem", "getAsset", "renderDesignCase", "decideDesignCase", "prepareVisualAssets", "generateSlideVisual", "editVisual", "reviewDeck", "undo", "export"], + "actions": ["initializeWork", "attachWorkObject", "inspect", "getDocument", "commitPresentationDocument", "commitPresentationManuscript", "commitSpeakerScript", "reviewPresentationManuscript", "setPresentationSystem", "getAsset", "renderDesignCase", "decideDesignCase", "prepareVisualAssets", "generateSlideVisual", "editVisual", "reviewDeck", "undo", "export"], "streaming": false, "cancelable": true, "resumable": true, @@ -35,6 +35,7 @@ ], "actions": [ { "name": "initializeWork", "description": "Create one human-visible PPT folder for a newly created workspace Work.", "inputSchema": { "type": "object" }, "outputSchema": { "type": "object" }, "streaming": false, "cancelable": false, "resumable": true }, + { "name": "attachWorkObject", "description": "Bind a new Work runtime to an existing PPT Live WorkObject without copying content.", "inputSchema": { "type": "object" }, "outputSchema": { "type": "object" }, "streaming": false, "cancelable": false, "resumable": true }, { "name": "inspect", "description": "Return the current parsed Manuscript, revisions, Design Case, visual state, ReviewCoverage, and deterministic RuleViolations.", "inputSchema": { "type": "object" }, "outputSchema": { "type": "object" }, "streaming": false, "cancelable": false, "resumable": true }, { "name": "getDocument", "description": "Read the managed manuscript or speaker script for the Surface editor.", "inputSchema": { "type": "object" }, "outputSchema": { "type": "object" }, "streaming": false, "cancelable": false, "resumable": true }, { "name": "commitPresentationDocument", "description": "Validate a structured ManuscriptContract payload, serialize canonical Markdown, and atomically reset visual pages bound to the previous Manuscript.", "inputSchema": { "type": "object" }, "outputSchema": { "type": "object" }, "streaming": false, "cancelable": true, "resumable": true }, @@ -58,7 +59,7 @@ "description": "Produce a manuscript-first PPT Live deck and export its reviewed VisualDocument.", "capabilityId": "sparo.pptDeck", "action": "inspect", - "actions": ["initializeWork", "inspect", "getDocument", "commitPresentationDocument", "commitPresentationManuscript", "commitSpeakerScript", "reviewPresentationManuscript", "setPresentationSystem", "getAsset", "renderDesignCase", "decideDesignCase", "prepareVisualAssets", "generateSlideVisual", "editVisual", "reviewDeck", "undo", "export"], + "actions": ["initializeWork", "attachWorkObject", "inspect", "getDocument", "commitPresentationDocument", "commitPresentationManuscript", "commitSpeakerScript", "reviewPresentationManuscript", "setPresentationSystem", "getAsset", "renderDesignCase", "decideDesignCase", "prepareVisualAssets", "generateSlideVisual", "editVisual", "reviewDeck", "undo", "export"], "readonly": false, "needsPermissions": true, "inputSchema": { "type": "object" }, diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/engine.js b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/engine.js index db1c8752..b4ea2be4 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/engine.js +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/engine.js @@ -278,7 +278,12 @@ function safePresentationDirectoryName(value) { } function defaultPptIndex() { - return { schemaVersion: PPT_INDEX_SCHEMA_VERSION, works: {}, updatedAt: nowIso() }; + return { + schemaVersion: PPT_INDEX_SCHEMA_VERSION, + works: {}, + objects: {}, + updatedAt: nowIso(), + }; } function loadPptIndex(indexFile) { @@ -286,6 +291,10 @@ function loadPptIndex(indexFile) { if (!index || index.schemaVersion !== PPT_INDEX_SCHEMA_VERSION || typeof index.works !== "object" || Array.isArray(index.works)) { throw new Error("Workspace PPT index is invalid"); } + if (index.objects == null) index.objects = {}; + if (typeof index.objects !== "object" || Array.isArray(index.objects)) { + throw new Error("Workspace PPT WorkObject index is invalid"); + } return index; } @@ -302,13 +311,29 @@ function controlMatchesWork(controlFile, workId) { } } -function locatePresentationRoot(workspaceRoot, workId, indexFile) { +function controlMatchesWorkObject(controlFile, workObjectId) { + try { + const control = readControl(controlFile, null); + return Boolean( + control + && control.schemaVersion === CONTROL_SCHEMA_VERSION + && control.workObjectId === workObjectId + ); + } catch (_error) { + return false; + } +} + +function locatePresentationRoot(workspaceRoot, workId, workObjectId, indexFile) { const index = loadPptIndex(indexFile); - const indexed = index.works[workId]; + const indexed = workObjectId ? index.objects[workObjectId] : index.works[workId]; if (typeof indexed === "string" && indexed.trim()) { const candidate = path.resolve(workspaceRoot, indexed); const pptRoot = path.join(workspaceRoot, PPT_DIRECTORY); - if (isInside(pptRoot, candidate) && controlMatchesWork(path.join(candidate, ".ppt-live.json"), workId)) { + const matches = workObjectId + ? controlMatchesWorkObject(path.join(candidate, ".ppt-live.json"), workObjectId) + : controlMatchesWork(path.join(candidate, ".ppt-live.json"), workId); + if (isInside(pptRoot, candidate) && matches) { return candidate; } } @@ -318,8 +343,11 @@ function locatePresentationRoot(workspaceRoot, workId, indexFile) { for (const entry of fs.readdirSync(pptRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const candidate = path.join(pptRoot, entry.name); - if (controlMatchesWork(path.join(candidate, ".ppt-live.json"), workId)) return candidate; + const controlFile = path.join(candidate, ".ppt-live.json"); + if (workObjectId && controlMatchesWorkObject(controlFile, workObjectId)) return candidate; + if (!workObjectId && controlMatchesWork(controlFile, workId)) return candidate; } + if (workObjectId) return locatePresentationRoot(workspaceRoot, workId, null, indexFile); return null; } @@ -344,13 +372,17 @@ function projectPaths(trusted, options = {}) { } const workspaceRoot = path.resolve(trusted.workspacePath); const workId = safeId(trusted.workId, "work"); + const workObjectId = trusted.workObjectId + ? safeId(trusted.workObjectId, "object") + : null; const indexFile = path.join(workspaceRoot, ".sparo_os", "ppt-index.json"); - let root = locatePresentationRoot(workspaceRoot, workId, indexFile); + let root = locatePresentationRoot(workspaceRoot, workId, workObjectId, indexFile); if (!root && options.create === true) { root = uniquePresentationRoot(workspaceRoot, options.title || trusted.workTitle); } if (!root) { - const error = new Error(`PPT Live presentation '${workId}' is not initialized in this workspace`); + const identity = workObjectId || workId; + const error = new Error(`PPT Live presentation '${identity}' is not initialized in this workspace`); error.code = "ppt_work_not_initialized"; throw error; } @@ -358,9 +390,25 @@ function projectPaths(trusted, options = {}) { throw new Error("Resolved PPT Live project escaped the trusted workspace root"); } const controlFile = path.join(root, ".ppt-live.json"); + if (workObjectId) { + const control = readControl(controlFile, null); + if (control?.workObjectId && control.workObjectId !== workObjectId) { + throw new Error("Resolved PPT Live presentation belongs to another WorkObject"); + } + if (!control?.workObjectId && control?.workId === workId) { + control.workObjectId = workObjectId; + control.updatedAt = nowIso(); + writeControl(controlFile, control); + const index = loadPptIndex(indexFile); + index.objects[workObjectId] = relativeWorkspacePath(workspaceRoot, root); + index.updatedAt = nowIso(); + writeJson(indexFile, index); + } + } return { workspaceRoot, workId, + workObjectId, root, indexFile, controlFile, @@ -441,8 +489,14 @@ function initialDeck(deckId) { function syncPptIndex(paths) { const index = loadPptIndex(paths.indexFile); + const control = readControl(paths.controlFile, null); + if (!control || control.schemaVersion !== CONTROL_SCHEMA_VERSION || control.appId !== APP_ID) { + throw new Error("PPT Live control file is missing or invalid"); + } index.schemaVersion = PPT_INDEX_SCHEMA_VERSION; - index.works[paths.workId] = relativeWorkspacePath(paths.workspaceRoot, paths.root); + const relativeRoot = relativeWorkspacePath(paths.workspaceRoot, paths.root); + if (control.workId) index.works[control.workId] = relativeRoot; + if (control.workObjectId) index.objects[control.workObjectId] = relativeRoot; index.updatedAt = nowIso(); writeJson(paths.indexFile, index); } @@ -501,7 +555,21 @@ function bindControlSession(paths, sessionId) { function initializeProject(paths, input = {}, trusted = {}) { if (fs.existsSync(paths.controlFile)) { - if (!controlMatchesWork(paths.controlFile, paths.workId)) { + const control = readControl(paths.controlFile, null); + if (paths.workObjectId) { + if (control?.workObjectId && control.workObjectId !== paths.workObjectId) { + throw new Error("PPT Live presentation directory belongs to another WorkObject"); + } + if (!control?.workObjectId) { + if (control?.workId !== paths.workId) { + throw new Error("PPT Live presentation directory belongs to another Work"); + } + control.workObjectId = paths.workObjectId; + control.updatedAt = nowIso(); + writeControl(paths.controlFile, control); + syncPptIndex(paths); + } + } else if (!controlMatchesWork(paths.controlFile, paths.workId)) { throw new Error("PPT Live presentation directory belongs to another Work"); } ensureProject(paths); @@ -528,6 +596,7 @@ function initializeProject(paths, input = {}, trusted = {}) { appId: APP_ID, appVersion: APP_VERSION, workId: paths.workId, + workObjectId: paths.workObjectId, deckId, title, revision: 0, @@ -569,10 +638,66 @@ function initializeProject(paths, input = {}, trusted = {}) { syncPptIndex(paths); } +function attachWorkObject(input = {}, trusted = {}) { + if (!trusted.workspacePath || typeof trusted.workspacePath !== "string") { + throw new Error("PPT Live requires a host-bound workspace root"); + } + if (!trusted.workId || typeof trusted.workId !== "string") { + throw new Error("PPT Live requires a host-bound Work id"); + } + if (!trusted.workObjectId || typeof trusted.workObjectId !== "string") { + throw new Error("PPT Live requires a host-bound WorkObject id to attach existing content"); + } + const sourceWorkId = safeId(requiredText(input.sourceWorkId, "sourceWorkId", 128)); + const targetWorkId = safeId(trusted.workId, "work"); + const workObjectId = safeId(trusted.workObjectId, "object"); + if (sourceWorkId === targetWorkId) { + throw new Error("PPT Live requires a different source Work when attaching a WorkObject"); + } + + const workspaceRoot = path.resolve(trusted.workspacePath); + const indexFile = path.join(workspaceRoot, ".sparo_os", "ppt-index.json"); + const existingRoot = locatePresentationRoot( + workspaceRoot, + "__unbound_work__", + workObjectId, + indexFile, + ); + if (existingRoot) { + const paths = projectPaths(trusted); + ensureProject(paths); + bindControlSession(paths, trusted.sessionId); + return inspect(paths, input); + } + + const sourcePaths = projectPaths({ ...trusted, workId: sourceWorkId, workObjectId: null }); + ensureProject(sourcePaths); + const control = readControl(sourcePaths.controlFile, null); + if (!control || control.schemaVersion !== CONTROL_SCHEMA_VERSION || control.appId !== APP_ID) { + throw new Error("PPT Live source presentation state is missing or invalid"); + } + if (control.workObjectId && control.workObjectId !== workObjectId) { + throw new Error("PPT Live source presentation belongs to another WorkObject"); + } + control.workObjectId = workObjectId; + control.updatedAt = nowIso(); + writeControl(sourcePaths.controlFile, control); + syncPptIndex({ ...sourcePaths, workObjectId }); + + const paths = projectPaths(trusted); + ensureProject(paths); + bindControlSession(paths, trusted.sessionId); + return inspect(paths, input); +} + function ensureProject(paths) { const control = readControl(paths.controlFile, null); - if (!control || control.schemaVersion !== CONTROL_SCHEMA_VERSION || control.appId !== APP_ID || control.workId !== paths.workId) { - const error = new Error(`PPT Live presentation '${paths.workId}' is missing or invalid`); + const identityMatches = paths.workObjectId + ? control?.workObjectId === paths.workObjectId + : control?.workId === paths.workId; + if (!control || control.schemaVersion !== CONTROL_SCHEMA_VERSION || control.appId !== APP_ID || !identityMatches) { + const identity = paths.workObjectId || paths.workId; + const error = new Error(`PPT Live presentation '${identity}' is missing or invalid`); error.code = "ppt_work_state_missing"; throw error; } @@ -643,6 +768,17 @@ function assertExpectedRevision(actual, expected, subject) { } } +function assertBaselineRevision(actual, expected, subject) { + const parsed = Number(expected); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${subject} expectedRevision must be a non-negative integer`); + } + if (parsed > Number(actual)) { + throw new Error(`${subject} revision conflict: expected ${parsed}, current ${actual}`); + } + return parsed; +} + function boundedNumber(value, fallback, min, max) { const number = Number(value); return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; @@ -1022,6 +1158,18 @@ function contrastRatio(left, right) { return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } +function diagnosticId(rule) { + const identity = { + level: String(rule?.level || ""), + code: String(rule?.code || ""), + artifact: String(rule?.artifact || ""), + message: String(rule?.message || ""), + slideIds: [...new Set((rule?.slideIds || []).map(String))].sort(), + elementIds: [...new Set((rule?.elementIds || []).map(String))].sort(), + }; + return `diagnostic-${sha256(JSON.stringify(identity)).slice(0, 20)}`; +} + function ruleViolationsFor(deck, manuscriptState, presentationSystemState, manuscriptContent, speakerScript, assets, visualDocument = null) { const rules = []; const presentationSystem = presentationSystemContent(presentationSystemState); @@ -1275,6 +1423,39 @@ function manuscriptSlideHash(manuscript, index) { return section ? sha256(`${section.position}:${section.title}:${section.content}`) : ""; } +function compactDesignCase(designCase) { + if (!designCase || typeof designCase !== "object") return designCase; + return { + ...designCase, + sampleSlides: (designCase.sampleSlides || []).map((sample) => { + const { renderTree, ...metadata } = sample; + return { + ...metadata, + nodeCount: Array.isArray(renderTree?.nodes) ? renderTree.nodes.length : 0, + }; + }), + }; +} + +function compactCommittedSlide(slide) { + return { + id: slide.id, + title: slide.title, + claim: slide.claim, + pageRole: slide.pageRole, + recipeId: slide.recipeId, + visualMode: slide.visualMode, + revision: slide.revision, + status: slide.status, + sourceRevision: slide.sourceRevision, + visualRevision: slide.visualRevision, + userOverrideCount: slide.userOverrideCount, + visualConflicts: slide.visualConflicts, + nodeCount: Array.isArray(slide.renderTree?.nodes) ? slide.renderTree.nodes.length : 0, + updatedAt: slide.updatedAt, + }; +} + function inspect(paths, input = {}) { ensureProject(paths); const project = syncDocumentMetadata(paths); @@ -1415,6 +1596,7 @@ function inspect(paths, input = {}) { if (input.audience !== "agent") return snapshot; return { ...snapshot, + designCase: compactDesignCase(snapshot.designCase), deck: { ...snapshot.deck, slides: snapshot.deck.slides.map((slide) => { @@ -1865,13 +2047,29 @@ function pushHistory(paths, kind, state, intent) { } function commitDeck(paths, current, next, intent) { - pushHistory(paths, "visualDeck", current, intent); next.revision = Number(current.revision || 0) + 1; next.updatedAt = nowIso(); next.schemaVersion = DECK_SCHEMA_VERSION; next.lastIntent = text(intent, "Visual presentation change", 500); - writeJson(paths.deck, next); - return inspect(paths); + const controlBeforeCommit = readControl(paths.controlFile, null); + if (!controlBeforeCommit) throw new Error("PPT Live control file is missing or invalid"); + try { + pushHistory(paths, "visualDeck", current, intent); + writeJson(paths.deck, next); + return inspect(paths); + } catch (error) { + try { + writeControl(paths.controlFile, controlBeforeCommit); + } catch (rollbackError) { + const failure = new Error( + `VisualDeck commit failed and rollback could not restore the previous state: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + failure.code = "ppt_commit_rollback_failed"; + failure.cause = error; + throw failure; + } + throw error; + } } function setPresentationSystem(paths, input) { @@ -2011,7 +2209,7 @@ async function renderDesignCase(paths, input) { updatedAt: nowIso(), }; writeJson(paths.designCase, designCase); - return designCase; + return compactDesignCase(designCase); } function decideDesignCase(paths, input) { @@ -2082,7 +2280,7 @@ function commitSingleSlide(paths, input) { const manuscript = fs.readFileSync(paths.manuscript, "utf8"); const speakerScript = fs.readFileSync(paths.speakerScript, "utf8"); const manuscriptState = buildManuscriptState(manuscript, speakerScript, project); - assertExpectedRevision(current.revision, input.expectedDeckRevision, "VisualDeck"); + const baselineDeckRevision = assertBaselineRevision(current.revision, input.expectedDeckRevision, "VisualDeck"); assertExpectedRevision(presentationSystem.revision, input.expectedSystemRevision, "PresentationSystem"); assertExpectedRevision(manuscriptState.revision, input.expectedManuscriptRevision, "Manuscript"); assertExpectedRevision(designCase.revision, input.expectedDesignCaseRevision, "DesignCase"); @@ -2136,8 +2334,10 @@ function commitSingleSlide(paths, input) { slideId, slideRevision: slide.revision, recipeId: slide.recipeId, + deckRevisionBeforeCommit: current.revision, + rebasedFromDeckRevision: baselineDeckRevision < current.revision ? baselineDeckRevision : null, }]), - slide: committed, + slide: compactCommittedSlide(committed), }; } @@ -2785,6 +2985,9 @@ async function exportDeck(paths, input) { } async function dispatch(action, input = {}, trusted = {}) { + if (action === "attachWorkObject") { + return attachWorkObject(input, trusted); + } const paths = projectPaths(trusted, { create: action === "initializeWork", title: input.title || trusted.workTitle, diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/slide-compiler.js b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/slide-compiler.js index 2a0316f3..3aadebee 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/slide-compiler.js +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/slide-compiler.js @@ -44,6 +44,17 @@ function requiredNumber(value, name, min, max) { return value; } +function compositionContractError(violations) { + const error = new Error( + `composition is invalid (${violations.length} issue${violations.length === 1 ? "" : "s"}): ` + + violations.map((violation) => `${violation.path}: ${violation.message}`).join("; "), + ); + error.code = "ppt_composition_invalid"; + error.contractVersion = 1; + error.violations = violations; + return error; +} + function normalizeSlotStyle(value, name, presentationSystem) { if (value === undefined) return {}; const style = objectValue(value, name); @@ -154,18 +165,54 @@ function normalizeComposition(value, recipeId, pageRole, designPackage, presenta if (!Array.isArray(composition.slots) || composition.slots.length < 1 || composition.slots.length > 40) { throw new Error("composition.slots must contain between 1 and 40 items"); } - const slots = composition.slots.map((item, index) => normalizeSlotItem(item, index, recipe, presentationSystem, assets)); - if (new Set(slots.map((item) => item.id)).size !== slots.length) throw new Error("composition.slots contains duplicate item ids"); + const slots = []; + const violations = []; + for (let index = 0; index < composition.slots.length; index += 1) { + try { + slots.push(normalizeSlotItem(composition.slots[index], index, recipe, presentationSystem, assets)); + } catch (error) { + violations.push({ + code: "invalid_slot_element", + path: `composition.slots[${index}]`, + message: error instanceof Error ? error.message : String(error), + }); + } + } + const duplicateIds = [...new Set(slots.map((item) => item.id).filter((id, index, ids) => ids.indexOf(id) !== index))]; + for (const id of duplicateIds) { + violations.push({ + code: "duplicate_element_id", + path: "composition.slots", + message: `Element id '${id}' is duplicated`, + }); + } for (const slot of recipe.slots) { const count = slots.filter((item) => item.slotId === slot.id).length; - if (slot.required && count === 0) throw new Error(`Recipe '${recipe.id}' requires slot '${slot.id}'`); - if (!slot.repeatable && count > 1) throw new Error(`Recipe '${recipe.id}' slot '${slot.id}' accepts one item`); + if (slot.required && count === 0) { + violations.push({ + code: "required_recipe_slot_missing", + path: "composition.slots", + message: `Recipe '${recipe.id}' requires slot '${slot.id}'`, + }); + } + if (!slot.repeatable && count > 1) { + violations.push({ + code: "recipe_slot_not_repeatable", + path: "composition.slots", + message: `Recipe '${recipe.id}' slot '${slot.id}' accepts one item`, + }); + } if (slot.repeatable && count > 0) { if (count < Number(slot.minItems || 1) || count > Number(slot.maxItems || 12)) { - throw new Error(`Recipe '${recipe.id}' slot '${slot.id}' requires ${slot.minItems || 1}-${slot.maxItems || 12} items`); + violations.push({ + code: "recipe_slot_cardinality_invalid", + path: "composition.slots", + message: `Recipe '${recipe.id}' slot '${slot.id}' requires ${slot.minItems || 1}-${slot.maxItems || 12} items`, + }); } } } + if (violations.length) throw compositionContractError(violations); return { slots }; } diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/vendor/ppt-export.bundle.mjs b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/vendor/ppt-export.bundle.mjs index dc77014e..e285ee6b 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/vendor/ppt-export.bundle.mjs +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/src/vendor/ppt-export.bundle.mjs @@ -7936,13 +7936,13 @@ export { jszip/dist/jszip.min.js: (*! - + JSZip v3.10.1 - A JavaScript class for generating and reading zip files - + (c) 2009-2016 Stuart Knightley Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. - + JSZip uses the library pako released under the MIT license : https://github.com/nodeca/pako/blob/main/LICENSE *) diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/test/engine.test.js b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/test/engine.test.js index f66a6ef8..08d852e6 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/test/engine.test.js +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/test/engine.test.js @@ -218,6 +218,47 @@ test("initializeWork creates one human-readable presentation directory", async ( assert.equal(fs.existsSync(path.join(presentationRoot, "visual")), false); }); +test("attachWorkObject lets two Works operate on the same presentation", async () => { + const root = await workspace(); + const source = await dispatch("inspect", {}, trusted(root)); + const targetTrusted = { + ...trusted(root), + workId: "work-2", + workObjectId: "object-deck-1", + sessionId: "session-2", + }; + + const attached = await dispatch("attachWorkObject", { + sourceWorkId: "work-1", + }, targetTrusted); + + assert.equal(attached.deck.deckId, source.deck.deckId); + assert.deepEqual(attached.deck.slides, source.deck.slides); + assert.equal(attached.project.root, source.project.root); + const presentationDirectories = fs.readdirSync(path.join(root, "PPT"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()); + assert.equal(presentationDirectories.length, 1); + const control = JSON.parse(fs.readFileSync(path.join( + source.project.root, + ".ppt-live.json", + ), "utf8")); + assert.equal(control.workId, "work-1"); + assert.equal(control.workObjectId, "object-deck-1"); + assert.ok(control.sessionRefs.includes("session-2")); + + await dispatch("commitPresentationDocument", { + expectedManuscriptRevision: attached.documents.manuscript.revision, + expectedSpeakerScriptRevision: attached.documents.speakerScript.revision, + manuscript: structuredManuscript(), + speakerScript: structuredSpeakerScript(), + intent: "Continue editing the shared presentation from another Work", + }, targetTrusted); + const changedSource = await dispatch("inspect", {}, trusted(root)); + const changedTarget = await dispatch("inspect", {}, targetTrusted); + assert.equal(changedSource.manuscript.revision, source.manuscript.revision + 1); + assert.equal(changedTarget.manuscript.revision, changedSource.manuscript.revision); +}); + test("inspect exposes the parsed Manuscript without a public Blueprint or hidden skill requirements", async () => { const root = await workspace(); const result = await dispatch("inspect", {}, trusted(root)); @@ -321,6 +362,12 @@ test("text-only AI cannot decide the Design Case but an explicit user can", asyn }, trusted(root)); assert.equal(designCase.sampleSlides.length, 3); assert.ok(designCase.sampleSlides.every((item) => fs.existsSync(item.previewRef))); + assert.ok(designCase.sampleSlides.every((item) => !("renderTree" in item))); + assert.ok(designCase.sampleSlides.every((item) => item.nodeCount > 0)); + const agentSnapshot = await dispatch("inspect", { audience: "agent" }, trusted(root)); + assert.ok(agentSnapshot.designCase.sampleSlides.every((item) => !("renderTree" in item))); + const surfaceSnapshot = await dispatch("inspect", {}, trusted(root)); + assert.ok(surfaceSnapshot.designCase.sampleSlides.every((item) => item.renderTree?.nodes?.length > 0)); await assert.rejects(() => dispatch("decideDesignCase", { caseId: designCase.caseId, decision: "approved", @@ -336,6 +383,150 @@ test("text-only AI cannot decide the Design Case but an explicit user can", asyn assert.equal(approved.status, "approved"); }); +test("composition validation reports all malformed slot payloads together", async () => { + const root = await workspace(); + await commitAndReviewManuscript(root); + const snapshot = await dispatch("inspect", {}, trusted(root)); + const invalid = slide(0, { + composition: { + slots: [ + { slotId: "title", kinds: ["text"], required: true }, + { slotId: "focal", kinds: ["text", "chart"], required: true }, + { slotId: "support", kinds: ["text"] }, + ], + }, + }); + await assert.rejects( + () => dispatch("renderDesignCase", { + expectedManuscriptRevision: snapshot.manuscript.revision, + expectedSystemRevision: snapshot.presentationSystem.revision, + slides: [invalid, slide(1), slide(2)], + }, trusted(root)), + (error) => { + assert.equal(error.code, "ppt_composition_invalid"); + assert.equal(error.contractVersion, 1); + assert.ok(error.violations.filter((item) => item.code === "invalid_slot_element").length >= 3); + assert.ok(error.violations.some((item) => item.code === "required_recipe_slot_missing")); + return true; + }, + ); +}); + +test("independent page commits safely rebase a shared deck revision", async () => { + const root = await workspace(); + await commitAndReviewManuscript(root); + await approveDesignCase(root); + const snapshot = await dispatch("inspect", {}, trusted(root)); + const common = { + expectedDeckRevision: snapshot.deck.revision, + expectedSlideRevision: 0, + expectedSystemRevision: snapshot.presentationSystem.revision, + expectedManuscriptRevision: snapshot.manuscript.revision, + expectedDesignCaseRevision: snapshot.designCase.revision, + }; + const first = await dispatch("generateSlideVisual", { + ...common, + slide: slide(0), + intent: "Generate the first page from the shared baseline", + }, trusted(root)); + const second = await dispatch("generateSlideVisual", { + ...common, + slide: slide(1), + intent: "Generate the second page from the shared baseline", + }, trusted(root)); + assert.equal(first.events[0].rebasedFromDeckRevision, null); + assert.equal(second.events[0].rebasedFromDeckRevision, snapshot.deck.revision); + assert.equal(second.events[0].deckRevisionBeforeCommit, first.deck.revision); + assert.equal("renderTree" in second.slide, false); + assert.equal("composition" in second.slide, false); + assert.ok(second.slide.nodeCount > 0); + assert.equal(second.deck.revision, snapshot.deck.revision + 2); + assert.equal(second.deck.slideCount, 2); +}); + +test("rule violations have stable diagnostic ids and do not fail a committed page", async () => { + const root = await workspace(); + await commitAndReviewManuscript(root); + await approveDesignCase(root); + const snapshot = await dispatch("inspect", {}, trusted(root)); + const evidenceSlide = slide(0, { + pageRole: "evidence", + recipeId: "evidence-split", + visualMode: "chart", + evidenceObject: "Activation evidence", + exportStrategy: "native-chart", + sourceNote: "", + composition: { + slots: [ + { id: "evidence-title", slotId: "title", type: "text", text: PAGES[0].title }, + { + id: "evidence-chart", + slotId: "evidence", + type: "chart", + text: "Activation trend", + data: [{ label: "Previous", value: 100 }, { label: "Current", value: 86 }], + }, + { id: "evidence-source", slotId: "source", type: "text", text: "Source shown in the visual only" }, + ], + }, + }); + const result = await dispatch("generateSlideVisual", { + expectedDeckRevision: snapshot.deck.revision, + expectedSlideRevision: 0, + expectedSystemRevision: snapshot.presentationSystem.revision, + expectedManuscriptRevision: snapshot.manuscript.revision, + expectedDesignCaseRevision: snapshot.designCase.revision, + slide: evidenceSlide, + intent: "Commit an evidence page with a deterministic source diagnostic", + }, trusted(root)); + const violation = result.ruleViolations.find((item) => item.code === "evidence_source_missing"); + assert.match(violation.id, /^diagnostic-[a-f0-9]{20}$/); + const inspectedAgain = await dispatch("inspect", {}, trusted(root)); + assert.equal( + inspectedAgain.ruleViolations.find((item) => item.code === "evidence_source_missing").id, + violation.id, + ); +}); + +test("a post-write inspection failure rolls back deck, visual document, and history", async () => { + const root = await workspace(); + await commitAndReviewManuscript(root); + await approveDesignCase(root); + const before = await dispatch("inspect", {}, trusted(root)); + const controlFile = path.join(before.project.root, ".ppt-live.json"); + const originalRename = fs.renameSync; + let injected = false; + fs.renameSync = function renameWithInjectedFailure(source, destination) { + if (!injected && path.resolve(destination) === path.resolve(controlFile)) { + const candidate = JSON.parse(fs.readFileSync(source, "utf8")); + if (candidate.state?.deck?.slides?.length === 1 && candidate.state?.visualDocument?.pages?.length === 1) { + injected = true; + throw new Error("Injected post-write inspection failure"); + } + } + return originalRename.call(fs, source, destination); + }; + try { + await assert.rejects(() => dispatch("generateSlideVisual", { + expectedDeckRevision: before.deck.revision, + expectedSlideRevision: 0, + expectedSystemRevision: before.presentationSystem.revision, + expectedManuscriptRevision: before.manuscript.revision, + expectedDesignCaseRevision: before.designCase.revision, + slide: slide(0), + intent: "Exercise transactional rollback", + }, trusted(root)), /Injected post-write inspection failure/); + } finally { + fs.renameSync = originalRename; + } + assert.equal(injected, true); + const after = await dispatch("inspect", {}, trusted(root)); + assert.equal(after.deck.revision, before.deck.revision); + assert.equal(after.deck.slideCount, 0); + assert.equal(after.visualDocument.pageCount, 0); + assert.deepEqual(after.history, before.history); +}); + test("page visuals are generated in manuscript order without per-page aesthetic review", async () => { const root = await workspace(); await commitAndReviewManuscript(root); diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/test/worker-protocol.test.js b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/test/worker-protocol.test.js index a1ba81f6..cfeecba3 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/test/worker-protocol.test.js +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/test/worker-protocol.test.js @@ -10,6 +10,7 @@ test("bridge declarations expose the structured manuscript action", () => { const component = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "component.json"), "utf8")); assert.ok(sourceManifest.capabilities[0].actions.includes("commitPresentationDocument")); assert.ok(sourceManifest.capabilities[0].actions.includes("initializeWork")); + assert.ok(sourceManifest.capabilities[0].actions.includes("attachWorkObject")); assert.ok(sourceManifest.actions.some((action) => action.name === "commitPresentationDocument")); assert.ok(sourceManifest.tools[0].actions.includes("commitPresentationDocument")); assert.ok(component.capabilities[0].actions.includes("commitPresentationDocument")); @@ -26,6 +27,7 @@ test("worker emits the standard completed bridge envelope", () => { consumer: { kind: "agentComponent", workId: "work-1", + workObjectId: "object-deck-1", workTitle: "Worker protocol presentation", runtimeInstanceId: "runtime-1", sessionId: "session-1", @@ -44,6 +46,13 @@ test("worker emits the standard completed bridge envelope", () => { assert.equal(events[2].event.type, "run.started"); assert.equal(events[3].event.type, "run.completed"); assert.equal(events[3].event.output.deck.revision, 0); + const control = JSON.parse(fs.readFileSync(path.join( + root, + "PPT", + "Worker protocol presentation", + ".ppt-live.json", + ), "utf8")); + assert.equal(control.workObjectId, "object-deck-1"); }); test("worker preserves aggregate manuscript contract diagnostics in the failed envelope", () => { diff --git a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/worker.js b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/worker.js index e70ab955..437e8194 100644 --- a/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/worker.js +++ b/bundles/product-apps/builtin-ppt-live/151.0.10/components/bridges/builtin-ppt-runtime/source/worker.js @@ -13,6 +13,7 @@ async function handleRequest(request) { const trusted = { workspacePath: request.workspacePath || request.workspace_path || null, workId: consumer.workId || consumer.work_id || null, + workObjectId: consumer.workObjectId || consumer.work_object_id || null, workTitle: consumer.workTitle || consumer.work_title || null, runtimeInstanceId: consumer.runtimeInstanceId || consumer.runtime_instance_id || null, sessionId: consumer.sessionId || consumer.session_id || null, 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 deleted file mode 100644 index 9de4934f..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/app.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "id": "builtin-remotion-live", - "version": "19.0.0", - "name": "Remotion Live", - "description": "A Remotion video workbench for previewing, adjusting, fixing, and exporting motion projects.", - "authors": [ - { - "name": "GCWing", - "url": "https://github.com/GCWing" - } - ], - "i18n": { - "locales": { - "en-US": { - "name": "Remotion Live", - "description": "A Remotion video workbench for previewing, adjusting, fixing, and exporting motion projects.", - "tags": [ - "built-in", - "remotion", - "video", - "render" - ] - }, - "zh-CN": { - "name": "Remotion Live", - "description": "面向 Remotion 视频项目的创作工作台,用来预览、调整、修复和导出动态作品。", - "tags": [ - "内置", - "remotion", - "视频", - "渲染" - ] - } - } - }, - "interactionModel": "interactiveWorkspace", - "workMultiplicity": "multiple", - "workObjectKinds": [ - { - "id": "composition", - "label": "Composition", - "scope": "runtime", - "identitySchema": { - "type": "object", - "required": [ - "compositionId" - ], - "properties": { - "compositionId": { - "type": "string" - }, - "entryPoint": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "frame": { - "type": "number" - }, - "durationInFrames": { - "type": "number" - }, - "fps": { - "type": "number" - } - } - } - }, - { - "id": "preview-context", - "label": "Preview Context", - "scope": "runtime", - "identitySchema": { - "type": "object", - "required": [ - "workspacePath" - ], - "properties": { - "workspacePath": { - "type": "string" - }, - "previewHostId": { - "type": "string" - } - } - }, - "contextSchema": { - "type": "object", - "properties": { - "playerHost": { - "type": "object" - }, - "selectedLayer": { - "type": "object" - }, - "diagnostics": { - "type": "array" - } - } - } - } - ], - "truthSource": "runtimeFact", - "primarySurface": { - "componentId": "builtin-remotion-live-surface", - "surfaceId": "primary" - }, - "primarySurfaceMode": "sidecarLinked", - "components": [ - { - "componentId": "builtin-remotion-live-surface", - "kind": "surface", - "source": "private", - "role": "primarySurface", - "capabilities": [ - "interactiveSurface" - ], - "usesCapabilities": [ - "session.read", - "component.run" - ] - }, - { - "componentId": "remotion-video-agent", - "kind": "agent", - "source": "private", - "role": "assistant", - "capabilities": [ - "understandVideoProject", - "reviseVideoComposition", - "renderReviewArtifact" - ], - "usesCapabilities": [ - "filesystem.read", - "filesystem.write", - "terminal.run", - "process.spawn", - "workspace.inspect", - "workspace.modify", - "session.read", - "model.invoke", - "tool.invoke", - "component.run" - ] - }, - { - "componentId": "builtin-remotion-runtime", - "kind": "bridge", - "source": "private", - "role": "runtime", - "capabilities": [ - "sparo.videoEngine" - ], - "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": true, - "shell": true, - "gui": false, - "secrets": false, - "ai": true - }, - "installScope": "system", - "catalogVisibility": "discoverable", - "enabled": true, - "icon": { - "kind": "packageAsset", - "path": "assets/icon.png" - }, - "category": "creative", - "tags": [ - "built-in", - "remotion", - "video", - "render" - ], - "launch": { - "kind": "applicationSurface", - "targetId": "builtin-remotion-live", - "scopeRequirement": "workspaceRequired", - "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": "userManaged", - "deletion": "deleteOnUserRequest", - "migration": "exportImport", - "share": "declaredWorkObjectsOnly" - } -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/assets/icon.png b/bundles/product-apps/builtin-remotion-live/19.0.0/assets/icon.png deleted file mode 100644 index 5966327c..00000000 Binary files a/bundles/product-apps/builtin-remotion-live/19.0.0/assets/icon.png and /dev/null differ 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 deleted file mode 100644 index 7ea934b0..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/compatibility.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "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 deleted file mode 100644 index cbba0a51..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/component.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "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 managing exports through the Sparo Video Engine.", - "packageSource": "appPrivate", - "ownerApp": { - "appId": "builtin-remotion-live", - "appVersion": "19.0.0" - }, - "capabilities": [ - { - "id": "understandVideoProject", - "title": "Understand video project", - "description": "Detect the Remotion project, read composition structure, and identify the likely source files to change.", - "actions": [ - "detectProject", - "getCompositionManifest" - ] - }, - { - "id": "reviseVideoComposition", - "title": "Revise video composition", - "description": "Make scoped React and Remotion source edits while preserving preview semantics.", - "actions": [ - "getFrameDescriptor", - "refreshPreview" - ] - }, - { - "id": "renderReviewArtifact", - "title": "Render review artifact", - "description": "Render stills and start, monitor, or cancel exports through the workspace Remotion toolchain.", - "actions": [ - "renderStill", - "startExport", - "getExportStatus", - "cancelExport" - ] - } - ], - "permissions": [ - { - "kind": "ai", - "summary": "Uses configured AI providers through Sparo OS.", - "scopes": [ - "system", - "workspace" - ] - }, - { - "kind": "fs", - "summary": "Reads and edits workspace Remotion source files through declared tools.", - "scopes": [ - "{workspace}" - ] - }, - { - "kind": "shell", - "summary": "Uses workspace package managers and Remotion commands through declared tools.", - "scopes": [ - "node", - "npm", - "npx", - "pnpm", - "yarn", - "bunx", - "remotion" - ] - } - ], - "usedByApps": [ - "builtin-remotion-live" - ], - "visibility": "appDependency", - "dependencies": [ - { - "componentId": "builtin-remotion-runtime", - "kind": "bridge", - "source": "private", - "role": "videoEngine", - "capabilities": [ - "sparo.videoEngine" - ] - } - ], - "implementationRef": "app://builtin-remotion-live@19.0.0/agents/remotion-video-agent", - "usesCapabilities": [ - "filesystem.read", - "filesystem.write", - "terminal.run", - "process.spawn", - "workspace.inspect", - "workspace.modify", - "session.read", - "model.invoke", - "tool.invoke", - "component.run" - ] -} 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 deleted file mode 100644 index 4662e5b9..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/agent.md +++ /dev/null @@ -1,49 +0,0 @@ -You are Remotion Video Agent, a Sparo OS Agent Component specialized in React-based video development with Remotion. - -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 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_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, 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. -- Favor explicit composition dimensions, durationInFrames, fps, and typed props. -- Avoid browser-only nondeterminism during rendering. Do not depend on current time, random values, network-only assets, or viewport state unless they are made deterministic. -- Use staticFile or project-managed assets for renderable media. -- Keep side effects out of render paths. -- Preserve accessibility of text and contrast where the user-facing video contains readable copy. - -Failure behavior: -- If the workspace is not a Remotion project, say so and name the missing signal. -- If a selected preview element cannot be mapped to source with confidence, explain the uncertainty and inspect nearby composition files before editing. -- If preview/render/export fails, report the concrete error from the Remotion runtime and make one reasoned fix rather than repeating the same failing action. - -Skill routing: -- A Skill Library and a Routing guide are appended below. Do not load all of it at once. -- Pick the smallest relevant skill for the current task: remotion-fundamentals to understand structure, composition-architecture to add or restructure compositions, motion-timing for rhythm and animation, visual-design for layout and hierarchy, media-assets for media, selected-context when the user points at the preview, and validation-export to verify or export. -- Follow the Routing guide to decide when to use this agent and which skill applies, then act through your tools. 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 deleted file mode 100644 index 8b568606..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/bundle.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "schemaVersion": 1, - "id": "remotion-video-agent", - "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 deleted file mode 100644 index 67275f3c..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/evals/cases.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "name": "selected_text_revision", - "intent": "Revise selected title text in the active Remotion Live preview.", - "expectedTools": [ - "agentcomponent__remotion-video-agent__get_frame_descriptor", - "Grep", - "Read", - "Edit" - ] - }, - { - "name": "preview_refresh_after_edit", - "intent": "Refresh the embedded preview after a scoped composition edit.", - "expectedTools": [ - "GetFileDiff", - "agentcomponent__remotion-video-agent__refresh_preview" - ] - }, - { - "name": "render_keyframe", - "intent": "Render a still for the current composition frame.", - "expectedTools": [ - "agentcomponent__remotion-video-agent__render_still" - ] - } -] 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 deleted file mode 100644 index ea212487..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/examples.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "title": "Refine the selected title", - "prompt": "In the currently open Remotion Live preview, adjust the selected title so it reads more clearly without changing the scene timing." - }, - { - "title": "Add motion polish", - "prompt": "Make the current composition feel more polished with subtle entrance motion and verify the active frame still looks balanced." - }, - { - "title": "Prepare an export", - "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 deleted file mode 100644 index 88dd55cc..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/manifest.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "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 committed preview context, rendering stills, and managing exports through the Sparo Video Engine.", - "icon": "film", - "category": "video", - "tags": [ - "remotion", - "video", - "react", - "preview" - ], - "level": "user", - "model": "primary", - "readonly": false, - "enabled": true, - "tools": [ - "LS", - "Glob", - "Grep", - "Read", - "Edit", - "Write", - "GetFileDiff", - "Bash", - "agentcomponent__remotion-video-agent__detect_project", - "agentcomponent__remotion-video-agent__get_composition_manifest", - "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" - ], - "skills": [ - "remotion-fundamentals", - "video-development-workflow", - "composition-architecture", - "motion-timing", - "visual-design", - "media-assets", - "selected-context", - "validation-export" - ], - "subagents": [], - "toolPolicies": {}, - "bridgeCapabilities": [ - { - "bridgeId": "builtin-remotion-runtime", - "capabilityId": "sparo.videoEngine", - "alias": "videoEngine", - "mode": "auto" - } - ], - "serviceActions": [ - { - "name": "understandVideoProject", - "description": "Detect the Remotion project, read composition structure, and identify the likely source files to change.", - "toolPolicy": [ - "agentcomponent__remotion-video-agent__detect_project", - "agentcomponent__remotion-video-agent__get_composition_manifest", - "Glob", - "Grep", - "Read" - ], - "bridgeCall": { - "bridgeId": "builtin-remotion-runtime", - "capabilityId": "sparo.videoEngine", - "action": "getCompositionManifest", - "mode": "auto" - } - }, - { - "name": "reviseVideoComposition", - "description": "Make scoped React/Remotion source edits and keep preview semantics consistent with the selected composition or element.", - "toolPolicy": [ - "Read", - "Edit", - "Write", - "GetFileDiff", - "agentcomponent__remotion-video-agent__get_frame_descriptor", - "agentcomponent__remotion-video-agent__refresh_preview" - ] - }, - { - "name": "renderReviewArtifact", - "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__get_export_status", - "agentcomponent__remotion-video-agent__cancel_export" - ] - } - ], - "examples": [ - { - "title": "Refine the selected title", - "prompt": "In the currently open Remotion Live preview, adjust the selected title so it reads more clearly without changing the scene timing." - }, - { - "title": "Add motion polish", - "prompt": "Make the current composition feel more polished with subtle entrance motion and verify the active frame still looks balanced." - }, - { - "title": "Prepare an export", - "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/routing.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/routing.md deleted file mode 100644 index 911bf526..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/routing.md +++ /dev/null @@ -1,14 +0,0 @@ -# Remotion Video Agent Routing - -Use this Agent Component when the active surface is Remotion Live or the user asks for video composition work in a Remotion workspace. - -Prefer this agent for: -- Visual or timing edits to Remotion compositions. -- Requests involving the selected element in the Remotion Live preview. -- Preview refresh, frame inspection, still rendering, or export preparation. -- Explaining how a composition is structured from source and manifest context. - -Do not route here for: -- Generic repository refactors unrelated to video. -- Non-Remotion video tools unless the workspace is explicitly using Remotion. -- Pure diagnostics dashboards. The Remotion Live preview should remain the primary feedback surface. 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 deleted file mode 100644 index c63a203f..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/agent-action-input.schema.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Remotion Agent Action Input", - "type": "object", - "additionalProperties": true, - "properties": { - "context": { - "type": "object", - "additionalProperties": true, - "properties": { - "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": { - "type": "object", - "properties": { - "width": { "type": "number" }, - "height": { "type": "number" } - } - }, - "selectedVideoContext": { "$ref": "./selected-video-context.schema.json" }, - "sourceHints": { - "type": "array", - "items": { - "type": "object", - "properties": { - "file": { "type": "string" }, - "role": { "type": "string" }, - "confidence": { "type": "number" } - } - } - }, - "recentError": { "type": "string" } - } - }, - "userIntent": { "type": "string" }, - "question": { "type": "string" }, - "constraints": { "type": "array", "items": { "type": "string" } }, - "frames": { "type": "array", "items": { "type": "integer" } } - } -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/agent-action-output.schema.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/agent-action-output.schema.json deleted file mode 100644 index 0959dc81..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/agent-action-output.schema.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Remotion Agent Action Output", - "type": "object", - "additionalProperties": true, - "properties": { - "summary": { "type": "string" }, - "selectedTarget": { "type": "string" }, - "likelySourceFiles": { "type": "array", "items": { "type": "string" } }, - "changedFiles": { "type": "array", "items": { "type": "string" } }, - "visualHypothesis": { "type": "string" }, - "validationRequest": { - "type": "object", - "properties": { - "compositionId": { "type": "string" }, - "frames": { "type": "array", "items": { "type": "integer" } }, - "mode": { "type": "string", "enum": ["still", "player-refresh"] } - } - }, - "validation": { - "type": "object", - "properties": { - "status": { "type": "string", "enum": ["passed", "failed", "inconclusive"] }, - "artifacts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "frame": { "type": "integer" }, - "path": { "type": "string" }, - "uri": { "type": "string" } - } - } - }, - "notes": { "type": "string" } - } - }, - "userFacingSummary": { "type": "string" }, - "uncertainty": { "type": "string" }, - "nextStep": { "type": "string" } - } -} 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 deleted file mode 100644 index cc79f9f2..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/live-preview-context.schema.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Remotion Live Preview Context", - "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 deleted file mode 100644 index 0b540082..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/schemas/selected-video-context.schema.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Remotion Selected Video Context", - "type": "object", - "additionalProperties": true, - "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" }, - "selection": { - "type": "object", - "additionalProperties": true, - "properties": { - "type": { "type": "string", "enum": ["point", "region", "element", "timeRange"] }, - "element": { - "type": "object", - "additionalProperties": true, - "properties": { - "id": { "type": "string" }, - "type": { "type": "string" }, - "label": { "type": "string" }, - "sourceHint": { "type": "string" }, - "bboxPercent": { - "type": "object", - "properties": { - "x": { "type": "number" }, - "y": { "type": "number" }, - "width": { "type": "number" }, - "height": { "type": "number" } - } - } - } - } - } - } - } -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/composition-architecture.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/composition-architecture.md deleted file mode 100644 index 0bc3997b..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/composition-architecture.md +++ /dev/null @@ -1,12 +0,0 @@ -# Composition Architecture - -Use this knowledge when adding, splitting, or restructuring compositions and their components. - -- A Composition declares the renderable unit: id, width, height, fps, durationInFrames, and component. -- Keep root registration (registerRoot, RemotionRoot) clear and stable. -- Treat width, height, fps, and durationInFrames as a contract. Do not silently change them; a change there is a global change. -- Prefer typed defaultProps and a stable props shape over ad hoc inline values. -- Use calculateMetadata for dynamic duration or size instead of hardcoding when the source already drives it from data. -- Split a large component by visual responsibility (scene, layer, overlay), not by arbitrary line count. - -Output should name the target composition, the component or props to change, and whether the change touches the composition contract (id/fps/size/duration) so it can be flagged as high risk. diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/media-assets.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/media-assets.md deleted file mode 100644 index 235fbad4..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/media-assets.md +++ /dev/null @@ -1,12 +0,0 @@ -# Media And Assets - -Use this knowledge when working with images, video, audio, fonts, captions, or data assets. - -- Reference public assets with staticFile() rather than absolute filesystem paths or bare relative URLs. -- Use Remotion media primitives: Img for images, OffthreadVideo or Video for video, Audio for sound. -- Control media with Remotion props: trimBefore/trimAfter (or startFrom/endAt), volume, and playbackRate, so media stays frame-accurate. -- Keep media deterministic for render: avoid network-only sources that may fail headless rendering. -- When replacing an asset, reuse the existing asset path convention in public/ and keep references consistent. -- For data-driven video, load data as a typed prop or staticFile JSON, not from a live network call during render. - -Output should name the asset reference being changed, confirm it resolves through staticFile() or an existing managed path, and note any media timing implications. diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/motion-timing.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/motion-timing.md deleted file mode 100644 index 8c12c27b..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/motion-timing.md +++ /dev/null @@ -1,13 +0,0 @@ -# Motion And Timing - -Use this knowledge when adjusting rhythm, entrances, exits, beats, or transitions. - -- Drive every animation with useCurrentFrame() and useVideoConfig(); never with CSS transitions, CSS animations, or animation utility classes. -- Express intent in seconds, then convert with fps: frame = seconds * fps. -- Use interpolate() with explicit input/output ranges and Easing (for example Easing.bezier) for shaped motion. -- Use extrapolateLeft and extrapolateRight set to "clamp" to avoid values running past the intended range. -- Structure time with Sequence and Series. Inside a Sequence, useCurrentFrame() returns a local frame, not the global composition frame. -- Derive several properties from one timing progress value instead of repeating separate curves. -- Keep premountFor and offsets conservative unless the user asks to change pacing. - -Output should state the target frame or time range, the global-vs-local frame distinction when relevant, and which frames to validate (start, middle, end for motion). diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/remotion-fundamentals.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/remotion-fundamentals.md deleted file mode 100644 index 9af9124f..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/remotion-fundamentals.md +++ /dev/null @@ -1,12 +0,0 @@ -# Remotion Fundamentals - -Use this knowledge when identifying project structure or explaining how a Remotion video is assembled. - -- Treat a Composition as the renderable unit. -- Treat frames as the source of truth for time. -- Keep width, height, fps, and duration explicit. -- Prefer deterministic props and render paths. -- Use staticFile for public assets that must render reliably. -- Avoid browser-only state, current time, random values, and network-only assets in rendered output. - -Output should name the composition, entry point, likely source file, and the video consequence of any proposed change. 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 deleted file mode 100644 index 8183a6d9..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/selected-context.md +++ /dev/null @@ -1,11 +0,0 @@ -# Selected Preview Context - -Use selected context as a precise pointer to the user's intent. - -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 deleted file mode 100644 index 5c378645..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/validation-export.md +++ /dev/null @@ -1,13 +0,0 @@ -# Validation And Export - -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/skills/video-development-workflow.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/video-development-workflow.md deleted file mode 100644 index 90720bda..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/video-development-workflow.md +++ /dev/null @@ -1,12 +0,0 @@ -# Video Development Workflow - -Use this workflow for multi-step video edits. - -1. Understand the visual goal. -2. Detect the Remotion project and read the composition manifest. -3. Inspect only the source files likely to control the requested frame or element. -4. Make minimal source edits. -5. Refresh preview or render stills when available. -6. Summarize changed files, visual intent, and validation status. - -Do not start with broad repository refactors. The video surface is the contract. diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/visual-design.md b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/visual-design.md deleted file mode 100644 index b93c6e56..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/skills/visual-design.md +++ /dev/null @@ -1,12 +0,0 @@ -# Visual Design - -Use this knowledge when changing layout, hierarchy, typography, brand feel, or motion quality. - -- Improve legibility, rhythm, hierarchy, or narrative clarity. Avoid decorative overwork. -- Respect the composition aspect ratio and safe margins; keep readable copy inside safe areas. -- Build visual hierarchy with scale, weight, spacing, and contrast, not with color alone. -- Keep typography deterministic: load fonts through Remotion or project-managed font assets, not network-only fonts during render. -- Express styling as renderable CSS-in-React (inline style or styled components), keeping side effects out of the render path. -- When polishing motion, keep it subtle and frame-driven; tie visual emphasis to timing progress. - -Output should describe the visual change in design terms (hierarchy, spacing, type, motion intent) and the frame where the change is most visible. diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.js deleted file mode 100644 index 95c30a93..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callVideoEngine } = require("./remotion_bridge"); - -async function run(input, context) { - return callVideoEngine( - "cancelExport", - input, - context, - "Requested cancellation of the Remotion export.", - ); -} - -module.exports = { run }; 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 deleted file mode 100644 index 2037d14f..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/cancel_export.tool.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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/detect_project.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/detect_project.js deleted file mode 100644 index 6ca08cbc..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/detect_project.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callVideoEngine } = require("./remotion_bridge"); - -async function run(input, context) { - return callVideoEngine( - "detectProject", - input, - context, - "Detected the Remotion project context.", - ); -} - -module.exports = { run }; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/detect_project.tool.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/detect_project.tool.json deleted file mode 100644 index 1e6988c1..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/detect_project.tool.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "detect_project", - "description": "Detect whether the current workspace is a Remotion project and locate the entry point, package manager, renderer, and project diagnostics.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workspacePath": { "type": "string" }, - "entryPoint": { "type": "string" } - } - }, - "runtime": "javascript", - "entry": "tools/detect_project.js", - "readonly": true, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 12000 -} 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 deleted file mode 100644 index b538201f..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.js +++ /dev/null @@ -1,14 +0,0 @@ -const { callVideoEngine } = require("./remotion_bridge"); - -async function run(input, context) { - const action = input && input.forceCompile ? "compileProject" : "getCompositionManifest"; - const { forceCompile, ...request } = input || {}; - return callVideoEngine( - action, - forceCompile ? { ...request, force: true } : request, - context, - "Loaded the Remotion composition manifest.", - ); -} - -module.exports = { run }; 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 deleted file mode 100644 index d8ff99d0..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_composition_manifest.tool.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "get_composition_manifest", - "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, - "properties": { - "workspacePath": { "type": "string" }, - "entryPoint": { "type": "string" }, - "forceCompile": { "type": "boolean" } - } - }, - "runtime": "javascript", - "entry": "tools/get_composition_manifest.js", - "readonly": true, - "permissions": {}, - "timeoutMs": 30000, - "maxOutputBytes": 60000 -} 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 deleted file mode 100644 index af92f007..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_export_status.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index b58b16f3..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_export_status.tool.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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_descriptor.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.js deleted file mode 100644 index a4930cac..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 13ad8c2f..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/get_frame_descriptor.tool.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index 43871035..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callVideoEngine } = require("./remotion_bridge"); - -async function run(input = {}, context) { - return callVideoEngine( - "ensurePlayerPreviewHost", - input, - context, - "Refreshed the embedded Remotion Player preview host.", - ); -} - -module.exports = { run }; 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 deleted file mode 100644 index 3fd90b85..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/refresh_preview.tool.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "refresh_preview", - "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" }, - "expectedProjectRevision": { "type": "string", "minLength": 1 }, - "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, - "frame": { "type": "integer" }, - "force": { "type": "boolean" }, - "port": { "type": "integer" }, - "waitMs": { "type": "integer" }, - "inputProps": { "type": "object" } - } - }, - "runtime": "javascript", - "entry": "tools/refresh_preview.js", - "readonly": true, - "permissions": {}, - "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 deleted file mode 100644 index 1e961c74..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/remotion_bridge.js +++ /dev/null @@ -1,26 +0,0 @@ -const BRIDGE_ID = "builtin-remotion-runtime"; -const CAPABILITY_ID = "sparo.videoEngine"; - -function normalizeInput(input = {}, context = {}) { - const payload = { ...input }; - if (!payload.workspacePath && !payload.workspace_path && context.workspaceRoot) { - payload.workspacePath = context.workspaceRoot; - } - return payload; -} - -function callVideoEngine(action, input, context, summary) { - return { - summary, - bridgeCall: { - bridgeId: BRIDGE_ID, - capabilityId: CAPABILITY_ID, - action, - input: normalizeInput(input, context), - }, - }; -} - -module.exports = { - callVideoEngine, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/render_still.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/render_still.js deleted file mode 100644 index 42e95da9..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/render_still.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callVideoEngine } = require("./remotion_bridge"); - -async function run(input, context) { - return callVideoEngine( - "renderStill", - input, - context, - "Rendered a Remotion still for review.", - ); -} - -module.exports = { run }; 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 deleted file mode 100644 index 0ee5b124..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/render_still.tool.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "render_still", - "description": "Render a still image for a Remotion composition frame through the workspace Remotion toolchain.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["compositionId", "frame", "expectedProjectRevision", "expectedDescriptorRevision"], - "properties": { - "workspacePath": { "type": "string" }, - "entryPoint": { "type": "string" }, - "compositionId": { "type": "string" }, - "expectedProjectRevision": { "type": "string", "minLength": 1 }, - "expectedDescriptorRevision": { "type": "string", "minLength": 1 }, - "frame": { "type": "integer" }, - "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, - "inputProps": { "type": "object" } - } - }, - "runtime": "javascript", - "entry": "tools/render_still.js", - "readonly": true, - "permissions": {}, - "timeoutMs": 300000, - "maxOutputBytes": 60000 -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/start_export.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/start_export.js deleted file mode 100644 index 1158d74c..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/start_export.js +++ /dev/null @@ -1,12 +0,0 @@ -const { callVideoEngine } = require("./remotion_bridge"); - -async function run(input, context) { - return callVideoEngine( - "startExport", - input, - context, - "Started a Remotion video export.", - ); -} - -module.exports = { run }; 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 deleted file mode 100644 index c1e04730..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/agents/remotion-video-agent/source/tools/start_export.tool.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "start_export", - "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" }, - "expectedProjectRevision": { "type": "string", "minLength": 1 }, - "expectedDescriptorRevision": { "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" }, - "inputProps": { "type": "object" } - } - }, - "runtime": "javascript", - "entry": "tools/start_export.js", - "readonly": false, - "permissions": {}, - "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 deleted file mode 100644 index 956739bc..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/component.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "id": "builtin-remotion-runtime", - "kind": "bridge", - "name": "Sparo Video Engine", - "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", - "appVersion": "19.0.0" - }, - "capabilities": [ - { - "id": "sparo.videoEngine", - "title": "Sparo Video Engine", - "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", - "getFrameDescriptor", - "ensurePlayerPreviewHost", - "getPlayerPreviewHostStatus", - "stopPlayerPreviewHost", - "renderStill", - "startExport", - "getExportStatus", - "cancelExport", - "indexAssets", - "readDiagnostics" - ] - } - ], - "permissions": [ - { - "kind": "fs", - "summary": "Reads Remotion project source and writes generated preview/export artifacts.", - "scopes": [ - "{workspace}", - "{app}" - ] - }, - { - "kind": "shell", - "summary": "Runs workspace package managers and Remotion commands.", - "scopes": [ - "node", - "git", - "npm", - "npx", - "pnpm", - "yarn", - "bunx", - "remotion" - ] - } - ], - "usedByApps": [ - "builtin-remotion-live" - ], - "visibility": "appDependency", - "dependencies": [], - "implementationRef": "app://builtin-remotion-live@19.0.0/bridges/builtin-remotion-runtime", - "usesCapabilities": [ - "filesystem.read", - "filesystem.write", - "terminal.run", - "process.spawn", - "workspace.inspect", - "component.run" - ] -} 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 deleted file mode 100644 index 6081e474..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/bundle.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "schemaVersion": 1, - "id": "builtin-remotion-runtime", - "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 deleted file mode 100644 index df322097..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/manifest.json +++ /dev/null @@ -1,403 +0,0 @@ -{ - "schemaVersion": 1, - "id": "builtin-remotion-runtime", - "name": "Sparo Video Engine", - "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", - "idleTimeoutMs": 120000 - }, - "surfaces": { - "launchableApp": false, - "agent": true, - "tool": true, - "productAppRuntimeBackend": true - }, - "permissions": { - "fs": [ - "{workspace}", - "{app}" - ], - "net": [], - "shell": [ - "node", - "git", - "npm", - "npx", - "pnpm", - "yarn", - "bunx", - "remotion" - ], - "gui": [], - "secrets": [] - }, - "capabilities": [ - { - "id": "sparo.videoEngine", - "title": "Sparo Video Engine", - "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", - "getFrameDescriptor", - "ensurePlayerPreviewHost", - "getPlayerPreviewHostStatus", - "stopPlayerPreviewHost", - "renderStill", - "startExport", - "getExportStatus", - "cancelExport", - "indexAssets", - "readDiagnostics" - ], - "streaming": false, - "cancelable": true, - "resumable": true, - "usableBy": [ - "agentComponent", - "productAppRuntime", - "management" - ], - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["workspacePath"], - "properties": { - "workspacePath": { "type": "string", "minLength": 1 }, - "entryPoint": { "type": "string", "minLength": 1 } - } - }, - "outputSchema": { - "type": "object" - } - } - ], - "actions": [ - { - "name": "detectProject", - "description": "Detect whether the workspace contains a Remotion project and locate entry/composition source files.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["workspacePath"], - "properties": { - "workspacePath": { "type": "string", "minLength": 1 }, - "entryPoint": { "type": "string", "minLength": 1 } - } - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": false - }, - { - "name": "compileProject", - "description": "Materialize a fresh schema-v3 project snapshot by bundling the registered Root and resolving calculateMetadata through the project renderer.", - "inputSchema": { - "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" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "getCompositionManifest", - "description": "Read the current source-and-input-scoped resolved snapshot; use compileProject to refresh calculateMetadata explicitly.", - "inputSchema": { - "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" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "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", - "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" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "ensurePlayerPreviewHost", - "description": "Build, start, or reuse the workspace's Remotion Player preview host for interactive playback.", - "inputSchema": { - "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" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "getPlayerPreviewHostStatus", - "description": "Read the current Remotion Player preview host status and recent logs.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["workspacePath"], - "properties": { - "workspacePath": { "type": "string", "minLength": 1 } - } - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "stopPlayerPreviewHost", - "description": "Stop the Remotion Player preview host for the workspace.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["workspacePath"], - "properties": { - "workspacePath": { "type": "string", "minLength": 1 } - } - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "renderStill", - "description": "Render the current frame through the workspace's Remotion toolchain to a local image still.", - "inputSchema": { - "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" - }, - "streaming": false, - "cancelable": false, - "resumable": false - }, - { - "name": "startExport", - "description": "Start an isolated asynchronous Remotion render job with durable progress reporting.", - "inputSchema": { - "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" - }, - "streaming": false, - "cancelable": true, - "resumable": true - }, - { - "name": "getExportStatus", - "description": "Read native export run status.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["workspacePath", "runId"], - "properties": { - "workspacePath": { "type": "string", "minLength": 1 }, - "runId": { "type": "string", "minLength": 1 } - } - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "cancelExport", - "description": "Request renderer-native export cancellation with isolated process-tree termination fallback.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["workspacePath", "runId"], - "properties": { - "workspacePath": { "type": "string", "minLength": 1 }, - "runId": { "type": "string", "minLength": 1 } - } - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "indexAssets", - "description": "Index project media, font, caption, and data assets.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["workspacePath"], - "properties": { - "workspacePath": { "type": "string", "minLength": 1 }, - "entryPoint": { "type": "string", "minLength": 1 } - } - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - }, - { - "name": "readDiagnostics", - "description": "Read project diagnostics and source control changes.", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["workspacePath"], - "properties": { - "workspacePath": { "type": "string", "minLength": 1 }, - "entryPoint": { "type": "string", "minLength": 1 } - } - }, - "outputSchema": { - "type": "object" - }, - "streaming": false, - "cancelable": false, - "resumable": true - } - ], - "tools": [ - { - "name": "SparoVideoEngine", - "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", - "getFrameDescriptor", - "ensurePlayerPreviewHost", - "getPlayerPreviewHostStatus", - "stopPlayerPreviewHost", - "renderStill", - "startExport", - "getExportStatus", - "cancelExport", - "indexAssets", - "readDiagnostics" - ], - "readonly": false, - "needsPermissions": true, - "inputSchema": { - "type": "object" - }, - "ui": { - "card": { - "kind": "appDefined", - "displayName": "Sparo Video Engine", - "icon": "Film", - "resultDisplayType": "detailed", - "displayMode": "compact", - "family": "bridge-component", - "title": "Sparo Video Engine" - } - } - } - ] -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/package.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/package.json deleted file mode 100644 index 7b066ffb..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "private": true, - "type": "commonjs", - "name": "builtin-remotion-runtime" -} 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 deleted file mode 100644 index d92d8395..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/artifacts.js +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index a9a4d2ab..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/constants.js +++ /dev/null @@ -1,86 +0,0 @@ -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 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 ARTIFACT_CACHE_MAX_BYTES = 512 * 1024 * 1024; -const PROJECT_BUNDLE_CACHE_LIMIT = 3; -const PLAYER_HOST_BOOT_WAIT_MS = 45_000; -const PLAYER_HOST_RUNTIME_VERSION = 14; -const PLAYER_CONTROL_PROTOCOL_VERSION = 3; -const ASSET_EXTENSIONS = new Map([ - [".png", "image"], - [".jpg", "image"], - [".jpeg", "image"], - [".webp", "image"], - [".gif", "image"], - [".svg", "image"], - [".mp4", "video"], - [".mov", "video"], - [".webm", "video"], - [".m4v", "video"], - [".mp3", "audio"], - [".wav", "audio"], - [".m4a", "audio"], - [".ogg", "audio"], - [".aac", "audio"], - [".ttf", "font"], - [".otf", "font"], - [".woff", "font"], - [".woff2", "font"], - [".srt", "caption"], - [".vtt", "caption"], - [".json", "data"], -]); - -module.exports = { - IGNORED_DIRS, - SOURCE_EXTENSIONS, - PROJECT_REVISION_EXTENSIONS, - DEFAULT_STILL_SCALE, - REMOTION_RENDER_TIMEOUT_MS, - ARTIFACT_CACHE_MAX_BYTES, - PROJECT_BUNDLE_CACHE_LIMIT, - PLAYER_HOST_BOOT_WAIT_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 deleted file mode 100644 index 6ff1144a..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export-job-worker.js +++ /dev/null @@ -1,160 +0,0 @@ -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 deleted file mode 100644 index 9d50b2ae..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/export.js +++ /dev/null @@ -1,246 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { spawn } = require("node:child_process"); -const { ensureRuntimeDir, normalizeWorkspace, workspacePathOf } = require("./paths"); -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) { - 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) { - writeJsonAtomic(runsPath(workspacePath), { runs: runs.slice(0, EXPORT_HISTORY_LIMIT) }); -} - -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); - const from = Math.round(Math.max(0, Number.isFinite(fromValue) ? fromValue : 0)); - const to = Math.max(from, Math.round(Math.min( - composition.durationInFrames - 1, - Number.isFinite(toValue) ? toValue : composition.durationInFrames - 1, - ))); - 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 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 }; - } - 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 }; - } - 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 || "").trim(); - const runs = readRuns(workspacePath).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 || "").trim(); - if (!runId) return { ok: false, status: "not_found" }; - 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 = { - runsPath, - readRuns, - writeRuns, - startExport, - getExportStatus, - cancelExport, -}; 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 deleted file mode 100644 index 4dd0e63c..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/paths.js +++ /dev/null @@ -1,69 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); - -function workspacePathOf(requestOrInput) { - const input = requestOrInput?.input || requestOrInput || {}; - return String( - input.workspacePath || - input.workspace_path || - requestOrInput?.workspacePath || - requestOrInput?.workspace_path || - "", - ).trim(); -} - -function normalizeWorkspace(workspacePath) { - if (!workspacePath) throw new Error("workspacePath is required."); - const resolved = path.resolve(workspacePath); - if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { - throw new Error(`Workspace does not exist or is not a directory: ${resolved}`); - } - return resolved; -} - -function relativeToWorkspace(workspacePath, absolutePath) { - return path.relative(workspacePath, absolutePath).replace(/\\/g, "/"); -} - -function ensureRuntimeDir(workspacePath, child = "") { - const dir = path.join(workspacePath, ".sparo_os", "remotion-live", child); - fs.mkdirSync(dir, { recursive: true }); - return dir; -} - -function playerHostStatePath(workspacePath) { - return path.join(ensureRuntimeDir(workspacePath), "player-host.json"); -} - -function playerHostRootDir(workspacePath) { - return ensureRuntimeDir(workspacePath, "player-host"); -} - -function playerHostSourceDir(workspacePath) { - return path.join(playerHostRootDir(workspacePath), "src"); -} - -function playerHostDistDir(workspacePath) { - return path.join(playerHostRootDir(workspacePath), "dist"); -} - -function playerHostLogPath(workspacePath) { - return path.join(ensureRuntimeDir(workspacePath, "logs"), "player-host.log"); -} - -function playerHostServerPath(workspacePath) { - return path.join(ensureRuntimeDir(workspacePath, "bin"), "player-host-server.js"); -} - -module.exports = { - workspacePathOf, - normalizeWorkspace, - relativeToWorkspace, - ensureRuntimeDir, - playerHostStatePath, - playerHostRootDir, - playerHostSourceDir, - playerHostDistDir, - playerHostLogPath, - playerHostServerPath, -}; 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 deleted file mode 100644 index 404f6733..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/player-host.js +++ /dev/null @@ -1,1298 +0,0 @@ -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, readJson, writeJson, isProcessAlive, httpStatus, tailPlayerHostLogs, psQuote, findFreePort, clampNumber, waitForHttp, terminateProcessTree } = require("./util"); -const { compositionForInput } = require("./project"); -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([ - projectRoot, - workspacePath, - __dirname, - process.cwd(), - ].filter(Boolean))); -} - -function resolveWorkspaceModule(workspacePath, request, projectRoot = workspacePath) { - return require.resolve(request, { paths: moduleSearchPaths(workspacePath, projectRoot) }); -} - -function resolveRemotionBundlerLoader(workspacePath, projectRoot) { - const packagePath = resolveWorkspaceModule(workspacePath, "@remotion/bundler/package.json", projectRoot); - return path.join(path.dirname(packagePath), "dist", "esbuild-loader", "index.js"); -} - -function playerHostBundleId(manifest) { - return hashContent(JSON.stringify({ - runtimeVersion: PLAYER_HOST_RUNTIME_VERSION, - protocolVersion: PLAYER_CONTROL_PROTOCOL_VERSION, - buildId: manifest.buildId, - entryPoint: manifest.entryPoint, - })); -} - -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-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 code = ` -import * as React from "react"; -import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react"; -import {createRoot} from "react-dom/client"; -import {Player, type PlayerRef} from "@remotion/player"; -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 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); - return Math.max(0, Math.min(composition.durationInFrames - 1, frame)); -} - -function post(type: string, payload: Record = {}) { - 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, - }, "*"); -} - -function elementLabel(element: Element): string { - const direct = element.getAttribute("aria-label") - || element.getAttribute("title") - || element.getAttribute("alt") - || element.getAttribute("data-name") - || element.getAttribute("data-testid") - || ""; - const text = direct || (element.textContent || "").replace(/\\s+/g, " ").trim(); - return text.slice(0, 80); -} - -function elementPath(element: Element, root: Element): string { - const parts: string[] = []; - let current: Element | null = element; - while (current && current !== root && parts.length < 6) { - const tag = current.tagName.toLowerCase(); - const parent = current.parentElement; - if (!parent) { - parts.unshift(tag); - break; - } - const siblings = Array.from(parent.children).filter((item) => item.tagName === current!.tagName); - const index = siblings.indexOf(current) + 1; - parts.unshift(siblings.length > 1 ? tag + ":nth-of-type(" + index + ")" : tag); - current = parent; - } - return parts.join(">"); -} - -function hasOwnText(element: Element): boolean { - return Array.from(element.childNodes).some((node) => { - return node.nodeType === Node.TEXT_NODE && Boolean((node.textContent || "").trim()); - }); -} - -function visibleElementCandidates(stage: HTMLElement): Element[] { - const candidates = Array.from(stage.querySelectorAll(ELEMENT_SELECTOR)); - const stageRect = stage.getBoundingClientRect(); - const stageArea = Math.max(1, stageRect.width * stageRect.height); - return candidates.filter((element) => { - if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) return false; - const tag = element.tagName.toLowerCase(); - if (tag === "script" || tag === "style") return false; - const className = typeof element.className === "string" ? element.className : ""; - if (SKIP_CLASS_PATTERN.test(className)) return false; - const rect = element.getBoundingClientRect(); - if (rect.width < 4 || rect.height < 4) return false; - if (rect.right <= stageRect.left || rect.left >= stageRect.right || rect.bottom <= stageRect.top || rect.top >= stageRect.bottom) return false; - const style = window.getComputedStyle(element); - if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity || "1") <= 0.02) return false; - const area = rect.width * rect.height; - const mediaLike = ["img", "video", "canvas", "svg"].includes(tag); - const textLike = Boolean(elementLabel(element)); - const ownText = hasOwnText(element); - const visualStyle = style.backgroundImage !== "none" - || (style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)" && style.backgroundColor !== "transparent") - || style.borderTopStyle !== "none" - || style.boxShadow !== "none"; - const nearWholeStage = area / stageArea > 0.92; - if (nearWholeStage && !mediaLike && !ownText) return false; - return mediaLike || textLike || visualStyle; - }); -} - -function measureFrameContext(stage: HTMLElement | null, frame: number) { - if (!stage) { - return { - frame, - timeSeconds: frame / composition.fps, - layers: [], - measurement: "player-dom", - }; - } - const stageRect = stage.getBoundingClientRect(); - const width = Math.max(1, stageRect.width); - const height = Math.max(1, stageRect.height); - const layers = visibleElementCandidates(stage) - .map((element, index) => { - const rect = element.getBoundingClientRect(); - const x = Math.max(0, Math.min(100, ((rect.left - stageRect.left) / width) * 100)); - const y = Math.max(0, Math.min(100, ((rect.top - stageRect.top) / height) * 100)); - const w = Math.max(0.5, Math.min(100 - x, (rect.width / width) * 100)); - const h = Math.max(0.5, Math.min(100 - y, (rect.height / height) * 100)); - const tag = element.tagName.toLowerCase(); - const path = elementPath(element, stage); - const label = elementLabel(element) || tag; - return { - id: "dom:" + path, - type: tag, - label, - source: "player-dom", - sourceHint: ${JSON.stringify(manifest.entryPoint || null)}, - componentPath: null, - elementPath: path, - x, - y, - width: w, - height: h, - bboxPercent: { x, y, width: w, height: h }, - color: ["#5dc6ff", "#f4c542", "#8de16d", "#ff7a90", "#b99cff", "#63dbc6"][index % 6], - opacity: 0.18, - }; - }) - .sort((left, right) => (left.width * left.height) - (right.width * right.height)) - .slice(0, 32); - return { - frame, - timeSeconds: frame / composition.fps, - composition, - measurement: "player-dom", - layers, - }; -} - -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 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 currentFrame = useCallback(() => { - const actual = playerRef.current?.getCurrentFrame?.(); - const frame = Number.isFinite(Number(actual)) ? clampFrame(actual) : clampFrame(lastKnownFrameRef.current); - lastKnownFrameRef.current = frame; - return frame; - }, []); - - const postFrameContext = useCallback((frame: number) => { - if (frameContextRequestRef.current !== null) { - window.cancelAnimationFrame(frameContextRequestRef.current); - } - frameContextRequestRef.current = window.requestAnimationFrame(() => { - frameContextRequestRef.current = null; - post("frameContext", measureFrameContext(stageRef.current, frame)); - }); - }, []); - - const postSnapshot = useCallback((requestId: unknown) => { - const frame = currentFrame(); - post("snapshot", { - 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, - height: composition.height, - frameContext: measureFrameContext(stageRef.current, frame), - }); - }, [currentFrame]); - - const actualPayload = useCallback((frame: unknown = currentFrame()) => { - const next = clampFrame(frame); - lastKnownFrameRef.current = next; - return { - frame: next, - playing: playingRef.current, - buffering: bufferingRef.current, - seeking: seekingRef.current, - muted: mutedRef.current, - volume: volumeRef.current, - revision: activeRevisionRef.current, - }; - }, [currentFrame]); - - const postActualState = useCallback((type = "actualState", frame?: unknown) => { - post(type, actualPayload(frame)); - }, [actualPayload]); - - 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 (revision < activeRevisionRef.current) { - post("commandAccepted", { - commandId: message.commandId, - command: message.command, - revision, - accepted: false, - reason: "stale-revision", - }); - postActualState(); - return; - } - - activeRevisionRef.current = revision; - post("commandAccepted", { - commandId: message.commandId, - command: message.command, - revision, - accepted: true, - }); - - 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 nextFrame = clampFrame(desired.frame); - if (Math.abs(currentFrame() - nextFrame) > 0) { - seekingRef.current = true; - player.seekTo(nextFrame); - } - - if (desired.playing === true) { - try { - player.play(); - } catch (error) { - post("error", {message: error instanceof Error ? error.message : String(error), revision}); - } - } else { - player.pause(); - } - - window.requestAnimationFrame(() => postActualState()); - }, [currentFrame, postActualState]); - - const flushLatestReconcile = useCallback(() => { - if (reconcileRequestRef.current !== null) { - window.cancelAnimationFrame(reconcileRequestRef.current); - reconcileRequestRef.current = null; - } - 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; - } - if (message.type === "frameContext") { - flushLatestReconcile(); - postFrameContext(currentFrame()); - return; - } - 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); - }); - } - } - }, [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; - 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("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) { - const posted = post("ready", { - ...actualPayload(frame), - frame, - durationInFrames: composition.durationInFrames, - fps: composition.fps, - width: composition.width, - height: composition.height, - }); - if (posted) { - readyPostedRef.current = true; - postFrameContext(frame); - window.requestAnimationFrame(() => { - postActualState("frameCommitted", clampFrame(player.getCurrentFrame())); - }); - } - } - - const pending = pendingCommandsRef.current.splice(0); - pending.forEach(runCommand); - return true; - }, [actualPayload, initialFrame, postActualState, postFrameContext, runCommand]); - - useLayoutEffect(() => { - postFrameContext(clampFrame(playerRef.current?.getCurrentFrame?.() ?? initialFrame)); - }, [initialFrame, postFrameContext]); - - useEffect(() => { - const onConnect = (event: MessageEvent) => { - const message = event.data || {}; - if (message.source !== "sparo-remotion-live") return; - 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; - } - announceBootstrap(); - }, 250); - return () => { - window.clearInterval(bootstrapTimer); - window.removeEventListener("message", onConnect); - controlPort?.close(); - controlPort = null; - }; - }, [ensurePlayerReady, runCommand]); - - useEffect(() => { - let attempts = 0; - const timer = window.setInterval(() => { - attempts += 1; - if (ensurePlayerReady()) { - window.clearInterval(timer); - } else if (attempts > 200) { - window.clearInterval(timer); - post("error", {message: "Timed out waiting for Remotion Player to mount."}); - } - }, 25); - return () => { - window.clearInterval(timer); - if (frameContextRequestRef.current !== null) { - window.cancelAnimationFrame(frameContextRequestRef.current); - frameContextRequestRef.current = null; - } - if (reconcileRequestRef.current !== null) { - window.cancelAnimationFrame(reconcileRequestRef.current); - reconcileRequestRef.current = null; - } - cleanupRef.current?.(); - cleanupRef.current = null; - }; - }, [ensurePlayerReady]); - - return ( -
-
Loading preview...
} - errorFallback={({error}) =>
{error.message}
} - /> -
- ); -} - -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(); -`; - fs.writeFileSync(entryPath, code, "utf8"); - return entryPath; -} - -async function runWebpackBuild(webpack, config) { - await new Promise((resolve, reject) => { - const compiler = webpack(config); - compiler.run((error, stats) => { - compiler.close(() => {}); - if (error) { - reject(error); - return; - } - if (stats?.hasErrors()) { - reject(new Error(stats.toString({ all: false, errors: true, errorDetails: true }))); - return; - } - resolve(); - }); - }); -} - -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, bundleId); - const webpack = require(resolveWorkspaceModule(workspacePath, "webpack", moduleRoot)); - const esbuild = require(resolveWorkspaceModule(workspacePath, "esbuild", moduleRoot)); - const esbuildLoaderPath = resolveRemotionBundlerLoader(workspacePath, moduleRoot); - const config = { - mode: "development", - target: "web", - context: moduleRoot, - // 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", - publicPath: "/", - assetModuleFilename: "assets/[hash][ext][query]", - clean: true, - }, - resolve: { - extensions: [".ts", ".tsx", ".web.js", ".js", ".jsx", ".mjs", ".cjs", ".json"], - modules: [ - path.join(moduleRoot, "node_modules"), - path.join(workspacePath, "node_modules"), - "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"), - }, - }, - module: { - rules: [ - { - test: /\.tsx?$/, - exclude: /node_modules/, - use: [{ - loader: esbuildLoaderPath, - options: { - target: "chrome105", - loader: "tsx", - implementation: esbuild, - remotionRoot: moduleRoot, - }, - }], - }, - { - test: /\.jsx?$/, - exclude: /node_modules/, - use: [{ - loader: esbuildLoaderPath, - options: { - target: "chrome105", - loader: "jsx", - implementation: esbuild, - remotionRoot: moduleRoot, - }, - }], - }, - { - test: /\.css$/i, - use: [ - resolveWorkspaceModule(workspacePath, "style-loader", moduleRoot), - { - loader: resolveWorkspaceModule(workspacePath, "css-loader", moduleRoot), - options: { modules: { auto: true, namedExport: false } }, - }, - ], - type: "javascript/auto", - }, - { - test: /\.(png|svg|jpg|jpeg|webp|gif|bmp|webm|mp4|mov|mp3|m4a|wav|aac|woff2?|otf|ttf|eot)$/i, - type: "asset/resource", - }, - ], - }, - plugins: [ - new webpack.DefinePlugin({ - "process.env.NODE_ENV": JSON.stringify("development"), - "process.env.REMOTION_ENV": JSON.stringify("preview"), - }), - ], - cache: { - type: "filesystem", - cacheDirectory: path.join(playerHostRootDir(workspacePath), "webpack-cache"), - }, - optimization: { minimize: false }, - }; - await runWebpackBuild(webpack, config); - fs.writeFileSync(path.join(distDir, "index.html"), ` - - - - - - - Remotion Player Preview - - -
- - - -`, "utf8"); - return { distDir, entryPath }; -} - -function ensurePlayerHostServerScript(workspacePath) { - const serverPath = playerHostServerPath(workspacePath); - fs.mkdirSync(path.dirname(serverPath), { recursive: true }); - fs.writeFileSync(serverPath, `#!/usr/bin/env node -const fs = require("node:fs"); -const http = require("node:http"); -const path = require("node:path"); - -const root = path.resolve(process.argv[2]); -const port = Number(process.argv[3]); -const logPath = process.argv[4]; -const publicRoot = path.resolve(process.argv[5] || path.join(process.cwd(), "public")); -const artifactsRoot = path.resolve(process.argv[6]); -const parentPid = Number(process.argv[7]); -fs.mkdirSync(path.dirname(logPath), {recursive: true}); -function log(message) { - fs.appendFileSync(logPath, "[" + new Date().toISOString() + "] " + message + "\\n", "utf8"); -} -const mime = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".svg": "image/svg+xml", - ".webp": "image/webp", - ".avif": "image/avif", - ".bmp": "image/bmp", - ".ico": "image/x-icon", - ".gif": "image/gif", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mov": "video/quicktime", - ".m4v": "video/x-m4v", - ".ogv": "video/ogg", - ".mp3": "audio/mpeg", - ".m4a": "audio/mp4", - ".wav": "audio/wav", - ".aac": "audio/aac", - ".ogg": "audio/ogg", - ".oga": "audio/ogg", - ".opus": "audio/ogg", - ".flac": "audio/flac", - ".vtt": "text/vtt; charset=utf-8", - ".srt": "application/x-subrip; charset=utf-8", - ".wasm": "application/wasm", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", - ".otf": "font/otf" -}; -function isInside(candidate, base) { - const relative = path.relative(base, candidate); - return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative)); -} -function resolveServedFile(requested, pathname) { - if (pathname.startsWith("/artifacts/")) { - const artifactPath = path.resolve(artifactsRoot, requested); - if (isInside(artifactPath, artifactsRoot)) { - try { - const stat = fs.statSync(artifactPath); - if (stat.isFile()) return {filePath: artifactPath, source: "artifact", stat}; - } catch {} - } - return null; - } - const distPath = path.resolve(root, requested); - if (isInside(distPath, root)) { - try { - const stat = fs.statSync(distPath); - if (stat.isFile()) return {filePath: distPath, source: "dist", stat}; - } catch {} - } - const publicPath = path.resolve(publicRoot, requested); - if (isInside(publicPath, publicRoot)) { - try { - const stat = fs.statSync(publicPath); - if (stat.isFile()) return {filePath: publicPath, source: "public", stat}; - } catch {} - } - return null; -} -const server = http.createServer((request, response) => { - try { - if (request.method === "OPTIONS") { - response.writeHead(204, { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, HEAD, OPTIONS", - "access-control-allow-headers": "Range", - "access-control-max-age": "86400", - }); - response.end(); - return; - } - if (request.method !== "GET" && request.method !== "HEAD") { - response.writeHead(405, {"content-type": "text/plain; charset=utf-8", "allow": "GET, HEAD, OPTIONS"}); - response.end("Method not allowed"); - return; - } - const url = new URL(request.url || "/", "http://127.0.0.1"); - if (url.pathname === "/health") { - const body = JSON.stringify({ok: true}); - response.writeHead(200, {"content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(body), "cache-control": "no-store"}); - response.end(request.method === "HEAD" ? undefined : body); - return; - } - const isArtifact = url.pathname.startsWith("/artifacts/"); - const requested = url.pathname === "/" - ? "index.html" - : decodeURIComponent(url.pathname.slice(isArtifact ? "/artifacts/".length : 1)); - const served = resolveServedFile(requested, url.pathname); - if (!served) throw new Error("Not a file"); - const {filePath, source, stat} = served; - const etag = '"' + stat.size.toString(16) + "-" + Math.trunc(stat.mtimeMs).toString(16) + '"'; - const lastModified = stat.mtime.toUTCString(); - const baseHeaders = { - "content-type": mime[path.extname(filePath).toLowerCase()] || "application/octet-stream", - "cache-control": source === "artifact" ? "public, max-age=31536000, immutable" : source === "public" ? "no-cache" : "no-store", - "accept-ranges": "bytes", - "etag": etag, - "last-modified": lastModified, - "access-control-allow-origin": "*", - "access-control-expose-headers": "Accept-Ranges, Content-Length, Content-Range, ETag, Last-Modified", - "cross-origin-resource-policy": "cross-origin", - "cross-origin-opener-policy": "same-origin-allow-popups", - }; - const ifRange = request.headers["if-range"]; - let ifRangeMatches = !ifRange || ifRange === etag; - if (!ifRangeMatches && ifRange) { - const ifRangeDate = Date.parse(ifRange); - ifRangeMatches = Number.isFinite(ifRangeDate) && stat.mtimeMs <= ifRangeDate + 999; - } - const range = ifRangeMatches ? request.headers.range : null; - if (range) { - const match = /^bytes=(\\d*)-(\\d*)$/.exec(range); - if (!match) { - response.writeHead(416, {...baseHeaders, "content-range": "bytes */" + stat.size}); - response.end(); - return; - } - const suffixLength = match[1] === "" ? Number(match[2]) : null; - const start = suffixLength !== null ? Math.max(0, stat.size - suffixLength) : Number(match[1]); - const end = suffixLength !== null || match[2] === "" ? stat.size - 1 : Math.min(stat.size - 1, Number(match[2])); - if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || start > end || start >= stat.size) { - response.writeHead(416, {...baseHeaders, "content-range": "bytes */" + stat.size}); - response.end(); - return; - } - response.writeHead(206, { - ...baseHeaders, - "content-range": "bytes " + start + "-" + end + "/" + stat.size, - "content-length": end - start + 1, - }); - if (request.method === "HEAD") response.end(); - else fs.createReadStream(filePath, {start, end}).pipe(response); - return; - } - response.writeHead(200, {...baseHeaders, "content-length": stat.size}); - if (request.method === "HEAD") response.end(); - else fs.createReadStream(filePath).pipe(response); - } catch (error) { - response.writeHead(404, {"content-type": "text/plain; charset=utf-8"}); - response.end("Not found"); - } -}); -server.listen(port, "127.0.0.1", () => log("Player host listening on 127.0.0.1:" + port)); -function shutdown() { - server.close(() => process.exit(0)); - setTimeout(() => process.exit(0), 2000).unref(); -} -function parentIsAlive() { - if (!Number.isInteger(parentPid) || parentPid <= 0) return false; - try { - process.kill(parentPid, 0); - return true; - } catch { - return false; - } -} -const parentWatch = setInterval(() => { - if (!parentIsAlive()) shutdown(); -}, 2000); -parentWatch.unref(); -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); -`, "utf8"); - return serverPath; -} - -async function playerHostStatusForState(workspacePath, state) { - if (!state?.url || !state?.pid) { - return { ok: true, status: "stopped", ready: false, url: null, log: "" }; - } - const pid = state.serverPid || state.pid; - const alive = isProcessAlive(pid); - const health = alive ? await httpStatus(`${state.url}/health`) : { reachable: false, error: "Process is not running." }; - const ready = Boolean(alive && health.reachable); - return { - ok: true, - status: ready ? "ready" : alive ? "starting" : "stopped", - ready, - alive, - url: state.url, - pid, - serverPid: state.serverPid, - port: state.port, - compositionId: state.compositionId, - projectRevision: state.projectRevision, - descriptorRevision: state.descriptorRevision, - buildId: state.buildId, - bundleId: state.bundleId, - runtimeVersion: state.runtimeVersion, - protocolVersion: state.protocolVersion, - startedAt: state.startedAt, - distDir: state.distDir, - publicDir: state.publicDir, - logPath: state.logPath, - log: tailPlayerHostLogs(state), - health, - }; -} - -function readPlayerHostState(workspacePath) { - return readJson(playerHostStatePath(workspacePath), null); -} - -function writePlayerHostState(workspacePath, state) { - writeJson(playerHostStatePath(workspacePath), state); -} - -function spawnPlayerHostServer(workspacePath, projectRoot, distDir, port, stateBase) { - const serverPath = ensurePlayerHostServerScript(workspacePath); - const logPath = playerHostLogPath(workspacePath); - const runtimeCwd = projectRoot || workspacePath; - const publicDir = path.join(runtimeCwd, "public"); - const artifactsDir = artifactRoot(workspacePath); - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.appendFileSync(logPath, `\n[${new Date().toISOString()}] Starting Remotion Player host on 127.0.0.1:${port}\n`, "utf8"); - if (process.platform === "win32") { - const psArgs = [serverPath, distDir, String(port), logPath, publicDir, artifactsDir, String(process.pid)].map(psQuote).join(", "); - const psScript = [ - `$p = Start-Process -FilePath ${psQuote(process.execPath)} -ArgumentList @(${psArgs}) -WorkingDirectory ${psQuote(runtimeCwd)} -WindowStyle Hidden -PassThru`, - "$p.Id", - ].join("; "); - const launch = spawnSync("powershell", [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - psScript, - ], { - cwd: runtimeCwd, - encoding: "utf8", - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: 5000, - }); - if (launch.error || launch.status !== 0) { - const detail = String(launch.stderr || launch.stdout || launch.error?.message || `exit ${launch.status}`).trim(); - throw new Error(`Failed to start Remotion Player host: ${detail}`); - } - const pid = Number(String(launch.stdout || "").match(/\d+/)?.[0]); - if (!Number.isInteger(pid)) { - const detail = String(launch.stderr || launch.stdout || "Start-Process did not return a PID.").trim(); - throw new Error(`Failed to start Remotion Player host: ${detail}`); - } - return { ...stateBase, pid, serverPid: pid, parentPid: process.pid, launcher: "powershell-start-process", logPath, publicDir, artifactsDir }; - } - const child = spawn(process.execPath, [serverPath, distDir, String(port), logPath, publicDir, artifactsDir, String(process.pid)], { - cwd: runtimeCwd, - detached: false, - stdio: "ignore", - env: process.env, - }); - child.unref(); - return { ...stateBase, pid: child.pid, serverPid: child.pid, parentPid: process.pid, launcher: "node-child", logPath, publicDir, artifactsDir }; -} - -async function ensurePlayerPreviewHostResolved(input, resolved) { - const { workspacePath, projectRoot, manifest, composition, frame } = resolved; - if (manifest.schemaVersion !== RUNTIME_SCHEMA_VERSION || !manifest.entryPoint) { - throw new Error("Cannot start Player preview without a current compiled project snapshot."); - } - - const bundleId = playerHostBundleId(manifest); - const existing = readPlayerHostState(workspacePath); - const existingStatus = await playerHostStatusForState(workspacePath, existing); - const force = input.force === true; - const sameRuntime = existing?.runtimeVersion === PLAYER_HOST_RUNTIME_VERSION; - const sameBundle = sameRuntime && existing?.bundleId === bundleId && existing?.buildId === manifest.buildId; - - if (!force && sameBundle && existingStatus.ready) { - const revisionState = { - projectRevision: manifest.projectRevision || manifest.sourceRevision, - descriptorRevision: composition.descriptorRevision || manifest.descriptorRevision, - }; - writePlayerHostState(workspacePath, { ...existing, ...revisionState, compositionId: composition.id }); - return { - ...existingStatus, - ...revisionState, - renderer: "remotion-player", - reused: true, - frame, - composition, - url: `${existingStatus.url}?compositionId=${encodeURIComponent(composition.id)}&frame=${frame}`, - baseUrl: existingStatus.url, - }; - } - - if (existingStatus.alive) { - terminateProcessTree(existingStatus.pid); - } - - const reusableDist = !force - && sameBundle - && existing?.distDir - && fs.existsSync(path.join(existing.distDir, "index.html")) - && fs.existsSync(path.join(existing.distDir, "player-host.js")); - const { distDir, entryPath } = reusableDist - ? { distDir: existing.distDir, entryPath: existing.entryPath } - : await bundlePlayerHost(workspacePath, projectRoot, manifest, bundleId); - const requestedPort = Number(input.port); - const port = Number.isInteger(requestedPort) && requestedPort >= 1024 && requestedPort <= 65535 - ? requestedPort - : await findFreePort(); - const baseUrl = `http://127.0.0.1:${port}`; - const stateBase = { - ok: true, - status: "starting", - ready: false, - url: baseUrl, - baseUrl, - port, - pid: null, - serverPid: null, - renderer: "remotion-player", - bundleReused: reusableDist, - compositionId: composition.id, - projectRevision: manifest.projectRevision || manifest.sourceRevision, - descriptorRevision: composition.descriptorRevision || manifest.descriptorRevision, - buildId: manifest.buildId, - bundleId, - runtimeVersion: PLAYER_HOST_RUNTIME_VERSION, - protocolVersion: PLAYER_CONTROL_PROTOCOL_VERSION, - workspacePath, - projectRoot, - distDir, - entryPath, - startedAt: Date.now(), - }; - const state = spawnPlayerHostServer(workspacePath, projectRoot, distDir, port, stateBase); - writePlayerHostState(workspacePath, state); - const waitMs = clampNumber(input.waitMs, 0, 120_000, PLAYER_HOST_BOOT_WAIT_MS); - const health = waitMs > 0 - ? await waitForHttp(`${baseUrl}/health`, waitMs) - : { reachable: false, error: "Not waited." }; - const ready = Boolean(health.reachable); - const nextState = { - ...state, - ready, - status: ready ? "ready" : "starting", - health, - }; - writePlayerHostState(workspacePath, nextState); - return { - ...nextState, - renderer: "remotion-player", - reused: false, - frame, - composition, - url: `${baseUrl}?compositionId=${encodeURIComponent(composition.id)}&frame=${frame}`, - baseUrl, - log: tailPlayerHostLogs(nextState), - }; -} - -async function ensurePlayerPreviewHost(input = {}) { - const resolved = await compositionForInput(input); - const revision = resolved.manifest.buildId || resolved.manifest.descriptorRevision || resolved.manifest.sourceRevision; - const flightKey = `${resolved.workspacePath}:${revision}:${input.force === true ? 'force' : 'reuse'}`; - let flight = playerHostFlights.get(flightKey); - if (!flight) { - const previous = playerHostWorkspaceTails.get(resolved.workspacePath) || Promise.resolve(); - flight = previous - .catch(() => {}) - .then(() => ensurePlayerPreviewHostResolved(input, resolved)) - .finally(() => { - if (playerHostFlights.get(flightKey) === flight) playerHostFlights.delete(flightKey); - if (playerHostWorkspaceTails.get(resolved.workspacePath) === flight) { - playerHostWorkspaceTails.delete(resolved.workspacePath); - } - }); - playerHostFlights.set(flightKey, flight); - playerHostWorkspaceTails.set(resolved.workspacePath, flight); - } - - const result = await flight; - const baseUrl = result.baseUrl || result.url; - return { - ...result, - compositionId: resolved.composition.id, - composition: resolved.composition, - frame: resolved.frame, - url: `${baseUrl}?compositionId=${encodeURIComponent(resolved.composition.id)}&frame=${resolved.frame}`, - }; -} - -async function getPlayerPreviewHostStatus(input = {}) { - const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const state = readPlayerHostState(workspacePath); - const status = await playerHostStatusForState(workspacePath, state); - if (state && status.status !== state.status) { - writePlayerHostState(workspacePath, { ...state, status: status.status, ready: status.ready }); - } - return { - ...status, - renderer: "remotion-player", - }; -} - -async function stopPlayerPreviewHost(input = {}) { - const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const state = readPlayerHostState(workspacePath); - const pid = state?.serverPid || state?.pid; - if (pid) { - terminateProcessTree(pid); - } - const nextState = { - ...(state || {}), - ok: true, - status: "stopped", - ready: false, - stoppedAt: Date.now(), - }; - writePlayerHostState(workspacePath, nextState); - return { - ok: true, - status: "stopped", - ready: false, - url: state?.url || null, - pid: pid || null, - logPath: state?.logPath || null, - log: tailPlayerHostLogs(state), - renderer: "remotion-player", - }; -} - -module.exports = { - resolveWorkspaceModule, - playerHostBundleId, - playerHostProjectEntryPath, - playerHostWebpackEntries, - writePlayerHostEntry, - runWebpackBuild, - bundlePlayerHost, - ensurePlayerHostServerScript, - playerHostStatusForState, - readPlayerHostState, - writePlayerHostState, - spawnPlayerHostServer, - ensurePlayerPreviewHost, - getPlayerPreviewHostStatus, - stopPlayerPreviewHost, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project-deps.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project-deps.js deleted file mode 100644 index 20872ce1..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project-deps.js +++ /dev/null @@ -1,46 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); - -function projectModuleSearchPaths(projectRoot, workspacePath) { - return Array.from(new Set([ - projectRoot, - workspacePath, - process.cwd(), - ].filter(Boolean).map((candidate) => path.resolve(candidate)))); -} - -function resolveProjectModule(request, projectRoot, workspacePath) { - return require.resolve(request, { - paths: projectModuleSearchPaths(projectRoot, workspacePath), - }); -} - -function requireProjectModule(request, projectRoot, workspacePath) { - return require(resolveProjectModule(request, projectRoot, workspacePath)); -} - -function resolvedPackageInfo(name, projectRoot, workspacePath) { - try { - const packagePath = resolveProjectModule(`${name}/package.json`, projectRoot, workspacePath); - return JSON.parse(fs.readFileSync(packagePath, "utf8")); - } catch { - return null; - } -} - -function hasProjectModule(request, projectRoot, workspacePath) { - try { - resolveProjectModule(request, projectRoot, workspacePath); - return true; - } catch { - return false; - } -} - -module.exports = { - projectModuleSearchPaths, - resolveProjectModule, - requireProjectModule, - resolvedPackageInfo, - hasProjectModule, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project-runtime.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project-runtime.js deleted file mode 100644 index 8f02acf5..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project-runtime.js +++ /dev/null @@ -1,396 +0,0 @@ -const crypto = require("node:crypto"); -const fs = require("node:fs"); -const path = require("node:path"); -const { ensureRuntimeDir, relativeToWorkspace } = require("./paths"); -const { PROJECT_REVISION_EXTENSIONS, PROJECT_BUNDLE_CACHE_LIMIT } = require("./constants"); -const { walkFiles, safeStat, readJson, writeJsonAtomic, stableJson } = require("./util"); -const { requireProjectModule, resolvedPackageInfo } = require("./project-deps"); -const { emitStatus } = require("./protocol"); - -const RUNTIME_SCHEMA_VERSION = 3; -const STALE_BUNDLE_PIN_MS = 24 * 60 * 60 * 1000; -const revisionFileCache = new Map(); -const bundleFlights = new Map(); -const manifestFlights = new Map(); - -const REVISION_FILENAMES = new Set([ - "package.json", - "package-lock.json", - "pnpm-lock.yaml", - "yarn.lock", - "bun.lock", - "bun.lockb", - "tsconfig.json", -]); - -function isRevisionFile(filePath, projectRoot) { - const relative = path.relative(projectRoot, filePath); - const segments = relative.split(path.sep); - if (segments[0] === "public") return true; - if (REVISION_FILENAMES.has(path.basename(filePath).toLowerCase())) return true; - return PROJECT_REVISION_EXTENSIONS.has(path.extname(filePath).toLowerCase()); -} - -function digestFile(filePath, stat) { - const cacheKey = path.resolve(filePath); - const cached = revisionFileCache.get(cacheKey); - if ( - stat.size >= 1024 * 1024 - && cached - && cached.size === stat.size - && cached.mtimeMs === stat.mtimeMs - && cached.ctimeMs === stat.ctimeMs - ) { - return cached.digest; - } - const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); - revisionFileCache.set(cacheKey, { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, digest }); - return digest; -} - -function computeSourceRevision(detection) { - const projectRoot = path.resolve(detection.projectRoot); - const files = walkFiles(projectRoot, { - maxFiles: Number.POSITIVE_INFINITY, - include: (filePath) => isRevisionFile(filePath, projectRoot), - }) - .sort((a, b) => a.localeCompare(b)); - const hash = crypto.createHash("sha256"); - hash.update(`sparo-remotion-runtime:${RUNTIME_SCHEMA_VERSION}\n`); - hash.update(`entry:${path.resolve(detection.entryFile)}\n`); - hash.update(`remotion:${detection.remotionVersion || "unknown"}\n`); - for (const filePath of files) { - const stat = safeStat(filePath); - if (!stat?.isFile()) continue; - const relative = path.relative(projectRoot, filePath).replace(/\\/g, "/"); - hash.update(`${relative}\0${stat.size}\0${digestFile(filePath, stat)}\n`); - } - return hash.digest("hex").slice(0, 24); -} - -function computeInputPropsRevision(inputProps = null) { - return crypto.createHash("sha256") - .update(`sparo-remotion-input:${RUNTIME_SCHEMA_VERSION}\n${stableJson(inputProps || {})}`) - .digest("hex") - .slice(0, 24); -} - -function bundleRoot(workspacePath) { - return ensureRuntimeDir(workspacePath, "bundles"); -} - -function bundleMarkerPath(bundlePath) { - return path.join(bundlePath, ".sparo-bundle.json"); -} - -function bundlePinPath(bundlePath, ownerId) { - const safeOwner = String(ownerId).replace(/[^a-z0-9._-]+/gi, "-"); - return path.join(bundlePath, `.sparo-pin-${safeOwner}.json`); -} - -function pinProjectBundle(bundlePath, ownerId) { - const pinPath = bundlePinPath(bundlePath, ownerId); - writeJsonAtomic(pinPath, { ownerId, pinnedAt: Date.now() }); - return pinPath; -} - -function unpinProjectBundle(pinPath) { - if (pinPath) fs.rmSync(pinPath, { force: true }); -} - -function hasLiveBundlePin(bundlePath) { - const now = Date.now(); - const pins = fs.readdirSync(bundlePath, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.startsWith(".sparo-pin-")); - let live = false; - for (const pin of pins) { - const pinPath = path.join(bundlePath, pin.name); - const pinnedAt = Number(readJson(pinPath, null)?.pinnedAt) || safeStat(pinPath)?.mtimeMs || 0; - if (now - pinnedAt <= STALE_BUNDLE_PIN_MS) live = true; - else fs.rmSync(pinPath, { force: true }); - } - return live; -} - -function reusableBundle(bundlePath, sourceRevision, entryPoint) { - const marker = readJson(bundleMarkerPath(bundlePath), null); - return Boolean( - marker?.sourceRevision === sourceRevision - && marker?.entryPoint === entryPoint - && fs.existsSync(path.join(bundlePath, "index.html")), - ); -} - -function pruneBundleCache(workspacePath, activeBundlePath) { - const root = bundleRoot(workspacePath); - const directories = fs.readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && !entry.name.includes(".building-")) - .map((entry) => { - const directory = path.join(root, entry.name); - return { directory, mtimeMs: safeStat(directory)?.mtimeMs || 0 }; - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs); - const retained = new Set([path.resolve(activeBundlePath)]); - for (const item of directories) { - if (hasLiveBundlePin(item.directory)) retained.add(path.resolve(item.directory)); - } - for (const item of directories) { - if (retained.size < PROJECT_BUNDLE_CACHE_LIMIT) retained.add(path.resolve(item.directory)); - } - for (const item of directories) { - if (!retained.has(path.resolve(item.directory))) { - fs.rmSync(item.directory, { recursive: true, force: true }); - } - } -} - -async function ensureProjectBundle(detection, sourceRevision) { - const workspacePath = detection.workspacePath; - const entryPoint = relativeToWorkspace(workspacePath, detection.entryFile); - const finalPath = path.join(bundleRoot(workspacePath), sourceRevision); - if (reusableBundle(finalPath, sourceRevision, entryPoint)) return finalPath; - - const flightKey = `${workspacePath}:${sourceRevision}`; - const existing = bundleFlights.get(flightKey); - if (existing) return existing; - - const flight = (async () => { - const { bundle } = requireProjectModule("@remotion/bundler", detection.projectRoot, workspacePath); - if (typeof bundle !== "function") { - throw new Error("The project @remotion/bundler package does not expose bundle()."); - } - const temporaryPath = `${finalPath}.building-${process.pid}-${Date.now()}`; - fs.rmSync(temporaryPath, { recursive: true, force: true }); - emitStatus("Bundling the Remotion Root.", "bundling"); - try { - await bundle({ - entryPoint: detection.entryFile, - outDir: temporaryPath, - rootDir: detection.projectRoot, - publicDir: fs.existsSync(path.join(detection.projectRoot, "public")) - ? path.join(detection.projectRoot, "public") - : null, - enableCaching: true, - onProgress: (progress) => { - const percent = Math.max(0, Math.min(100, Math.round(Number(progress) * 100))); - emitStatus(`Bundling the Remotion Root (${percent}%).`, "bundling"); - }, - }); - writeJsonAtomic(bundleMarkerPath(temporaryPath), { - schemaVersion: RUNTIME_SCHEMA_VERSION, - sourceRevision, - entryPoint, - generatedAt: Date.now(), - }); - fs.rmSync(finalPath, { recursive: true, force: true }); - fs.renameSync(temporaryPath, finalPath); - pruneBundleCache(workspacePath, finalPath); - return finalPath; - } catch (error) { - fs.rmSync(temporaryPath, { recursive: true, force: true }); - throw error; - } - })().finally(() => bundleFlights.delete(flightKey)); - - bundleFlights.set(flightKey, flight); - return flight; -} - -function jsonView(value, seen = new WeakSet()) { - if (value === null || value === undefined || typeof value === "string" || typeof value === "boolean") return value ?? null; - if (typeof value === "number") return Number.isFinite(value) ? value : null; - if (typeof value === "bigint") return value.toString(); - if (value instanceof Date) return value.toISOString(); - if (Array.isArray(value)) return value.map((item) => jsonView(item, seen)); - if (value instanceof Map) return Object.fromEntries(Array.from(value.entries(), ([key, item]) => [String(key), jsonView(item, seen)])); - if (value instanceof Set) return Array.from(value, (item) => jsonView(item, seen)); - if (typeof value !== "object") return null; - if (seen.has(value)) return null; - seen.add(value); - const output = {}; - for (const [key, item] of Object.entries(value)) output[key] = jsonView(item, seen); - seen.delete(value); - return output; -} - -function serializeProps(noReact, props) { - return noReact.NoReactInternals.serializeJSONWithSpecialTypes({ - data: props || {}, - indent: undefined, - staticBase: null, - }).serializedString; -} - -function normalizeComposition(config, noReact, workspacePath, entryFile, sourceRevision) { - const resolvedProps = config.props || {}; - const defaultProps = config.defaultProps || {}; - const serializedDefaultProps = serializeProps(noReact, defaultProps); - const serializedResolvedProps = serializeProps(noReact, resolvedProps); - const width = Number(config.width); - const height = Number(config.height); - const fps = Number(config.fps); - const durationInFrames = Math.max(1, Math.round(Number(config.durationInFrames))); - const defaults = { - codec: config.defaultCodec || null, - outName: config.defaultOutName || null, - videoImageFormat: config.defaultVideoImageFormat || null, - pixelFormat: config.defaultPixelFormat || null, - proResProfile: config.defaultProResProfile || null, - sampleRate: config.defaultSampleRate || null, - }; - const descriptorRevision = crypto.createHash("sha256").update(stableJson({ - sourceRevision, - id: String(config.id), - width, - height, - fps, - durationInFrames, - serializedDefaultProps, - serializedResolvedProps, - defaults, - })).digest("hex").slice(0, 24); - return { - id: String(config.id), - descriptorRevision, - width, - height, - fps, - durationInFrames, - defaultProps: jsonView(defaultProps), - resolvedProps: jsonView(resolvedProps), - serializedDefaultProps, - serializedResolvedProps, - defaults, - sourcePath: relativeToWorkspace(workspacePath, entryFile), - }; -} - -function resolvedBuildId(sourceRevision, descriptorRevision) { - return crypto.createHash("sha256") - .update(`${sourceRevision}:${descriptorRevision}`) - .digest("hex") - .slice(0, 24); -} - -async function resolveCompositionManifest(detection, input = {}) { - const inputProps = input.inputProps && typeof input.inputProps === "object" ? input.inputProps : null; - const sourceRevision = computeSourceRevision(detection); - const inputPropsRevision = computeInputPropsRevision(inputProps); - const flightKey = `${detection.workspacePath}:${sourceRevision}:${inputPropsRevision}`; - const existingFlight = manifestFlights.get(flightKey); - if (existingFlight && input.force !== true) return existingFlight; - - const materialize = async () => { - const bundlePath = await ensureProjectBundle(detection, sourceRevision); - const renderer = requireProjectModule("@remotion/renderer", detection.projectRoot, detection.workspacePath); - const noReact = requireProjectModule("remotion/no-react", detection.projectRoot, detection.workspacePath); - if ( - typeof renderer.getCompositions !== "function" - || typeof renderer.selectComposition !== "function" - || typeof renderer.openBrowser !== "function" - ) { - throw new Error("The project @remotion/renderer package must expose openBrowser(), getCompositions(), and selectComposition()."); - } - - emitStatus("Evaluating the registered Remotion compositions.", "resolving-metadata"); - const browser = await renderer.openBrowser("chrome", { logLevel: "warn" }); - const compositions = []; - try { - const baseCompositions = await renderer.getCompositions(bundlePath, { - ...(inputProps ? { inputProps } : {}), - puppeteerInstance: browser, - logLevel: "warn", - timeoutInMilliseconds: 60_000, - }); - for (const base of baseCompositions) { - const resolved = await renderer.selectComposition({ - serveUrl: bundlePath, - id: base.id, - ...(inputProps ? { inputProps } : {}), - puppeteerInstance: browser, - logLevel: "warn", - timeoutInMilliseconds: 60_000, - }); - compositions.push(normalizeComposition( - resolved, - noReact, - detection.workspacePath, - detection.entryFile, - sourceRevision, - )); - } - } finally { - await browser.close({ silent: true }); - } - - const descriptorRevision = crypto.createHash("sha256") - .update(compositions.map((composition) => composition.descriptorRevision).join(":")) - .digest("hex") - .slice(0, 24); - const buildId = resolvedBuildId(sourceRevision, descriptorRevision); - return { - schemaVersion: RUNTIME_SCHEMA_VERSION, - engine: "sparo-video-engine", - sourceRevision, - projectRevision: sourceRevision, - inputPropsRevision, - descriptorRevision, - buildId, - generatedAt: Date.now(), - entryPoint: relativeToWorkspace(detection.workspacePath, detection.entryFile), - projectRootRelative: relativeToWorkspace(detection.workspacePath, detection.projectRoot) || ".", - bundlePath, - compositions, - }; - }; - const flight = (existingFlight && input.force === true - ? existingFlight.catch(() => null).then(materialize) - : materialize() - ).finally(() => { - if (manifestFlights.get(flightKey) === flight) manifestFlights.delete(flightKey); - }); - manifestFlights.set(flightKey, flight); - return flight; -} - -function rendererVersion(detection) { - return resolvedPackageInfo("@remotion/renderer", detection.projectRoot, detection.workspacePath)?.version || null; -} - -function deserializeCompositionProps(composition, projectRoot, workspacePath) { - if (!composition?.serializedResolvedProps) return composition?.resolvedProps || {}; - const noReact = requireProjectModule("remotion/no-react", projectRoot, workspacePath); - return noReact.NoReactInternals.deserializeJSONWithSpecialTypes(composition.serializedResolvedProps); -} - -function toRemotionVideoConfig(composition, projectRoot, workspacePath) { - return { - id: composition.id, - width: composition.width, - height: composition.height, - fps: composition.fps, - durationInFrames: composition.durationInFrames, - defaultProps: composition.defaultProps || {}, - props: deserializeCompositionProps(composition, projectRoot, workspacePath), - defaultCodec: composition.defaults?.codec || null, - defaultOutName: composition.defaults?.outName || null, - defaultVideoImageFormat: composition.defaults?.videoImageFormat || null, - defaultPixelFormat: composition.defaults?.pixelFormat || null, - defaultProResProfile: composition.defaults?.proResProfile || null, - defaultSampleRate: composition.defaults?.sampleRate || null, - }; -} - -module.exports = { - RUNTIME_SCHEMA_VERSION, - computeSourceRevision, - computeInputPropsRevision, - resolvedBuildId, - ensureProjectBundle, - resolveCompositionManifest, - rendererVersion, - deserializeCompositionProps, - toRemotionVideoConfig, - pinProjectBundle, - unpinProjectBundle, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project.js deleted file mode 100644 index 89002ce6..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/project.js +++ /dev/null @@ -1,334 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); -const { normalizeWorkspace, workspacePathOf, relativeToWorkspace, ensureRuntimeDir } = require("./paths"); -const { packageInfo, sourceFiles, dependencyVersion, packageManager, safeStat, readJson, writeJsonAtomic, walkFiles } = require("./util"); -const { detectRemotionRenderer } = require("./remotion-cli"); -const { findRemotionEntry, collectEntryPoints } = require("./source-parse"); -const { ASSET_EXTENSIONS, SOURCE_EXTENSIONS } = require("./constants"); -const { hasProjectModule, resolvedPackageInfo } = require("./project-deps"); -const { - RUNTIME_SCHEMA_VERSION, - computeInputPropsRevision, - computeSourceRevision, - resolveCompositionManifest, - rendererVersion, -} = require("./project-runtime"); - -function isWithinPath(root, candidate) { - const relative = path.relative(root, candidate); - return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function nearestPackageRoot(filePath, workspacePath) { - const workspaceRoot = path.resolve(workspacePath); - let current = fs.existsSync(filePath) && fs.statSync(filePath).isDirectory() - ? path.resolve(filePath) - : path.dirname(path.resolve(filePath)); - - while (isWithinPath(workspaceRoot, current)) { - if (fs.existsSync(path.join(current, "package.json"))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - - return workspaceRoot; -} - -function packageRootForEntry(entryFile, workspacePath) { - return entryFile ? nearestPackageRoot(entryFile, workspacePath) : workspacePath; -} - -function resolveRequestedEntry(workspacePath, entryPoint) { - const workspaceRoot = fs.realpathSync(workspacePath); - const candidate = path.resolve(workspaceRoot, String(entryPoint)); - if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) { - throw new Error(`Remotion entry point is not a file: ${entryPoint}`); - } - const canonical = fs.realpathSync(candidate); - if (!isWithinPath(workspaceRoot, canonical)) { - throw new Error(`Remotion entry point must stay inside the workspace: ${entryPoint}`); - } - if (!SOURCE_EXTENSIONS.has(path.extname(canonical).toLowerCase())) { - throw new Error(`Unsupported Remotion entry point extension: ${entryPoint}`); - } - return canonical; -} - -function detectProject(input = {}) { - const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const files = sourceFiles(workspacePath); - const entryFile = input.entryPoint - ? resolveRequestedEntry(workspacePath, input.entryPoint) - : findRemotionEntry(workspacePath, files); - const projectRoot = packageRootForEntry(entryFile, workspacePath); - const projectFiles = sourceFiles(projectRoot); - const pkg = packageInfo(projectRoot); - const entryPoint = entryFile ? relativeToWorkspace(workspacePath, entryFile) : null; - const resolvedRemotionPackage = resolvedPackageInfo("remotion", projectRoot, workspacePath); - const hasRemotionDependency = Boolean(dependencyVersion(pkg, "remotion") || resolvedRemotionPackage?.version); - const entryPoints = collectEntryPoints(workspacePath, projectFiles, entryPoint); - const runnableEntryPoints = entryPoints.filter((entry) => entry.source !== "config"); - const requiredModules = ["remotion", "@remotion/bundler", "@remotion/renderer"]; - const missingDependencies = requiredModules.filter((request) => !hasProjectModule(request, projectRoot, workspacePath)); - const hasNodeModules = missingDependencies.length === 0; - - const diagnostics = []; - if (!entryPoint) diagnostics.push({ level: "error", source: "detectProject", message: "No Remotion entry point found." }); - if (!hasRemotionDependency) diagnostics.push({ level: "warning", source: "package.json", message: "Package does not declare a remotion dependency." }); - if (entryPoint && hasRemotionDependency && !hasNodeModules) diagnostics.push({ level: "warning", source: "node_modules", message: "Dependencies are not installed (node_modules is missing)." }); - - const hasRemotionSignal = hasRemotionDependency || entryPoints.length > 0; - let status; - if (!hasRemotionSignal && !entryPoint) { - status = "notFound"; - } else if (!entryPoint || (hasRemotionDependency && !hasNodeModules)) { - status = "broken"; - } else if (runnableEntryPoints.length > 1 && !input.entryPoint) { - status = "ambiguous"; - } else { - status = "matched"; - } - const confidence = status === "matched" ? 0.9 - : status === "ambiguous" ? 0.6 - : status === "broken" ? 0.3 - : 0; - const errorSummary = status === "broken" - ? (!entryPoint - ? "Recognized Remotion sources but no usable entry point." - : "Remotion dependencies are not installed. Install dependencies to preview.") - : status === "notFound" - ? "This workspace does not look like a Remotion project." - : null; - - return { - ok: Boolean(entryPoint && hasRemotionDependency && missingDependencies.length === 0), - status, - confidence, - workspacePath, - projectRoot, - entryFile, - projectRootRelative: relativeToWorkspace(workspacePath, projectRoot) || ".", - projectName: pkg.name || path.basename(workspacePath), - packageManager: packageManager(projectRoot, workspacePath), - entryPoint, - selectedEntryPoint: entryPoint, - entryPoints, - rootFile: entryPoint, - remotionVersion: dependencyVersion(pkg, "remotion") || resolvedRemotionPackage?.version || null, - renderer: detectRemotionRenderer(projectRoot, workspacePath), - hasNodeModules, - missingDependencies, - sourceFileCount: projectFiles.length, - diagnostics, - errorSummary, - }; -} - -async function buildManifest(input = {}, detection = detectProject(input)) { - if (!detection.ok || !detection.entryFile) { - throw new Error(detection.errorSummary || "No runnable Remotion project was detected."); - } - return resolveCompositionManifest(detection, input); -} - -async function compileProject(input = {}) { - const detection = detectProject(input); - const workspacePath = detection.workspacePath; - const manifest = await buildManifest(input, detection); - const assets = indexAssetsForDetection(detection).assets; - const diagnostics = [ - ...detection.diagnostics, - ...(manifest.compositions.length ? [] : [{ level: "error", source: "compileProject", message: "No compositions were found." }]), - ]; - const changes = gitChanges(workspacePath); - const output = { - ok: manifest.compositions.length > 0, - buildId: manifest.buildId, - project: detection, - renderer: detection.renderer, - manifest, - assets, - diagnostics, - changes, - }; - writeJsonAtomic(path.join(ensureRuntimeDir(workspacePath), "engine-state.json"), output); - return output; -} - -async function getCompositionManifest(input = {}) { - const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const statePath = path.join(ensureRuntimeDir(workspacePath), "engine-state.json"); - const state = readJson(statePath, null); - const project = detectProject(input); - if (!project.entryFile) throw new Error(project.errorSummary || "No runnable Remotion project was detected."); - const inputProps = input.inputProps && typeof input.inputProps === "object" ? input.inputProps : null; - const currentRevision = computeSourceRevision(project); - const currentInputPropsRevision = computeInputPropsRevision(inputProps); - if ( - input.force !== true - && state?.manifest?.schemaVersion === RUNTIME_SCHEMA_VERSION - && state.manifest.sourceRevision === currentRevision - && state.manifest.inputPropsRevision === currentInputPropsRevision - ) { - return { ok: true, manifest: state.manifest, project }; - } - const manifest = await resolveCompositionManifest(project, input); - writeJsonAtomic(statePath, { - ...(state && typeof state === "object" ? state : {}), - ok: manifest.compositions.length > 0, - buildId: manifest.buildId, - project, - renderer: project.renderer, - manifest, - diagnostics: project.diagnostics || [], - }); - return { ok: true, manifest, project }; -} - -async function getFrameDescriptor(input = {}) { - const { manifest, composition, frame } = await compositionForInput(input); - return { - ok: true, - compositionId: composition.id, - frame, - timeSeconds: frame / composition.fps, - projectRevision: manifest.projectRevision || manifest.sourceRevision, - descriptorRevision: composition.descriptorRevision || manifest.descriptorRevision, - frameState: "descriptor-only", - composition, - sequences: [], - activeSequences: [], - layers: [], - contextSource: "project-descriptor", - diagnostics: [], - }; -} - -function indexAssetsForDetection(detection) { - const workspacePath = detection.workspacePath; - const assets = walkFiles(detection.projectRoot || workspacePath, { - maxFiles: 8000, - include: (filePath) => ASSET_EXTENSIONS.has(path.extname(filePath).toLowerCase()), - }) - .map((filePath) => { - const ext = path.extname(filePath).toLowerCase(); - const type = ASSET_EXTENSIONS.get(ext); - if (!type) return null; - const stat = safeStat(filePath); - return { - type, - name: path.basename(filePath), - path: relativeToWorkspace(workspacePath, filePath), - bytes: stat?.size || 0, - }; - }) - .filter(Boolean) - .slice(0, 500); - return { ok: true, assets, truncated: assets.length >= 500 }; -} - -function indexAssets(input = {}) { - return indexAssetsForDetection(detectProject(input)); -} - -function gitChanges(workspacePath) { - const result = spawnSync("git", ["status", "--short"], { - cwd: workspacePath, - encoding: "utf8", - timeout: 5000, - windowsHide: true, - }); - if (result.error || result.status !== 0) return []; - return String(result.stdout || "") - .split(/\r?\n/) - .map((line) => line.trimEnd()) - .filter(Boolean) - .slice(0, 200) - .map((line) => ({ - status: line.slice(0, 2).trim() || "changed", - path: line.slice(3).trim(), - })); -} - -function readDiagnostics(input = {}) { - const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const state = readJson(path.join(ensureRuntimeDir(workspacePath), "engine-state.json"), null); - return { - ok: true, - diagnostics: state?.diagnostics || detectProject(input).diagnostics, - changes: gitChanges(workspacePath), - }; -} - -async function compositionForInput(input = {}) { - const workspacePath = normalizeWorkspace(workspacePathOf(input)); - const statePath = path.join(ensureRuntimeDir(workspacePath), "engine-state.json"); - const state = readJson(statePath, null); - const manifest = state?.manifest; - if (manifest?.schemaVersion !== RUNTIME_SCHEMA_VERSION || !manifest.sourceRevision || !manifest.inputPropsRevision) { - throw new Error("No resolved Remotion snapshot is available. Compile the project before running a snapshot-bound operation."); - } - - const project = detectProject({ - ...input, - entryPoint: input.entryPoint || manifest.entryPoint, - }); - if (!project.ok || !project.entryFile) { - throw new Error(project.errorSummary || "The resolved Remotion snapshot no longer has a runnable project entry point."); - } - const inputProps = input.inputProps && typeof input.inputProps === "object" ? input.inputProps : null; - const currentProjectRevision = computeSourceRevision(project); - if (currentProjectRevision !== manifest.sourceRevision) { - throw new Error( - `Stale Remotion source snapshot. snapshot=${manifest.sourceRevision}, current=${currentProjectRevision}. Compile the project again.`, - ); - } - const currentInputPropsRevision = computeInputPropsRevision(inputProps); - if (currentInputPropsRevision !== manifest.inputPropsRevision) { - throw new Error( - `Stale Remotion input snapshot. snapshot=${manifest.inputPropsRevision}, current=${currentInputPropsRevision}. Compile the project again with the intended input props.`, - ); - } - - const projectRoot = project.projectRoot || workspacePath; - const compositionId = String(input.compositionId || "").trim() || manifest.compositions[0]?.id; - const composition = manifest.compositions.find((item) => item.id === compositionId); - if (!composition) throw new Error(`Composition not found: ${compositionId || "(none)"}`); - const projectRevision = manifest.projectRevision || manifest.sourceRevision; - const descriptorRevision = composition.descriptorRevision || manifest.descriptorRevision; - const expectedProjectRevision = String(input.expectedProjectRevision || "").trim(); - const expectedDescriptorRevision = String(input.expectedDescriptorRevision || "").trim(); - if (!expectedProjectRevision || !expectedDescriptorRevision) { - throw new Error("expectedProjectRevision and expectedDescriptorRevision are required for a snapshot-bound Remotion operation."); - } - if (expectedProjectRevision !== projectRevision || expectedDescriptorRevision !== descriptorRevision) { - throw new Error( - `Stale Remotion snapshot. expected=${expectedProjectRevision}/${expectedDescriptorRevision}, current=${projectRevision}/${descriptorRevision}`, - ); - } - const frame = Math.max(0, Math.min(Math.round(Number(input.frame) || 0), composition.durationInFrames - 1)); - return { - workspacePath, - projectRoot, - manifest, - composition, - frame, - rendererVersion: rendererVersion(project), - }; -} - -module.exports = { - detectProject, - buildManifest, - compileProject, - getCompositionManifest, - getFrameDescriptor, - indexAssets, - gitChanges, - readDiagnostics, - compositionForInput, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/protocol.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/protocol.js deleted file mode 100644 index 166d5eea..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/protocol.js +++ /dev/null @@ -1,35 +0,0 @@ -const fs = require("node:fs"); -const { AsyncLocalStorage } = require("node:async_hooks"); - -const requestContext = new AsyncLocalStorage(); - -function emit(event) { - const line = `${JSON.stringify(event)}\n`; - try { - fs.writeSync(1, line); - } catch { - process.stdout.write(line); - } -} - -function emitStatus(message, phase = "running") { - emitRunEvent({ type: "run.status", status: "running", phase, message }); -} - -function emitRunEvent(event) { - const context = requestContext.getStore(); - if (!context?.runId || !context?.bridgeId) { - throw new Error("Bridge runtime event emitted outside a request context."); - } - emit({ bridgeId: context.bridgeId, runId: context.runId, event }); -} - -function runWithRequestContext(context, callback) { - return requestContext.run(context, callback); -} - -module.exports = { - emitRunEvent, - emitStatus, - runWithRequestContext, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/remotion-cli.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/remotion-cli.js deleted file mode 100644 index 9645f8ac..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/remotion-cli.js +++ /dev/null @@ -1,65 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { packageManager, packageInfo, dependencyVersion } = require("./util"); -const { resolveProjectModule, resolvedPackageInfo } = require("./project-deps"); - -function remotionCommand(workspacePath, projectRoot = workspacePath) { - let localCli = null; - try { - localCli = resolveProjectModule("@remotion/cli/remotion-cli.js", projectRoot, workspacePath); - } catch { - const packageInfo = resolvedPackageInfo("@remotion/cli", projectRoot, workspacePath); - if (packageInfo) { - try { - const packagePath = resolveProjectModule("@remotion/cli/package.json", projectRoot, workspacePath); - const candidate = path.join(path.dirname(packagePath), "remotion-cli.js"); - if (fs.existsSync(candidate)) localCli = candidate; - } catch { - localCli = null; - } - } - } - if (localCli && fs.existsSync(localCli)) { - return { - command: process.execPath, - argsPrefix: [localCli], - source: "local-cli-js", - shell: false, - }; - } - - const localName = process.platform === "win32" ? "remotion.cmd" : "remotion"; - const localBinary = path.join(projectRoot, "node_modules", ".bin", localName); - if (fs.existsSync(localBinary)) { - return { - command: localBinary, - argsPrefix: [], - source: "local-bin", - shell: process.platform === "win32", - }; - } - - const manager = packageManager(projectRoot, workspacePath); - if (manager === "pnpm") return { command: "pnpm", argsPrefix: ["exec", "remotion"], source: "pnpm" }; - if (manager === "yarn") return { command: "yarn", argsPrefix: ["remotion"], source: "yarn" }; - if (manager === "bun") return { command: "bunx", argsPrefix: ["remotion"], source: "bunx" }; - return { command: "npx", argsPrefix: ["remotion"], source: "npx" }; -} - -function detectRemotionRenderer(projectRoot, workspacePath = projectRoot) { - const pkg = packageInfo(projectRoot); - const command = remotionCommand(workspacePath, projectRoot); - const rendererPackage = resolvedPackageInfo("@remotion/renderer", projectRoot, workspacePath); - const remotionPackage = resolvedPackageInfo("remotion", projectRoot, workspacePath); - return { - kind: "remotion-renderer", - available: Boolean(rendererPackage), - version: rendererPackage?.version || remotionPackage?.version || dependencyVersion(pkg, "remotion") || null, - command: command.source, - }; -} - -module.exports = { - remotionCommand, - detectRemotionRenderer, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/render.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/render.js deleted file mode 100644 index 6b5ada83..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/render.js +++ /dev/null @@ -1,92 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { safeFilePart, clampNumber, safeStat } = require("./util"); -const { compositionForInput } = require("./project"); -const { requireProjectModule } = require("./project-deps"); -const { deserializeCompositionProps, toRemotionVideoConfig, pinProjectBundle, unpinProjectBundle } = require("./project-runtime"); -const { artifactDirectory, describeArtifact, pruneArtifactCache } = require("./artifacts"); -const { DEFAULT_STILL_SCALE, REMOTION_RENDER_TIMEOUT_MS } = require("./constants"); - -function renderedFramePath(workspacePath, compositionId, frame, scale, buildId) { - const scalePart = String(scale).replace(/[^0-9.]+/g, "").replace(".", "p") || "1"; - const outputDir = artifactDirectory(workspacePath, "stills"); - const outputPath = path.join( - outputDir, - `${safeFilePart(compositionId)}-frame-${frame}-scale-${scalePart}-${safeFilePart(buildId)}.png`, - ); - return { outputDir, outputPath }; -} - -async function renderStill(input = {}) { - const { workspacePath, projectRoot, manifest, composition, frame, rendererVersion } = await compositionForInput(input); - const scale = clampNumber(input.scale, 0.05, 1, DEFAULT_STILL_SCALE); - const { outputDir, outputPath } = renderedFramePath( - workspacePath, - composition.id, - frame, - scale, - manifest.buildId, - ); - const useCache = input.force !== true && fs.existsSync(outputPath); - let contentType = "image/png"; - - if (!useCache) { - fs.mkdirSync(outputDir, { recursive: true }); - const renderer = requireProjectModule("@remotion/renderer", projectRoot, workspacePath); - if (typeof renderer.renderStill !== "function") { - throw new Error("The project @remotion/renderer package does not expose renderStill()."); - } - const bundlePinPath = pinProjectBundle( - manifest.bundlePath, - `still-${process.pid}-${composition.id}-${frame}-${Date.now()}`, - ); - try { - const result = await renderer.renderStill({ - serveUrl: manifest.bundlePath, - composition: toRemotionVideoConfig(composition, projectRoot, workspacePath), - inputProps: deserializeCompositionProps(composition, projectRoot, workspacePath), - output: outputPath, - frame, - scale, - imageFormat: "png", - overwrite: true, - logLevel: "warn", - timeoutInMilliseconds: REMOTION_RENDER_TIMEOUT_MS, - }); - contentType = result?.contentType || contentType; - } catch (error) { - fs.rmSync(outputPath, { force: true }); - throw error; - } finally { - unpinProjectBundle(bundlePinPath); - } - } - - const stat = safeStat(outputPath); - if (!stat?.isFile()) throw new Error(`Remotion did not produce an image: ${outputPath}`); - pruneArtifactCache(workspacePath, outputPath); - - return { - status: "completed", - kind: "remotion-still", - ok: true, - renderer: "@remotion/renderer", - rendererVersion, - cached: useCache, - sourceRevision: manifest.sourceRevision, - projectRevision: manifest.projectRevision || manifest.sourceRevision, - descriptorRevision: composition.descriptorRevision || manifest.descriptorRevision, - compositionId: composition.id, - frame, - scale, - width: Math.max(1, Math.round(Number(composition.width) * scale)), - height: Math.max(1, Math.round(Number(composition.height) * scale)), - contentType, - ...describeArtifact(workspacePath, outputPath), - diagnostics: [], - }; -} - -module.exports = { - renderStill, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/source-parse.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/source-parse.js deleted file mode 100644 index 2871a833..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/source-parse.js +++ /dev/null @@ -1,42 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { readText } = require("./util"); -const { relativeToWorkspace } = require("./paths"); - -function findRemotionEntry(workspacePath, files) { - const explicit = ["src/index.ts", "src/index.tsx", "src/main.ts", "src/main.tsx"] - .map((relative) => path.join(workspacePath, relative)) - .find((candidate) => fs.existsSync(candidate)); - const withRegisterRoot = files.find((filePath) => { - try { - return readText(filePath).includes("registerRoot"); - } catch { - return false; - } - }); - return withRegisterRoot || explicit || null; -} - -function collectEntryPoints(workspacePath, files, primaryEntry) { - const candidates = new Map(); - const add = (relativePath, source, confidence) => { - if (!relativePath) return; - const previous = candidates.get(relativePath); - if (!previous || confidence > previous.confidence) { - candidates.set(relativePath, { path: relativePath, source, confidence }); - } - }; - for (const filePath of files) { - const relative = relativeToWorkspace(workspacePath, filePath); - const source = readText(filePath) || ""; - if (source.includes("registerRoot(")) add(relative, "registerRoot", 0.95); - else if (/(^|[\\/])remotion\.config\.[tj]sx?$/.test(relative)) add(relative, "config", 0.7); - } - if (primaryEntry && !candidates.has(primaryEntry)) add(primaryEntry, "explicit", 0.85); - return Array.from(candidates.values()).sort((a, b) => b.confidence - a.confidence); -} - -module.exports = { - findRemotionEntry, - collectEntryPoints, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/util.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/util.js deleted file mode 100644 index 1e180452..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/src/util.js +++ /dev/null @@ -1,314 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const net = require("node:net"); -const crypto = require("node:crypto"); -const { spawnSync } = require("node:child_process"); -const { IGNORED_DIRS, SOURCE_EXTENSIONS } = require("./constants"); - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function tailFile(filePath, maxBytes = 6000) { - try { - const stat = fs.statSync(filePath); - const start = Math.max(0, stat.size - maxBytes); - const fd = fs.openSync(filePath, "r"); - const buffer = Buffer.alloc(stat.size - start); - fs.readSync(fd, buffer, 0, buffer.length, start); - fs.closeSync(fd); - return buffer.toString("utf8"); - } catch { - return ""; - } -} - -function tailPreviewLogs(state) { - return [ - tailFile(state?.logPath), - tailFile(state?.stdoutLogPath), - tailFile(state?.stderrLogPath), - ].filter(Boolean).join("\n"); -} - -function tailPlayerHostLogs(state) { - return [ - tailFile(state?.logPath), - tailFile(state?.stderrLogPath), - ].filter(Boolean).join("\n"); -} - -function psQuote(value) { - return `'${String(value).replace(/'/g, "''")}'`; -} - -function jsString(value) { - return JSON.stringify(String(value).replace(/\\/g, "/")); -} - -function relativeImport(fromDir, toFile) { - let specifier = path.relative(fromDir, toFile).replace(/\\/g, "/"); - if (!specifier.startsWith(".")) specifier = `./${specifier}`; - return specifier; -} - -function sideEffectImportsForSource(entryDir, sourcePath) { - if (!sourcePath || !fs.existsSync(sourcePath)) return ""; - const source = readText(sourcePath); - const imports = []; - const sideEffectImportPattern = /(^|\n)\s*import\s+["']([^"']+)["']\s*;?/g; - for (const match of source.matchAll(sideEffectImportPattern)) { - const specifier = match[2]; - if (!specifier) continue; - const resolvedSpecifier = specifier.startsWith(".") - ? relativeImport(entryDir, path.resolve(path.dirname(sourcePath), specifier)) - : specifier; - imports.push(`import ${jsString(resolvedSpecifier)};`); - } - return imports.join("\n"); -} - -function isProcessAlive(pid) { - const normalized = Number(pid); - if (!Number.isInteger(normalized) || normalized <= 0) return false; - try { - process.kill(normalized, 0); - return true; - } catch { - return false; - } -} - -function terminateProcessTree(pid) { - const normalized = Number(pid); - if (!Number.isInteger(normalized) || normalized <= 0) return; - try { - if (process.platform === "win32") { - const killed = spawnSync("taskkill", ["/PID", String(normalized), "/T", "/F"], { - windowsHide: true, - stdio: "ignore", - }); - if (killed.status !== 0) { - spawnSync("powershell", [ - "-NoProfile", - "-Command", - `Stop-Process -Id ${normalized} -Force -ErrorAction SilentlyContinue`, - ], { - windowsHide: true, - stdio: "ignore", - }); - } - return; - } - try { - process.kill(-normalized, "SIGTERM"); - } catch { - process.kill(normalized, "SIGTERM"); - } - } catch { - // Best-effort cleanup; status polling will surface if the process stays alive. - } -} - -function findFreePort(host = "127.0.0.1") { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, host, () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close(() => { - if (port) resolve(port); - else reject(new Error("Could not allocate a preview server port.")); - }); - }); - }); -} - -async function httpStatus(url, timeoutMs = 1200) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - method: "GET", - signal: controller.signal, - redirect: "manual", - }); - return { reachable: true, statusCode: response.status }; - } catch (error) { - return { - reachable: false, - error: error instanceof Error ? error.message : String(error), - }; - } finally { - clearTimeout(timer); - } -} - -async function waitForHttp(url, timeoutMs) { - const startedAt = Date.now(); - let last = null; - while (Date.now() - startedAt < timeoutMs) { - last = await httpStatus(url, 1500); - if (last.reachable) return last; - await sleep(500); - } - return last || { reachable: false, error: "Timed out waiting for preview server." }; -} - -function readJson(filePath, fallback = null) { - try { - return JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { - return fallback; - } -} - -function writeJson(filePath, value) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -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; - } - } -} - -function readText(filePath) { - return fs.readFileSync(filePath, "utf8"); -} - -function safeStat(filePath) { - try { - return fs.statSync(filePath); - } catch { - return null; - } -} - -function walkFiles(root, options = {}) { - const maxFiles = options.maxFiles === Number.POSITIVE_INFINITY - ? Number.POSITIVE_INFINITY - : Number.isFinite(options.maxFiles) && options.maxFiles > 0 - ? Math.floor(options.maxFiles) - : 5000; - const include = typeof options.include === "function" ? options.include : null; - const files = []; - const stack = [root]; - while (stack.length && files.length < maxFiles) { - const current = stack.pop(); - const entries = fs.readdirSync(current, { withFileTypes: true }); - for (const entry of entries) { - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - if (!IGNORED_DIRS.has(entry.name)) stack.push(absolute); - } else if (entry.isFile()) { - if (include && !include(absolute)) continue; - files.push(absolute); - if (files.length >= maxFiles) break; - } - } - } - return files; -} - -function sourceFiles(workspacePath) { - return walkFiles(workspacePath, { - maxFiles: 25_000, - include: (filePath) => SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase()), - }) - .filter((filePath) => (safeStat(filePath)?.size || 0) < 750_000); -} - -function packageManager(startPath, boundaryPath = startPath) { - let current = path.resolve(startPath); - const boundary = path.resolve(boundaryPath); - while (true) { - if (fs.existsSync(path.join(current, "pnpm-lock.yaml"))) return "pnpm"; - if (fs.existsSync(path.join(current, "yarn.lock"))) return "yarn"; - if (fs.existsSync(path.join(current, "bun.lock")) || fs.existsSync(path.join(current, "bun.lockb"))) return "bun"; - if (fs.existsSync(path.join(current, "package-lock.json"))) return "npm"; - if (current === boundary) break; - const parent = path.dirname(current); - const relative = path.relative(boundary, parent); - if (parent === current || relative.startsWith("..") || path.isAbsolute(relative)) break; - current = parent; - } - return "npm"; -} - -function packageInfo(workspacePath) { - return readJson(path.join(workspacePath, "package.json"), {}) || {}; -} - -function dependencyVersion(pkg, name) { - return ( - pkg.dependencies?.[name] || - pkg.devDependencies?.[name] || - pkg.peerDependencies?.[name] || - "" - ); -} - -function hashContent(value) { - return crypto.createHash("sha1").update(String(value)).digest("hex").slice(0, 12); -} - -function stableJson(value) { - if (value === null || typeof value !== "object") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; - return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; -} - -function clampNumber(value, min, max, fallback) { - const number = Number(value); - if (!Number.isFinite(number)) return fallback; - return Math.min(max, Math.max(min, number)); -} - -function safeFilePart(value) { - return String(value || "composition").replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "") || "composition"; -} - -module.exports = { - sleep, - tailFile, - tailPreviewLogs, - tailPlayerHostLogs, - psQuote, - jsString, - relativeImport, - sideEffectImportsForSource, - isProcessAlive, - terminateProcessTree, - findFreePort, - httpStatus, - waitForHttp, - readJson, - writeJson, - writeJsonAtomic, - readText, - safeStat, - walkFiles, - sourceFiles, - packageManager, - packageInfo, - dependencyVersion, - hashContent, - stableJson, - clampNumber, - safeFilePart, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/worker.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/worker.js deleted file mode 100644 index 4bb9c916..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/bridges/builtin-remotion-runtime/source/worker.js +++ /dev/null @@ -1,78 +0,0 @@ -const readline = require("node:readline"); -const { emitRunEvent, runWithRequestContext } = require("./src/protocol"); -const { detectProject, compileProject, getCompositionManifest, getFrameDescriptor, indexAssets, readDiagnostics } = require("./src/project"); -const { renderStill } = require("./src/render"); -const { ensurePlayerPreviewHost, getPlayerPreviewHostStatus, stopPlayerPreviewHost } = require("./src/player-host"); -const { startExport, getExportStatus, cancelExport } = require("./src/export"); - -async function dispatchAction(action, input) { - switch (action) { - case "detectProject": return detectProject(input); - case "compileProject": return compileProject(input); - case "getCompositionManifest": return getCompositionManifest(input); - case "getFrameDescriptor": return getFrameDescriptor(input); - case "ensurePlayerPreviewHost": return ensurePlayerPreviewHost(input); - case "getPlayerPreviewHostStatus": return getPlayerPreviewHostStatus(input); - case "stopPlayerPreviewHost": return stopPlayerPreviewHost(input); - case "renderStill": return renderStill(input); - case "startExport": return startExport(input); - case "getExportStatus": return getExportStatus(input); - case "cancelExport": return cancelExport(input); - case "indexAssets": return indexAssets(input); - case "readDiagnostics": return readDiagnostics(input); - default: throw new Error(`Unsupported Sparo Video Engine action: ${action}`); - } -} - -async function handleRequest(request) { - const runId = request.runId || request.run_id || `remotion-${Date.now()}`; - const bridgeId = request.bridgeId || request.bridge_id || "builtin-remotion-runtime"; - const topLevelWorkspacePath = request.workspacePath || request.workspace_path || null; - const input = { - ...(request.input && typeof request.input === "object" ? request.input : {}), - ...(topLevelWorkspacePath ? { workspacePath: topLevelWorkspacePath } : {}), - }; - return runWithRequestContext({ bridgeId, runId }, async () => { - emitRunEvent({ type: "run.started", run_id: runId }); - try { - const output = await dispatchAction(request.action, input); - emitRunEvent({ type: "run.completed", output }); - } catch (error) { - emitRunEvent({ - type: "run.failed", - error: { message: error instanceof Error ? error.message : String(error) }, - }); - } - }); -} - -async function main() { - const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - const activeRequests = new Set(); - 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; - } - const active = handleRequest(request).finally(() => activeRequests.delete(active)); - activeRequests.add(active); - } - if (!sawRequest) { - process.stderr.write("No Bridge worker request received on stdin\n"); - process.exitCode = 1; - } - await Promise.allSettled(activeRequests); -} - -main().catch((error) => { - process.stderr.write(`Bridge worker failed: ${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/component.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/component.json deleted file mode 100644 index cbfeb7ac..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/component.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "id": "builtin-remotion-live-surface", - "kind": "surface", - "name": "Remotion Preview Workbench", - "description": "The app-owned Remotion preview, timeline, selection, and export surface.", - "packageSource": "appPrivate", - "ownerApp": { - "appId": "builtin-remotion-live", - "appVersion": "19.0.0" - }, - "capabilities": [ - { - "id": "interactiveSurface", - "title": "Interactive surface", - "description": "Hosts the Remotion Live primary workspace.", - "actions": [ - "open", - "refreshPreview", - "selectFrame", - "startExport", - "getExportStatus", - "cancelExport" - ] - } - ], - "permissions": [ - { - "kind": "fs", - "summary": "Reads project source and writes app-owned preview/export state.", - "scopes": [ - "{workspace}", - "{appdata}" - ] - }, - { - "kind": "net", - "summary": "Embeds localhost preview hosts started by the Remotion runtime component.", - "scopes": [ - "http://127.0.0.1:*", - "http://localhost:*" - ] - }, - { - "kind": "iframe", - "summary": "Allows the embedded Remotion Player to start playback and enter fullscreen.", - "scopes": [ - "autoplay", - "fullscreen" - ] - } - ], - "usedByApps": [ - "builtin-remotion-live" - ], - "visibility": "appDependency", - "dependencies": [ - { - "componentId": "remotion-video-agent", - "kind": "agent", - "source": "private", - "role": "remotionAgent", - "capabilities": [ - "understandVideoProject", - "reviseVideoComposition", - "renderReviewArtifact" - ] - }, - { - "componentId": "builtin-remotion-runtime", - "kind": "bridge", - "source": "private", - "role": "remotionRuntime", - "capabilities": [ - "sparo.videoEngine" - ] - }, - { - "componentId": "product-app-runtime-host", - "kind": "runtime", - "source": "shared", - "role": "runtimeHost", - "version": "^1.0.0", - "capabilities": [ - "uiWorker" - ] - } - ], - "implementationRef": "app://builtin-remotion-live@19.0.0/surfaces/builtin-remotion-live-surface", - "usesCapabilities": [ - "session.read", - "component.run" - ] -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/esm_dependencies.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/esm_dependencies.json deleted file mode 100644 index fe51488c..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/esm_dependencies.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/index.html b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/index.html deleted file mode 100644 index b755e0ed..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/index.html +++ /dev/null @@ -1,5 +0,0 @@ -
-
- Loading Remotion Live… -
-
diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/source_manifest.json b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/source_manifest.json deleted file mode 100644 index 067c84e8..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/source_manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "uiEntry": "ui.js", - "workerEntry": "worker.js", - "styleEntries": [ - "style.css" - ], - "buildMode": "nativeEsm" -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/actions.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/actions.js deleted file mode 100644 index 0976052c..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/actions.js +++ /dev/null @@ -1,375 +0,0 @@ -import { callBackend } from './backend.js'; -import { compositionDuration, currentComposition, defaultPreviewFrame, normalizeManifest } from './model.js'; -import * as playerProtocol from './player-protocol.js'; -import { resetPlayerChannelConnection } from './player-dom.js'; -import { playerPreviewReady, resetPlayerRuntimeState } from './preview-controller.js'; -import { clearPlayerHostPoll, ensurePlayerPreviewHost } from './preview-runtime.js'; -import { refreshSelectionDom, render, setError, updateTimelineDom } from './render-core.js'; -import { buildSelectedVideoContext, selectionContextSentence } from './selection-geom.js'; -import { state } from './state.js'; -import { clamp, projectName, round2, runtime, t } from './util.js'; -import { isFrameCommitted } from './views.js'; - -let exportPollTimer = null; -let exportGeneration = 0; -let projectLoadGeneration = 0; -let projectLoadRequest = null; -const ACTIVE_EXPORT_STATUSES = new Set(['queued', 'running', 'cancelling']); - -function applyProjectOutput(output) { - const previousBuildId = state.manifest?.buildId || null; - state.project = output?.project || output?.detection || output || null; - state.manifest = normalizeManifest(output); - const nextBuildId = state.manifest?.buildId || null; - if (previousBuildId && nextBuildId && previousBuildId !== nextBuildId) { - state.playerHost = null; - resetPlayerChannelConnection({ rotateNonce: true }); - resetPlayerRuntimeState(); - } - - const composition = currentComposition(); - if (!state.activeCompositionId && composition) { - state.activeCompositionId = composition.id; - state.frame = defaultPreviewFrame(composition); - state.frameTouched = false; - } else if (!state.frameTouched && composition) { - state.frame = defaultPreviewFrame(composition); - } -} - -function selectEntry(entryPoint) { - if (!entryPoint) return; - state.selectedEntry = entryPoint; - void refreshProject(); -} - -async function loadProject(generation, workspacePath, entryPoint, freshSnapshot) { - const input = entryPoint ? { entryPoint } : {}; - const isCurrent = () => ( - generation === projectLoadGeneration - && state.workspacePath === workspacePath - && state.selectedEntry === entryPoint - ); - if (!workspacePath) { - state.project = null; - state.manifest = null; - state.detection = null; - state.phase = 'idle'; - render(); - return; - } - - state.phase = 'detecting'; - render(); - try { - const detection = await callBackend('detectProject', input); - if (!isCurrent()) return; - state.detection = detection || null; - const status = detection?.status || (detection?.ok ? 'matched' : 'notFound'); - if (status === 'notFound' || status === 'broken' || status === 'ambiguous') { - state.project = detection || null; - state.manifest = null; - state.activeCompositionId = null; - state.phase = status; - state.error = null; - return; - } - - state.phase = freshSnapshot ? 'bundling' : 'snapshot'; - render(); - const output = await callBackend(freshSnapshot ? 'compileProject' : 'getCompositionManifest', input); - if (!isCurrent()) return; - applyProjectOutput(output); - state.phase = 'hostStarting'; - state.error = null; - await ensurePlayerPreviewHost(); - } catch (error) { - if (!isCurrent()) return; - state.phase = 'error'; - setError(error); - } finally { - if (!isCurrent()) return; - render(); - } -} - -function refreshProject(options = {}) { - const workspacePath = state.workspacePath; - const entryPoint = state.selectedEntry; - const freshSnapshot = options.fresh === true; - const requestKey = `${workspacePath || ''}\u0000${entryPoint || ''}\u0000${freshSnapshot ? 'fresh' : 'cached'}`; - if (projectLoadRequest?.key === requestKey) return projectLoadRequest.promise; - - const generation = ++projectLoadGeneration; - const request = { key: requestKey, promise: null }; - request.promise = loadProject(generation, workspacePath, entryPoint, freshSnapshot).finally(() => { - if (projectLoadRequest === request) projectLoadRequest = null; - }); - projectLoadRequest = request; - return request.promise; -} - -function setComposition(id) { - if (!id || id === state.activeCompositionId) return; - playerProtocol.sendOrQueuePlayerCommand('pause', { frame: state.frame }); - state.activeCompositionId = id; - state.frame = defaultPreviewFrame(currentComposition()); - state.frameTouched = false; - state.selectedElementId = null; - state.selection = null; - state.playerHostError = null; - resetPlayerChannelConnection({ rotateNonce: true }); - resetPlayerRuntimeState(); - clearPlayerHostPoll(); - render(); - void ensurePlayerPreviewHost(false); -} - -function setFrame(frame, options = {}) { - const duration = compositionDuration(); - state.frame = clamp(Math.round(Number(frame) || 0), 0, duration - 1); - if (!options.silent) state.frameTouched = true; - if (!playerPreviewReady()) void ensurePlayerPreviewHost(); - playerProtocol.sendOrQueuePlayerCommand('seek', { frame: state.frame }); - if (!options.fastSync) updateTimelineDom(); -} - -function stepFrame(delta) { - playerProtocol.sendOrQueuePlayerCommand('pause', { frame: state.frame }); - setFrame(state.frame + delta); -} - -function togglePlayback() { - if (!playerPreviewReady()) void ensurePlayerPreviewHost(); - const wantsToPlay = Boolean(state.playerDesiredState?.playing ?? state.playerRuntimePlaying); - if (wantsToPlay || state.playerRuntimePlaying) { - playerProtocol.sendOrQueuePlayerCommand('pause', { frame: state.frame }); - return; - } - - if (state.interactionMode === 'inspect') { - state.interactionMode = 'preview'; - state.selectionDragging = false; - } - if (state.playerPhase === 'ended' || state.frame >= compositionDuration() - 1) { - state.frame = 0; - } - playerProtocol.sendOrQueuePlayerCommand('play', { frame: state.frame }); - render(); -} - -function setInteractionMode(mode) { - const next = mode === 'inspect' ? 'inspect' : 'preview'; - if (state.interactionMode === next) return; - state.interactionMode = next; - state.selectionDragging = false; - state.selectionDraft = null; - if (next === 'inspect') { - playerProtocol.sendOrQueuePlayerCommand('pause', { frame: state.frame }); - playerProtocol.requestPlayerFrameContext?.(); - } - render(); -} - -function toggleInspect() { - setInteractionMode(state.interactionMode === 'inspect' ? 'preview' : 'inspect'); -} - -function toggleMuted() { - state.muted = !state.muted; - playerProtocol.setPlayerAudio?.({ muted: state.muted, volume: state.volume }); -} - -function setVolume(value) { - const volume = clamp(Number(value) || 0, 0, 1); - state.volume = volume; - state.muted = volume === 0; - playerProtocol.setPlayerAudio?.({ muted: state.muted, volume }); -} - -function retryPreview() { - state.playerHostError = null; - state.playerPhase = 'connecting'; - state.playerReloadNonce += 1; - resetPlayerChannelConnection({ rotateNonce: true }); - resetPlayerRuntimeState(); - render(); - void ensurePlayerPreviewHost(true); -} - -function requestExport() { - if (!currentComposition() || ACTIVE_EXPORT_STATUSES.has(state.exportRun?.status)) return; - state.exportConfirmOpen = true; - render(); -} - -function restoreExportFocus() { - queueMicrotask(() => document.querySelector('[data-action="start-export"]')?.focus()); -} - -function dismissExportDialog() { - state.exportConfirmOpen = false; - render(); - restoreExportFocus(); -} - -function dismissExportRun() { - if (exportPollTimer) clearTimeout(exportPollTimer); - exportPollTimer = null; - exportGeneration += 1; - state.exportRun = null; - render(); -} - -function resetExportState() { - if (exportPollTimer) clearTimeout(exportPollTimer); - exportPollTimer = null; - exportGeneration += 1; - state.exportRun = null; - state.exportConfirmOpen = false; -} - -function scheduleExportPoll(delayMs = 700) { - if (exportPollTimer) clearTimeout(exportPollTimer); - exportPollTimer = window.setTimeout(() => { - exportPollTimer = null; - void pollExportStatus(); - }, delayMs); -} - -function applyExportOutput(output) { - const payload = output && Object.hasOwn(output, 'run') - ? output.run || { status: 'failed', error: t('exportFailed') } - : output; - state.exportRun = { ...state.exportRun, ...payload }; - const status = state.exportRun?.status; - if (ACTIVE_EXPORT_STATUSES.has(status)) { - scheduleExportPoll(); - } - render(); -} - -async function pollExportStatus() { - const runId = state.exportRun?.runId; - if (!runId) return; - const generation = exportGeneration; - try { - const output = await callBackend('getExportStatus', { runId }); - if (generation !== exportGeneration || state.exportRun?.runId !== runId) return; - applyExportOutput(output); - } catch (error) { - if (generation !== exportGeneration || state.exportRun?.runId !== runId) return; - state.exportRun = { ...state.exportRun, status: 'failed', error: String(error?.message || error) }; - render(); - } -} - -async function confirmExport() { - const composition = currentComposition(); - if (!composition) return; - state.exportConfirmOpen = false; - state.exportRun = { status: 'queued', compositionId: composition.id, progress: 0 }; - const generation = ++exportGeneration; - render(); - restoreExportFocus(); - try { - const output = await callBackend('startExport', { - compositionId: composition.id, - expectedProjectRevision: state.manifest?.projectRevision || state.manifest?.sourceRevision, - expectedDescriptorRevision: composition.descriptorRevision || state.manifest?.descriptorRevision, - frameRange: [0, compositionDuration(composition) - 1], - }); - if (generation !== exportGeneration) return; - applyExportOutput(output); - } catch (error) { - if (generation !== exportGeneration) return; - state.exportRun = { status: 'failed', error: String(error?.message || error) }; - render(); - } -} - -async function cancelExport() { - const runId = state.exportRun?.runId; - if (!runId) return; - const generation = exportGeneration; - try { - const output = await callBackend('cancelExport', { runId }); - if (generation !== exportGeneration || state.exportRun?.runId !== runId) return; - applyExportOutput(output); - } catch (error) { - if (generation !== exportGeneration || state.exportRun?.runId !== runId) return; - state.exportRun = { ...state.exportRun, error: String(error?.message || error) }; - render(); - } -} - -async function sendContext() { - if (!isFrameCommitted()) return; - const snapshot = await playerProtocol.getPreviewSnapshot(); - const context = buildSelectedVideoContext(); - context.previewSnapshotSource = snapshot?.source || 'player-state'; - context.previewPlaying = Boolean(snapshot?.playing); - context.playbackState = state.playerPhase; - const sentence = selectionContextSentence(context); - const basePrompt = t('askPrompt', { - project: projectName(), - composition: context.compositionId || '-', - frame: context.frame, - workspace: state.workspacePath || '-', - }); - const prompt = `${basePrompt}\n\n${t('contextPayload')}: ${sentence}\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\``; - await runtime().host?.fillChatInput?.(prompt); -} - -function selectPreviewLayer(layerId) { - if (state.interactionMode !== 'inspect') return; - state.selectedElementId = layerId || null; - state.selection = layerId ? { type: 'element' } : null; - refreshSelectionDom(); -} - -function setPointSelection(point) { - if (state.interactionMode !== 'inspect') return; - state.selectedElementId = null; - state.selection = { type: 'point', point: { x: round2(point.x), y: round2(point.y) } }; - refreshSelectionDom(); -} - -function setRegionSelection(box) { - if (state.interactionMode !== 'inspect') return; - state.selectedElementId = null; - state.selection = { - type: 'region', - normalizedBox: { - x: round2(box.x), - y: round2(box.y), - width: round2(box.width), - height: round2(box.height), - }, - }; - refreshSelectionDom(); -} - -export { - cancelExport, - confirmExport, - dismissExportDialog, - dismissExportRun, - refreshProject, - requestExport, - resetExportState, - retryPreview, - selectEntry, - selectPreviewLayer, - sendContext, - setComposition, - setFrame, - setInteractionMode, - setPointSelection, - setRegionSelection, - setVolume, - stepFrame, - toggleInspect, - toggleMuted, - togglePlayback, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/backend.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/backend.js deleted file mode 100644 index 76974bb5..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/backend.js +++ /dev/null @@ -1,60 +0,0 @@ -// remotion-live :: backend.js (auto-split from ui.js; do not hand-merge) - -import { state } from './state.js'; -import { bridgeOutput, runtime, t } from './util.js'; - -const ACTION_TIMEOUT_MS = { - detectProject: 30_000, - getCompositionManifest: 90_000, - compileProject: 300_000, - ensurePlayerPreviewHost: 180_000, - getPlayerPreviewHostStatus: 15_000, - startExport: 90_000, - getExportStatus: 15_000, - cancelExport: 30_000, -}; - -async function callBackend(action, input = {}) { - const host = runtime(); - if (!host.backend?.call) throw new Error(t('backendMissing')); - const result = await host.backend.call( - `remotionRuntime.${action}`, - { - workspacePath: state.workspacePath, - ...input, - }, - { - entityId: state.workspacePath || 'default', - idempotencyKey: `remotion-live-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - }, - ); - if (result?.bridgeResult) { - const run = result.bridgeResult; - if (run.status === 'failed' || run.status === 'cancelled') { - throw new Error(run.error?.message || run.message || run.stderr || `${action} ${run.status}`); - } - return bridgeOutput(result); - } - if (!result?.actionRunId || !host.backend?.status) return bridgeOutput(result); - - const timeoutMs = ACTION_TIMEOUT_MS[action] || 60_000; - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - 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, 500)); - } - await host.backend.cancelRun?.(result.actionRunId, { - sessionId: result.sessionId, - turnId: result.turnId, - }).catch(() => null); - throw new Error(`Backend action timed out: ${action}`); -} - -export { callBackend }; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/constants.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/constants.js deleted file mode 100644 index a7ce4933..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/constants.js +++ /dev/null @@ -1,166 +0,0 @@ -const ROUTES = new Set(['/preview']); - -const PLAYER_HOST_RUNTIME_VERSION = 14; -const PLAYER_CONTROL_PROTOCOL_VERSION = 3; - -const MESSAGES = { - 'en-US': { - title: 'Remotion Live', - preview: 'Preview', - composition: 'Composition', - frame: 'Frame', - timeline: 'Review timeline', - refresh: 'Rebuild preview', - sendContext: 'Send context', - exportVideo: 'Export', - expandPanel: 'Expand workbench', - noWorkspace: 'Open a workspace containing a Remotion project to start previewing.', - waitingWorkspace: 'Waiting for workspace', - detecting: 'Finding Remotion project', - loadingSnapshot: 'Loading project snapshot', - bundling: 'Building preview', - hostStarting: 'Starting preview runtime', - playerLoading: 'Loading composition', - readyPaused: 'Ready', - playing: 'Playing', - buffering: 'Buffering media', - seeking: 'Seeking', - projectMissing: 'No Remotion project detected', - backendMissing: 'Remotion runtime is unavailable', - playerUnavailable: 'Preview could not start', - playerUnavailableHint: 'The last good frame is preserved when possible. Rebuild the preview to try again.', - protocolMismatch: 'Preview runtime protocol does not match this surface.', - runtimeStopped: 'Preview runtime stopped unexpectedly.', - connectionTimeout: 'The preview control channel timed out. Rebuild the preview to reconnect.', - retry: 'Retry', - retryPreview: 'Rebuild preview', - play: 'Play', - pause: 'Pause', - previous: 'Previous frame', - next: 'Next frame', - mute: 'Mute audio', - unmute: 'Unmute audio', - volume: 'Volume', - inspect: 'Inspect', - exitInspect: 'Exit inspect', - inspectHint: 'Click an element or drag a region on the paused frame.', - inspectActive: 'Inspecting current frame', - clearSelection: 'Clear selection', - contextPayload: 'Verified video context', - selectionPoint: 'point', - selectionRegion: 'region', - selectionElement: 'element', - selectionWhole: 'whole frame', - resolution: '{{width}} × {{height}}', - duration: '{{frames}} frames', - fps: '{{fps}} fps', - noCompositions: 'No compositions were returned by the project.', - notRemotionTitle: 'Not a Remotion project', - notRemotion: 'This workspace does not expose a Remotion root. Open the project that owns the composition and retry.', - projectBrokenTitle: 'Project needs attention', - ambiguousTitle: 'Choose the Remotion entry point', - ambiguousHint: 'More than one entry point was found. Choose the one that owns the preview.', - installDeps: 'Project dependencies are not installed. Install them, then retry.', - exportConfirmTitle: 'Export this composition?', - exportConfirmBody: 'Render {{composition}} from frame 0 through {{lastFrame}}. Preview remains available while the export job runs.', - exportConfirm: 'Start export', - cancel: 'Cancel', - exporting: 'Exporting', - exportCancelling: 'Cancelling export', - exportDone: 'Export complete', - exportFailed: 'Export failed', - exportCancelled: 'Export cancelled', - askPrompt: 'Use this verified Remotion preview context. Project: {{project}}. Composition: {{composition}}. Frame: {{frame}}. Workspace: {{workspace}}. Recommend the next edit and how to verify it.', - }, - 'zh-CN': { - title: 'Remotion Live', - preview: '预览', - composition: '合成', - frame: '帧', - timeline: '审阅时间线', - refresh: '重新构建预览', - sendContext: '发送上下文', - exportVideo: '导出', - expandPanel: '展开工作台', - noWorkspace: '请打开包含 Remotion 项目的工作区以开始预览。', - waitingWorkspace: '等待工作区', - detecting: '正在查找 Remotion 项目', - loadingSnapshot: '正在读取项目快照', - bundling: '正在构建预览', - hostStarting: '正在启动预览运行时', - playerLoading: '正在加载合成', - readyPaused: '已就绪', - playing: '播放中', - buffering: '正在缓冲媒体', - seeking: '正在定位', - projectMissing: '未检测到 Remotion 项目', - backendMissing: 'Remotion 运行时不可用', - playerUnavailable: '预览无法启动', - playerUnavailableHint: '系统会尽量保留最后一个可信画面。请重新构建预览后重试。', - protocolMismatch: '预览运行时协议与当前界面不匹配。', - runtimeStopped: '预览运行时意外停止。', - connectionTimeout: '预览控制通道连接超时,请重新构建预览后重试。', - retry: '重试', - retryPreview: '重新构建预览', - play: '播放', - pause: '暂停', - previous: '上一帧', - next: '下一帧', - mute: '静音', - unmute: '取消静音', - volume: '音量', - inspect: '检查', - exitInspect: '退出检查', - inspectHint: '在暂停的画面上点击元素,或拖出一个区域。', - inspectActive: '正在检查当前帧', - clearSelection: '清除选择', - contextPayload: '已确认的视频上下文', - selectionPoint: '点', - selectionRegion: '区域', - selectionElement: '元素', - selectionWhole: '整帧', - resolution: '{{width}} × {{height}}', - duration: '{{frames}} 帧', - fps: '{{fps}} fps', - noCompositions: '项目没有返回任何合成。', - notRemotionTitle: '这不是 Remotion 项目', - notRemotion: '当前工作区没有提供 Remotion Root。请打开该合成所属的项目后重试。', - projectBrokenTitle: '项目需要处理', - ambiguousTitle: '选择 Remotion 入口', - ambiguousHint: '检测到多个入口。请选择拥有当前预览的入口。', - installDeps: '项目依赖尚未安装。请安装依赖后重试。', - exportConfirmTitle: '导出当前合成?', - exportConfirmBody: '从第 0 帧渲染 {{composition}} 到第 {{lastFrame}} 帧。导出任务运行时预览仍然可用。', - exportConfirm: '开始导出', - cancel: '取消', - exporting: '正在导出', - exportCancelling: '正在取消导出', - exportDone: '导出完成', - exportFailed: '导出失败', - exportCancelled: '导出已取消', - askPrompt: '请使用这个经过确认的 Remotion 预览上下文。项目:{{project}}。合成:{{composition}}。帧:{{frame}}。工作区:{{workspace}}。请建议下一处编辑以及验证方式。', - }, -}; - -function normalizeRoute(route) { - const normalized = String(route || '/preview').trim() || '/preview'; - return ROUTES.has(normalized) ? normalized : '/preview'; -} - -const svg = (body, viewBox = '0 0 16 16') => ``; - -const ICONS = { - play: svg(''), - pause: svg(''), - prev: svg(''), - next: svg(''), - refresh: svg(''), - send: svg(''), - inspect: svg(''), - volume: svg(''), - muted: svg(''), - expand: svg(''), - film: svg(''), -}; - -export { ICONS, MESSAGES, PLAYER_CONTROL_PROTOCOL_VERSION, PLAYER_HOST_RUNTIME_VERSION, normalizeRoute }; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/model.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/model.js deleted file mode 100644 index 4faa448e..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/model.js +++ /dev/null @@ -1,117 +0,0 @@ -import { state } from './state.js'; -import { asArray, clamp } from './util.js'; - -function currentComposition() { - const compositions = asArray(state.manifest?.compositions); - return compositions.find((item) => item.id === state.activeCompositionId) || compositions[0] || null; -} - -function compositionDuration(composition = currentComposition()) { - return Math.max(1, Math.round(Number(composition?.durationInFrames || composition?.duration || 1))); -} - -function defaultPreviewFrame() { - return 0; -} - -function timelineFramePercent(frame = state.frame, composition = currentComposition()) { - const duration = compositionDuration(composition); - if (duration <= 1) return 0; - return clamp((Number(frame) || 0) * (100 / (duration - 1)), 0, 100); -} - -function normalizeManifest(output) { - return output?.manifest || output || { compositions: [] }; -} - -function layerBox(layer) { - const bounds = layer?.bboxPercent || layer?.boundsPercent || layer?.bounds || null; - if (!bounds) return null; - const values = [bounds.x, bounds.y, bounds.width, bounds.height].map(Number); - if (!values.every(Number.isFinite)) return null; - const [rawX, rawY, rawWidth, rawHeight] = values; - const x = clamp(rawX, 0, 100); - const y = clamp(rawY, 0, 100); - const width = clamp(rawWidth, 0, 100 - x); - const height = clamp(rawHeight, 0, 100 - y); - if (width <= 0 || height <= 0) return null; - return { - x, - y, - width, - height, - color: layer?.color || 'var(--rl-accent)', - opacity: Number.isFinite(Number(layer?.opacity)) ? Number(layer.opacity) : 0.82, - }; -} - -function frameModelMatches(model, composition = currentComposition()) { - if (!model || !composition) return false; - const modelCompositionId = model.compositionId || model.composition?.id; - if (modelCompositionId && modelCompositionId !== composition.id) return false; - if (model.frame === undefined || model.frame === null) return true; - return Math.round(Number(model.frame) || 0) === Math.round(Number(state.frame) || 0); -} - -function activeFrameModel() { - if (frameModelMatches(state.playerFrameModel)) return state.playerFrameModel; - return null; -} - -function frameLayers() { - return asArray(activeFrameModel()?.layers); -} - -function layerElementId(layer, index) { - return String(layer?.id || `${layer?.type || 'layer'}-${index + 1}`); -} - -function selectedLayer() { - if (!state.selectedElementId) return null; - return frameLayers() - .map((layer, index) => ({ layer, index, id: layerElementId(layer, index) })) - .find((item) => item.id === state.selectedElementId) || null; -} - -function selectedElementContext() { - const selected = selectedLayer(); - const composition = currentComposition(); - if (!selected || !composition) return null; - const { layer, index, id } = selected; - const model = activeFrameModel(); - const box = layerBox(layer, index); - if (!box) return null; - return { - workspacePath: state.workspacePath || null, - compositionId: composition.id, - frame: Math.round(Number(state.frame) || 0), - timeSeconds: model?.timeSeconds ?? null, - contextSource: model?.measurement || layer.source || 'player', - element: { - id, - type: layer.type || null, - label: layer.label || layer.id || layer.type || id, - sourceHint: layer.sourceHint || layer.sourcePath || layer.componentPath || null, - source: layer.source || model?.measurement || null, - elementPath: layer.elementPath || null, - sequenceId: layer.sequenceId || null, - from: layer.from ?? null, - duration: layer.duration ?? layer.durationInFrames ?? null, - bboxPercent: { x: box.x, y: box.y, width: box.width, height: box.height }, - }, - }; -} - -export { - activeFrameModel, - compositionDuration, - currentComposition, - defaultPreviewFrame, - frameLayers, - layerBox, - layerElementId, - normalizeManifest, - selectedElementContext, - selectedLayer, - timelineFramePercent, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/player-dom.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/player-dom.js deleted file mode 100644 index cf465e51..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/player-dom.js +++ /dev/null @@ -1,297 +0,0 @@ -// remotion-live :: player-dom.js - -import { PLAYER_CONTROL_PROTOCOL_VERSION } from './constants.js'; -import { currentComposition } from './model.js'; -import { ensurePlayerInstanceId, playerPreviewReady } from './preview-controller.js'; -import { state } from './state.js'; -import { t } from './util.js'; - -let channelIdentity = null; -let playerPort = null; -let playerMessageHandler = null; -let readinessHandshakeIdentityKey = null; -const readinessHandshakeSignals = new Set(); - -function randomNonce() { - const bytes = new Uint8Array(24); - crypto.getRandomValues(bytes); - return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); -} - - -function playerFrameNode() { - return document.querySelector('.rl-player-frame'); -} - - -function closePlayerPort() { - if (playerPort) { - playerPort.onmessage = null; - playerPort.onmessageerror = null; - playerPort.close(); - } - playerPort = null; - state.playerChannelConnected = false; - state.playerConnectionState = 'disconnected'; -} - - -function resetReadinessHandshakeSignals() { - readinessHandshakeIdentityKey = null; - readinessHandshakeSignals.clear(); -} - - -function playerChannelKey() { - return [ - ensurePlayerInstanceId(), - state.playerHost?.baseUrl || state.playerHost?.url || '', - state.playerReloadNonce || 0, - ].join('|'); -} - - -function nonceFromRenderedFrame(instanceId) { - const src = playerFrameNode()?.getAttribute('src'); - if (!src) return null; - try { - const url = new URL(src, window.location.href); - if (url.searchParams.get('instanceId') !== instanceId) return null; - return url.searchParams.get('channelNonce') || null; - } catch { - return null; - } -} - - -function ensurePlayerChannelIdentity() { - const key = playerChannelKey(); - if (!channelIdentity || channelIdentity.key !== key) { - closePlayerPort(); - resetReadinessHandshakeSignals(); - const instanceId = ensurePlayerInstanceId(); - channelIdentity = { - key, - instanceId, - nonce: nonceFromRenderedFrame(instanceId) || randomNonce(), - }; - state.playerChannelNonce = channelIdentity.nonce; - } - return channelIdentity; -} - - -function resetPlayerChannelConnection(options = {}) { - closePlayerPort(); - if (state.playerHandshakeTimer) { - clearTimeout(state.playerHandshakeTimer); - state.playerHandshakeTimer = null; - } - if (options.rotateNonce) { - channelIdentity = null; - state.playerChannelNonce = null; - resetReadinessHandshakeSignals(); - } -} - - -function activatePlayerHandshake(signal) { - if (state.playerChannelConnected) return; - const identity = ensurePlayerChannelIdentity(); - if (readinessHandshakeIdentityKey !== identity.key) { - readinessHandshakeIdentityKey = identity.key; - readinessHandshakeSignals.clear(); - } - if (readinessHandshakeSignals.has(signal)) return; - readinessHandshakeSignals.add(signal); - - // A pre-load transfer can be lost before the child installs its connect - // listener. The iframe load and bootstrap messages are monotonic readiness - // signals, so each may replace that speculative lease exactly once. Repeated - // bootstrap pulses never churn an in-flight MessagePort. - if (state.playerHandshakeTimer) { - clearTimeout(state.playerHandshakeTimer); - state.playerHandshakeTimer = null; - } - requestPlayerHandshake(); -} - - -function notifyPlayerFrameLoaded(node) { - if (!node || node !== playerFrameNode()) return; - activatePlayerHandshake('frame-load'); -} - - -function playerHostUrl(options = {}) { - const host = state.playerHost; - const composition = currentComposition(); - if (!host?.url || !composition) return ''; - try { - const identity = ensurePlayerChannelIdentity(); - const url = new URL(host.baseUrl || host.url); - url.searchParams.set('compositionId', composition.id); - url.searchParams.set('frame', String(Math.round(Number(state.frame) || 0))); - url.searchParams.set('instanceId', identity.instanceId); - url.searchParams.set('channelNonce', identity.nonce); - if (options.cacheBust || state.playerReloadNonce) { - url.searchParams.set('_rl', String(state.playerReloadNonce)); - } - return url.toString(); - } catch { - return host.url; - } -} - - -function setPlayerMessageHandler(handler) { - playerMessageHandler = typeof handler === 'function' ? handler : null; -} - - -function handlePlayerBootstrap(event) { - const node = playerFrameNode(); - const composition = currentComposition(); - const message = event.data || {}; - if (!node?.contentWindow) return; - if (!composition || message.source !== 'sparo-remotion-player-host' || message.type !== 'bootstrapReady') return; - if (message.protocolVersion !== PLAYER_CONTROL_PROTOCOL_VERSION) return; - if (message.compositionId !== composition.id) return; - if (message.projectRevision !== (state.manifest?.projectRevision || state.manifest?.sourceRevision)) return; - if (message.descriptorRevision !== (composition.descriptorRevision || state.manifest?.descriptorRevision)) return; - const identity = ensurePlayerChannelIdentity(); - if (message.instanceId !== identity.instanceId || message.channelNonce !== identity.nonce) return; - // WebView2 may expose different WindowProxy identities for the same nested, - // opaque iframe. The bootstrap message is a monotonic child-ready signal; - // the private channel remains bound to this navigation by the high-entropy - // nonce and immutable preview identity. - activatePlayerHandshake('bootstrap-ready'); -} - - -function handlePortMessage(identity, event) { - const message = event.data || {}; - if (identity !== channelIdentity) return; - if (message.source !== 'sparo-remotion-player-host') return; - if (message.protocolVersion !== PLAYER_CONTROL_PROTOCOL_VERSION) return; - if (message.instanceId !== identity.instanceId) return; - if (message.channelNonce !== identity.nonce) return; - - if (message.type === 'channelReady') { - state.playerChannelConnected = true; - state.playerConnectionState = 'connected'; - if (state.playerHandshakeTimer) { - clearTimeout(state.playerHandshakeTimer); - state.playerHandshakeTimer = null; - } - } - playerMessageHandler?.(message, { trusted: true }); -} - - -function openPlayerChannel(identity) { - const composition = currentComposition(); - const node = playerFrameNode(); - if (!composition || !node?.contentWindow || !playerPreviewReady()) return false; - - closePlayerPort(); - const channel = new MessageChannel(); - playerPort = channel.port1; - playerPort.onmessage = (event) => handlePortMessage(identity, event); - playerPort.onmessageerror = () => { - if (identity !== channelIdentity) return; - resetPlayerChannelConnection(); - state.playerRuntimeReady = false; - requestPlayerHandshake(); - }; - playerPort.start(); - state.playerConnectionState = 'connecting'; - - // The nested Player inherits an opaque sandbox origin. A transferred port is - // the capability; the nonce binds it to this exact iframe navigation. - node.contentWindow.postMessage({ - source: 'sparo-remotion-live', - type: 'connect', - protocolVersion: PLAYER_CONTROL_PROTOCOL_VERSION, - compositionId: composition.id, - projectRevision: state.manifest?.projectRevision || state.manifest?.sourceRevision, - descriptorRevision: composition.descriptorRevision || state.manifest?.descriptorRevision, - instanceId: identity.instanceId, - channelNonce: identity.nonce, - }, '*', [channel.port2]); - return true; -} - - -function postPlayerMessage(type, payload = {}, options = {}) { - const composition = currentComposition(); - const identity = ensurePlayerChannelIdentity(); - if (!composition || !playerPort || !state.playerChannelConnected || !playerPreviewReady()) return false; - if (options.requireReady !== false && !state.playerRuntimeReady) return false; - playerPort.postMessage({ - ...payload, - source: 'sparo-remotion-live', - type, - protocolVersion: PLAYER_CONTROL_PROTOCOL_VERSION, - compositionId: composition.id, - projectRevision: state.manifest?.projectRevision || state.manifest?.sourceRevision, - descriptorRevision: composition.descriptorRevision || state.manifest?.descriptorRevision, - instanceId: identity.instanceId, - channelNonce: identity.nonce, - }); - return true; -} - - -function requestPlayerHandshake(attempt = 0) { - if (!playerPreviewReady() || !playerFrameNode()?.contentWindow) { - if (!state.playerHandshakeTimer) { - state.playerHandshakeTimer = window.setTimeout(() => { - state.playerHandshakeTimer = null; - requestPlayerHandshake(0); - }, 250); - } - return; - } - if (state.playerChannelConnected) return; - if (attempt === 0 && state.playerHandshakeTimer) return; - - const identity = ensurePlayerChannelIdentity(); - openPlayerChannel(identity); - if (state.playerChannelConnected) return; - if (state.playerHandshakeTimer) clearTimeout(state.playerHandshakeTimer); - state.playerHandshakeTimer = window.setTimeout(() => { - state.playerHandshakeTimer = null; - if (attempt >= 24) { - closePlayerPort(); - state.playerConnectionState = 'failed'; - state.playerRuntimeReady = false; - state.playerPhase = 'error'; - state.playerHostError = t('connectionTimeout'); - window.dispatchEvent(new CustomEvent('remotion-live:render-request')); - return; - } - requestPlayerHandshake(attempt + 1); - }, attempt < 4 ? 750 : 1500); -} - - -function syncPlayerRuntimeDom() { - if (!state.playerRuntimeReady || state.playerHostLoading) return; - document.querySelectorAll('.rl-player-runtime-overlay').forEach((node) => node.remove()); -} - - -window.addEventListener('message', handlePlayerBootstrap); - - -export { - notifyPlayerFrameLoaded, - playerFrameNode, - playerHostUrl, - postPlayerMessage, - requestPlayerHandshake, - resetPlayerChannelConnection, - setPlayerMessageHandler, - syncPlayerRuntimeDom, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/player-protocol.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/player-protocol.js deleted file mode 100644 index 70cf86e4..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/player-protocol.js +++ /dev/null @@ -1,506 +0,0 @@ -// remotion-live :: player-protocol.js - -import { compositionDuration, currentComposition, selectedLayer } from './model.js'; -import { applyPlayerFrame, currentPreviewSnapshot, ensurePlayerInstanceId, nextPlayerCommandId, normalizePreviewFrame, playerPreviewReady } from './preview-controller.js'; -import { - playerFrameNode, - postPlayerMessage, - requestPlayerHandshake, - setPlayerMessageHandler, - syncPlayerRuntimeDom, -} from './player-dom.js'; -import { state } from './state.js'; -import { asArray, clamp, rootElement } from './util.js'; -import { setPlayingState, syncPhaseDom, syncPlaybackDom, syncPlayingDom, syncSelectionOverlayDom } from './views.js'; - -const snapshotRequests = new Map(); -let playbackDomFrame = null; - -function schedulePlaybackDomSync() { - if (playbackDomFrame !== null) return; - playbackDomFrame = window.requestAnimationFrame(() => { - playbackDomFrame = null; - syncPlayerStateAttributes(); - syncPlaybackDom(); - }); -} - -function requestPlayerHostEnsure(force = false) { - window.dispatchEvent(new CustomEvent('remotion-live:ensure-player-host', { detail: { force } })); -} - - -function requestRender() { - window.dispatchEvent(new CustomEvent('remotion-live:render-request')); -} - - -function syncPlayerStateAttributes() { - const root = rootElement(); - if (!root) return; - root.dataset.playerPhase = state.playerPhase || 'disconnected'; - root.dataset.playerBuffering = String(Boolean(state.playerBuffering)); - root.dataset.playerSeeking = String(Boolean(state.playerSeeking)); - root.dataset.playerReady = String(Boolean(state.playerRuntimeReady)); -} - - -function setPlayerPhase(phase) { - state.playerPhase = phase; - syncPlayerStateAttributes(); - syncPhaseDom(); -} - - -function desiredState() { - const instanceId = state.playerInstanceId || null; - if (state.playerDesiredState?.instanceId === instanceId) return state.playerDesiredState; - state.playerDesiredState = { - instanceId, - revision: Number(state.playerDesiredRevision) || 0, - frame: normalizePreviewFrame(state.frame), - playing: Boolean(state.playerRuntimePlaying), - muted: state.muted !== false, - volume: clamp(Number.isFinite(Number(state.volume)) ? Number(state.volume) : 1, 0, 1), - }; - return state.playerDesiredState; -} - - -function nextDesiredState(type, payload = {}) { - const previous = desiredState(); - const next = { ...previous }; - if (payload.frame !== undefined) next.frame = normalizePreviewFrame(payload.frame); - - if (type === 'play') next.playing = true; - if (type === 'pause') next.playing = false; - if (type === 'toggle') next.playing = !next.playing; - if (type === 'mute') next.muted = payload.muted !== false; - if (type === 'unmute') next.muted = false; - if (type === 'volume') { - next.volume = clamp(Number(payload.volume) || 0, 0, 1); - if (payload.muted !== undefined) next.muted = Boolean(payload.muted); - } - - const revision = Math.max( - Number(state.playerDesiredRevision) || 0, - Number(state.playerActualRevision) || 0, - ) + 1; - next.instanceId = state.playerInstanceId || null; - next.revision = revision; - state.playerDesiredRevision = revision; - state.playerDesiredState = next; - state.muted = next.muted; - state.volume = next.volume; - syncPlayingDom(); - return next; -} - - -function preparePlayerCommand(type, payload = {}) { - ensurePlayerInstanceId(); - const desired = nextDesiredState(type, payload); - const commandId = payload.commandId || nextPlayerCommandId(type); - return { - type, - commandId, - revision: desired.revision, - desired, - payload: { - commandId, - command: type, - revision: desired.revision, - desired, - }, - }; -} - - -function markCommandInFlight(command) { - state.playerInFlightCommand = { - commandId: command.commandId, - type: command.type, - revision: command.revision, - desired: command.desired, - accepted: false, - startedAt: Date.now(), - }; - if (command.type === 'seek') { - state.playerCommittedFrame = null; - state.playerSeeking = true; - setPlayerPhase('seeking'); - } -} - - -function postPreparedPlayerCommand(command) { - const posted = postPlayerMessage('reconcile', command.payload, { requireReady: false }); - if (posted) { - markCommandInFlight(command); - state.playerPendingCommand = null; - } - return posted; -} - - -function queuePlayerCommand(command) { - // This is a complete desired-state snapshot, not a lossy one-command queue. - state.playerPendingCommand = command; -} - - -function sendOrQueuePlayerCommand(type, payload = {}) { - const command = preparePlayerCommand(type, payload); - if (postPreparedPlayerCommand(command)) return true; - - queuePlayerCommand(command); - if (!playerPreviewReady() && state.workspacePath && currentComposition()) { - requestPlayerHostEnsure(); - } else { - requestPlayerHandshake(); - } - return false; -} - - -function flushPlayerCommand() { - if (!state.playerChannelConnected) return; - const pending = state.playerPendingCommand; - if (pending) { - if (!postPreparedPlayerCommand(pending)) queuePlayerCommand(pending); - return; - } - - const desired = desiredState(); - if (state.playerInFlightCommand?.revision === desired.revision) return; - const commandId = nextPlayerCommandId('reconcile'); - const command = { - type: desired.playing ? 'play' : 'pause', - commandId, - revision: desired.revision, - desired, - payload: { - commandId, - command: 'reconcile', - revision: desired.revision, - desired, - }, - }; - postPreparedPlayerCommand(command); -} - - -function currentMessageBelongsToPreview(message, options = {}) { - if (options.trusted !== true) return false; - if (message.source !== 'sparo-remotion-player-host') return false; - const composition = currentComposition(); - if (message.compositionId !== composition?.id) return false; - if (message.projectRevision !== (state.manifest?.projectRevision || state.manifest?.sourceRevision)) return false; - if (message.descriptorRevision !== (composition?.descriptorRevision || state.manifest?.descriptorRevision)) return false; - if (message.instanceId !== state.playerInstanceId) return false; - if (message.channelNonce !== state.playerChannelNonce) return false; - return true; -} - - -function syncFrameFromPlayerMessage(frame, playing = state.playerRuntimePlaying) { - const desiredFrame = Number(state.playerInFlightCommand?.desired?.frame); - const pendingSeek = state.playerInFlightCommand?.type === 'seek' - && Number.isFinite(desiredFrame) - && Number(frame) !== desiredFrame; - const nextFrame = applyPlayerFrame(frame, { updateDesired: !pendingSeek }); - if (playing && state.playerDesiredState?.playing) { - state.playerDesiredState = { ...state.playerDesiredState, frame: nextFrame }; - } - return nextFrame; -} - - -function applyFrameContextMessage(message) { - const composition = currentComposition(); - const frameContext = message.frameContext || message; - const frame = clamp(Number(frameContext.frame ?? message.frame ?? state.frame) || 0, 0, compositionDuration(composition) - 1); - state.playerFrameModel = { - ok: true, - compositionId: frameContext.compositionId || message.compositionId || composition?.id || state.activeCompositionId, - frame, - timeSeconds: frameContext.timeSeconds ?? (composition?.fps ? frame / composition.fps : null), - composition: frameContext.composition || composition || null, - measurement: frameContext.measurement || 'player-dom', - layers: asArray(frameContext.layers), - }; - if (state.selectedElementId && !selectedLayer()) state.selectedElementId = null; - if (!syncSelectionOverlayDom()) requestRender(); -} - - -function mergeActualState(message = {}) { - const previous = state.playerActualState || {}; - const revision = Number(message.revision); - const currentRevision = Number(state.playerActualRevision) || 0; - if (Number.isFinite(revision) && revision < currentRevision) return previous; - const nextRevision = Number.isFinite(revision) - ? revision - : currentRevision; - const actual = { - ...previous, - ...(message.actual && typeof message.actual === 'object' ? message.actual : {}), - instanceId: state.playerInstanceId, - revision: nextRevision, - projectRevision: message.projectRevision, - descriptorRevision: message.descriptorRevision, - }; - if (message.frame !== undefined) actual.frame = normalizePreviewFrame(message.frame); - if (typeof message.playing === 'boolean') actual.playing = message.playing; - if (typeof message.buffering === 'boolean') actual.buffering = message.buffering; - if (typeof message.seeking === 'boolean') actual.seeking = message.seeking; - if (typeof message.muted === 'boolean') actual.muted = message.muted; - if (Number.isFinite(Number(message.volume))) actual.volume = clamp(Number(message.volume), 0, 1); - - state.playerActualRevision = nextRevision; - state.playerActualState = actual; - if (actual.frame !== undefined) syncFrameFromPlayerMessage(actual.frame, actual.playing); - if (typeof actual.playing === 'boolean') state.playerRuntimePlaying = actual.playing; - if (typeof actual.buffering === 'boolean') state.playerBuffering = actual.buffering; - if (typeof actual.seeking === 'boolean') state.playerSeeking = actual.seeking; - if (typeof actual.muted === 'boolean') state.playerRuntimeMuted = actual.muted; - if (Number.isFinite(Number(actual.volume))) state.playerRuntimeVolume = actual.volume; - return actual; -} - - -function commandIsSettled(command, actual) { - if (!command || Number(actual.revision) < Number(command.revision)) return false; - if (command.type === 'play') return actual.playing === true; - if (command.type === 'pause') return actual.playing === false; - if (command.type === 'seek') return Number(actual.frame) === Number(command.desired.frame) && !actual.seeking; - if (command.type === 'mute' || command.type === 'unmute') return actual.muted === command.desired.muted; - if (command.type === 'volume') { - return Math.abs(Number(actual.volume) - Number(command.desired.volume)) < 0.001 - && actual.muted === command.desired.muted; - } - return true; -} - - -function settleInFlightCommand(actual) { - if (!commandIsSettled(state.playerInFlightCommand, actual)) return; - state.playerInFlightCommand = null; - state.playerPendingCommand = null; -} - - -function applySnapshotMessage(message) { - const actual = mergeActualState(message); - if (message.frameContext) applyFrameContextMessage({ ...message.frameContext, frame: actual.frame }); - return { - ok: true, - source: 'player-snapshot', - projectRevision: message.projectRevision, - descriptorRevision: message.descriptorRevision, - compositionId: message.compositionId || currentComposition()?.id || state.activeCompositionId, - frame: actual.frame ?? normalizePreviewFrame(state.frame), - playing: Boolean(actual.playing), - muted: actual.muted, - volume: actual.volume, - buffering: Boolean(actual.buffering), - durationInFrames: message.durationInFrames ?? currentComposition()?.durationInFrames ?? compositionDuration(), - fps: message.fps ?? currentComposition()?.fps ?? null, - width: message.width ?? currentComposition()?.width ?? null, - height: message.height ?? currentComposition()?.height ?? null, - frameContext: message.frameContext || state.playerFrameModel || null, - }; -} - - -function resolveSnapshotRequest(message) { - const requestId = message.requestId; - if (!requestId || !snapshotRequests.has(requestId)) return false; - const request = snapshotRequests.get(requestId); - snapshotRequests.delete(requestId); - clearTimeout(request.timer); - request.resolve(applySnapshotMessage(message)); - return true; -} - - -function handlePlayerHostMessage(message = {}, options = {}) { - if (!currentMessageBelongsToPreview(message, options)) return false; - const messageRevision = Number(message.revision); - if ( - message.type !== 'commandAccepted' - && Number.isFinite(messageRevision) - && messageRevision < (Number(state.playerActualRevision) || 0) - ) return true; - - if (message.type === 'channelReady') { - setPlayerPhase('connecting'); - flushPlayerCommand(); - return true; - } - - if (message.type === 'ready') { - state.playerRuntimeReady = true; - state.playerHostError = null; - const actual = mergeActualState({ ...message, buffering: false, seeking: false }); - setPlayingState(Boolean(actual.playing)); - setPlayerPhase(actual.playing ? 'playing' : 'paused'); - syncPlaybackDom(); - syncPlayerRuntimeDom(); - flushPlayerCommand(); - return true; - } - - if (message.type === 'snapshot') { - resolveSnapshotRequest(message); - return true; - } - - if (message.type === 'frameContext') { - applyFrameContextMessage(message); - return true; - } - - if (message.type === 'commandAccepted') { - if (state.playerInFlightCommand?.commandId === message.commandId) { - state.playerInFlightCommand.accepted = true; - state.playerInFlightCommand.acceptedAt = Date.now(); - } - return true; - } - - if (message.type === 'frameCommitted' || message.type === 'actualState') { - const actual = mergeActualState(message); - if (message.type === 'frameCommitted') state.playerCommittedFrame = actual.frame; - settleInFlightCommand(actual); - state.playerPhase = actual.buffering - ? 'buffering' - : actual.seeking - ? 'seeking' - : actual.playing - ? 'playing' - : 'paused'; - schedulePlaybackDomSync(); - return true; - } - - if (message.type === 'seekSettled') { - const actual = mergeActualState({ ...message, seeking: false }); - state.playerSeeking = false; - settleInFlightCommand(actual); - setPlayerPhase(actual.playing ? 'playing' : 'paused'); - syncPlaybackDom(); - return true; - } - - if (message.type === 'playing') { - const actual = mergeActualState({ ...message, playing: true, buffering: false }); - state.playerBuffering = false; - setPlayingState(true); - settleInFlightCommand(actual); - setPlayerPhase('playing'); - syncPlaybackDom(); - return true; - } - - if (message.type === 'paused') { - const actual = mergeActualState({ ...message, playing: false }); - setPlayingState(false); - settleInFlightCommand(actual); - setPlayerPhase('paused'); - syncPlaybackDom(); - return true; - } - - if (message.type === 'buffering') { - mergeActualState(message); - state.playerBuffering = Boolean(message.buffering); - setPlayerPhase(state.playerBuffering ? 'buffering' : state.playerRuntimePlaying ? 'playing' : 'paused'); - syncPlaybackDom(); - return true; - } - - if (message.type === 'ended') { - const actual = mergeActualState({ ...message, playing: false, buffering: false, seeking: false }); - if (state.playerDesiredState) { - state.playerDesiredState = { ...state.playerDesiredState, frame: actual.frame, playing: false }; - } - state.playerBuffering = false; - state.playerSeeking = false; - state.playerInFlightCommand = null; - setPlayingState(false); - setPlayerPhase('ended'); - syncPlaybackDom(); - return true; - } - - if (message.type === 'error') { - state.playerHostError = String(message.message || 'Player preview failed.'); - state.playerRuntimeReady = false; - state.playerRuntimePlaying = false; - state.playerBuffering = false; - state.playerSeeking = false; - state.playerInFlightCommand = null; - if (state.playerDesiredState) state.playerDesiredState = { ...state.playerDesiredState, playing: false }; - setPlayingState(false); - setPlayerPhase('error'); - requestRender(); - return true; - } - - return false; -} - - -function requestPlayerSnapshot(timeoutMs = 1_000) { - if (!playerPreviewReady() || !playerFrameNode()) return Promise.resolve(null); - const requestId = nextPlayerCommandId('snapshot'); - return new Promise((resolve) => { - const timer = window.setTimeout(() => { - snapshotRequests.delete(requestId); - resolve(null); - }, timeoutMs); - snapshotRequests.set(requestId, { resolve, timer }); - const posted = postPlayerMessage('snapshot', { requestId }, { requireReady: false }); - if (!posted) { - clearTimeout(timer); - snapshotRequests.delete(requestId); - resolve(null); - } - }); -} - - -function requestPlayerFrameContext() { - return postPlayerMessage('frameContext', {}, { requireReady: false }); -} - - -function setPlayerAudio({ muted, volume } = {}) { - if (volume !== undefined) return sendOrQueuePlayerCommand('volume', { volume, muted }); - return sendOrQueuePlayerCommand(muted === false ? 'unmute' : 'mute', { muted }); -} - - -async function getPreviewSnapshot() { - if (playerPreviewReady()) { - const snapshot = await requestPlayerSnapshot(); - if (snapshot) return snapshot; - return currentPreviewSnapshot('player-unavailable'); - } - return currentPreviewSnapshot('player-state'); -} - - -setPlayerMessageHandler(handlePlayerHostMessage); - -export { - flushPlayerCommand, - getPreviewSnapshot, - handlePlayerHostMessage, - requestPlayerFrameContext, - requestPlayerSnapshot, - sendOrQueuePlayerCommand, - setPlayerAudio, - syncPlayerStateAttributes, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/preview-controller.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/preview-controller.js deleted file mode 100644 index 6688b1a7..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/preview-controller.js +++ /dev/null @@ -1,138 +0,0 @@ -// remotion-live :: preview-controller.js - -import { PLAYER_CONTROL_PROTOCOL_VERSION, PLAYER_HOST_RUNTIME_VERSION } from './constants.js'; -import { compositionDuration, currentComposition } from './model.js'; -import { state } from './state.js'; -import { clamp } from './util.js'; - -let instanceCounter = 0; -let commandCounter = 0; - -function playerPreviewReady() { - const composition = currentComposition(); - const projectRevision = state.manifest?.projectRevision || state.manifest?.sourceRevision; - const descriptorRevision = composition?.descriptorRevision || state.manifest?.descriptorRevision; - return Boolean( - state.playerHost?.ready - && state.playerHost?.url - && state.playerHost?.runtimeVersion === PLAYER_HOST_RUNTIME_VERSION - && state.playerHost?.protocolVersion === PLAYER_CONTROL_PROTOCOL_VERSION - && state.playerHost?.projectRevision === projectRevision - && state.playerHost?.descriptorRevision === descriptorRevision, - ); -} - -function resetPlayerRuntimeState() { - state.playerRuntimeReady = false; - state.playerRuntimePlaying = false; - state.playerRuntimeFrame = null; - state.playerCommittedFrame = null; - state.playerBuffering = false; - state.playerSeeking = false; - state.playerPhase = 'disconnected'; - state.playerRuntimeMuted = state.muted; - state.playerRuntimeVolume = state.volume; - state.playerDesiredState = null; - state.playerActualState = null; - state.playerDesiredRevision = 0; - state.playerActualRevision = 0; - state.playerChannelConnected = false; - state.playerConnectionState = 'disconnected'; - state.playerChannelNonce = null; - state.playerPendingCommand = null; - state.playerInFlightCommand = null; - state.playerInstanceId = null; - state.playerStageKey = null; - state.playerRenderedStageKey = null; - state.playerFrameModel = null; - - if (state.playerHandshakeTimer) { - clearTimeout(state.playerHandshakeTimer); - state.playerHandshakeTimer = null; - } -} - -function playerStageKey(composition = currentComposition()) { - const hostUrl = state.playerHost?.baseUrl || state.playerHost?.url || ''; - return [ - state.workspacePath || '', - composition?.id || '', - state.manifest?.buildId || '', - state.manifest?.projectRevision || state.manifest?.sourceRevision || '', - composition?.descriptorRevision || state.manifest?.descriptorRevision || '', - hostUrl, - state.playerReloadNonce || 0, - PLAYER_HOST_RUNTIME_VERSION, - PLAYER_CONTROL_PROTOCOL_VERSION, - ].join('|'); -} - - -function ensurePlayerInstanceId() { - const nextStageKey = playerStageKey(); - if (!state.playerInstanceId || state.playerStageKey !== nextStageKey) { - instanceCounter += 1; - state.playerInstanceId = `rl-${Date.now().toString(36)}-${instanceCounter}`; - state.playerStageKey = nextStageKey; - } - return state.playerInstanceId; -} - - -function nextPlayerCommandId(type = 'command') { - commandCounter += 1; - return `${ensurePlayerInstanceId()}-${type}-${commandCounter}`; -} - - -function normalizePreviewFrame(frame) { - const duration = compositionDuration(); - return clamp(Math.round(Number(frame) || 0), 0, duration - 1); -} - - -function applyPlayerFrame(frame, options = {}) { - const nextFrame = normalizePreviewFrame(frame); - state.playerRuntimeFrame = nextFrame; - if (options.updateDesired !== false) { - state.frame = nextFrame; - if (options.touched !== false) state.frameTouched = true; - } - if (state.playerFrameModel && Math.round(Number(state.playerFrameModel.frame) || 0) !== nextFrame) { - state.playerFrameModel = null; - } - return nextFrame; -} - - -function currentPreviewSnapshot(source = 'state') { - const composition = currentComposition(); - const frame = normalizePreviewFrame(state.frame); - return { - ok: true, - source, - compositionId: composition?.id || state.activeCompositionId || null, - frame, - playing: Boolean(state.playerRuntimePlaying), - muted: state.playerRuntimeMuted ?? state.muted ?? true, - volume: state.playerRuntimeVolume ?? state.volume ?? 1, - buffering: Boolean(state.playerBuffering), - durationInFrames: composition?.durationInFrames ?? compositionDuration(composition), - fps: composition?.fps ?? null, - width: composition?.width ?? null, - height: composition?.height ?? null, - frameContext: state.playerFrameModel || null, - }; -} - - -export { - applyPlayerFrame, - currentPreviewSnapshot, - ensurePlayerInstanceId, - nextPlayerCommandId, - normalizePreviewFrame, - playerPreviewReady, - playerStageKey, - resetPlayerRuntimeState, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/preview-runtime.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/preview-runtime.js deleted file mode 100644 index 416b5a53..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/preview-runtime.js +++ /dev/null @@ -1,167 +0,0 @@ -import { callBackend } from './backend.js'; -import { PLAYER_CONTROL_PROTOCOL_VERSION, PLAYER_HOST_RUNTIME_VERSION } from './constants.js'; -import { currentComposition } from './model.js'; -import { resetPlayerChannelConnection } from './player-dom.js'; -import { render } from './render-core.js'; -import { state } from './state.js'; -import { t } from './util.js'; - -let ensureRequest = null; -let queuedForceKey = null; - -function playerHostRequestKey() { - const composition = currentComposition(); - return [ - state.workspacePath || '', - state.manifest?.projectRevision || state.manifest?.sourceRevision || '', - state.manifest?.descriptorRevision || '', - composition?.id || '', - composition?.descriptorRevision || '', - ].join('|'); -} - -function clearPlayerHostPoll() { - if (!state.playerHostPollTimer) return; - clearTimeout(state.playerHostPollTimer); - state.playerHostPollTimer = null; -} - -function schedulePlayerHostPoll(delayMs = 1600) { - clearPlayerHostPoll(); - state.playerHostPollTimer = window.setTimeout(() => { - state.playerHostPollTimer = null; - void pollPlayerPreviewHostStatus(); - }, delayMs); -} - -function playerHostSignature(host) { - if (!host) return ''; - return [ - host.status || '', - host.ready ? '1' : '0', - host.baseUrl || host.url || '', - host.pid || '', - host.bundleId || '', - host.runtimeVersion || '', - host.protocolVersion || '', - host.health?.reachable ? '1' : '0', - host.health?.error || '', - ].join('|'); -} - -function applyPlayerHostOutput(output) { - const previousSignature = playerHostSignature(state.playerHost); - const previousUrl = state.playerHost?.baseUrl || state.playerHost?.url || null; - const nextUrl = output?.baseUrl || output?.url || null; - state.playerHost = output || null; - - if (previousUrl !== nextUrl) { - state.playerRuntimeReady = false; - state.playerConnectionState = 'disconnected'; - state.playerPhase = 'connecting'; - resetPlayerChannelConnection({ rotateNonce: true }); - } - - if (output?.ready - && output?.runtimeVersion === PLAYER_HOST_RUNTIME_VERSION - && output?.protocolVersion === PLAYER_CONTROL_PROTOCOL_VERSION) { - state.playerHostError = null; - state.playerPhase = state.playerRuntimeReady ? state.playerPhase : 'connecting'; - schedulePlayerHostPoll(5000); - } else if (output?.ready) { - state.playerHostError = t('protocolMismatch'); - state.playerPhase = 'error'; - clearPlayerHostPoll(); - } else if (output?.status === 'starting' || output?.status === 'queued') { - state.playerPhase = 'connecting'; - schedulePlayerHostPoll(); - } else { - clearPlayerHostPoll(); - } - - return previousSignature !== playerHostSignature(state.playerHost); -} - -async function startPlayerHost(force, requestKey) { - const composition = currentComposition(); - if (!state.workspacePath || !composition) return null; - - state.playerHostLoading = true; - state.playerHostError = null; - state.playerPhase = 'connecting'; - render(); - try { - const output = await callBackend('ensurePlayerPreviewHost', { - compositionId: composition.id, - frame: state.frame, - expectedProjectRevision: state.manifest?.projectRevision || state.manifest?.sourceRevision, - expectedDescriptorRevision: composition.descriptorRevision || state.manifest?.descriptorRevision, - force, - waitMs: 45000, - }); - if (requestKey !== playerHostRequestKey()) return null; - applyPlayerHostOutput(output); - return output; - } catch (error) { - if (requestKey === playerHostRequestKey()) { - state.playerHostError = String(error?.message || error); - state.playerPhase = 'error'; - clearPlayerHostPoll(); - } - return null; - } finally { - if (requestKey === playerHostRequestKey()) { - state.playerHostLoading = false; - render(); - } - } -} - -function ensurePlayerPreviewHost(force = false) { - if (!state.workspacePath || !currentComposition()) return Promise.resolve(null); - const requestKey = playerHostRequestKey(); - if (ensureRequest?.key === requestKey) { - if (force) queuedForceKey = requestKey; - return ensureRequest.promise; - } - queuedForceKey = null; - const request = { key: requestKey, promise: null }; - request.promise = startPlayerHost(force, requestKey).finally(() => { - if (ensureRequest === request) ensureRequest = null; - if (queuedForceKey === requestKey && playerHostRequestKey() === requestKey) { - queuedForceKey = null; - void ensurePlayerPreviewHost(true); - } - }); - ensureRequest = request; - return request.promise; -} - -async function pollPlayerPreviewHostStatus() { - if (!state.workspacePath || !currentComposition()) return; - const requestKey = playerHostRequestKey(); - let shouldRender = false; - try { - const output = await callBackend('getPlayerPreviewHostStatus'); - if (requestKey !== playerHostRequestKey()) return; - shouldRender = applyPlayerHostOutput(output); - if (state.route === '/preview' && output?.status === 'stopped') { - state.playerHostError = t('runtimeStopped'); - state.playerPhase = 'error'; - shouldRender = true; - } - } catch (error) { - if (requestKey !== playerHostRequestKey()) return; - state.playerHostError = String(error?.message || error); - state.playerPhase = 'error'; - clearPlayerHostPoll(); - shouldRender = true; - } - if (shouldRender) render(); -} - -export { - clearPlayerHostPoll, - ensurePlayerPreviewHost, - pollPlayerPreviewHostStatus, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/render-core.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/render-core.js deleted file mode 100644 index b65c664a..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/render-core.js +++ /dev/null @@ -1,280 +0,0 @@ -import { compositionDuration, currentComposition } from './model.js'; -import { notifyPlayerFrameLoaded, playerFrameNode, requestPlayerHandshake } from './player-dom.js'; -import { playerStageKey } from './preview-controller.js'; -import { selectionSummary, shouldDeferRenderForSelection } from './selection-geom.js'; -import { state } from './state.js'; -import { escapeHtml, rootElement, runtime, t } from './util.js'; -import { - actualFrame, - commitSelectionMarkerDom, - fitPreviewStage, - isFrameCommitted, - previewPhase, - renderDetectingState, - renderDetectionState, - renderExportOverlay, - renderHeader, - renderPlaybackTransport, - renderPlayerOverlay, - renderPlayerPreviewContent, - renderPreviewClickLayer, - renderSelectCaptureLayer, - renderSelectionMarker, - renderSelectionOverlay, - renderTimelineInline, - renderWorkspaceEmpty, - replaceElementHtml, - syncFrameDom, - syncInteractionLayersDom, - syncPhaseDom, - syncPlayingDom, - syncSelectionOverlayDom, -} from './views.js'; - -function setError(error) { - const failedPhase = state.phase; - const message = error ? String(error?.message || error) : null; - state.error = message; - if (error) { - state.phase = 'error'; - runtime().log?.error?.('Remotion Live operation failed', { - phase: failedPhase, - error: message, - }); - } - render(); -} - -function renderStatusStrip() { - const phase = previewPhase(); - const busy = ['detecting', 'bundling', 'connecting', 'loading'].includes(phase); - return ``; -} - -function renderContextTray() { - if (!currentComposition() || state.interactionMode !== 'inspect') return ''; - const summary = selectionSummary(); - return ` - - `; -} - -function updateContextTrayDom() { - const tray = document.querySelector('.rl-context-tray'); - const html = renderContextTray().trim(); - if (!tray) { - if (html) { - const template = document.createElement('template'); - template.innerHTML = html; - const node = template.content.firstElementChild; - const transport = document.querySelector('.rl-workbench > .rl-transport'); - if (node && transport) transport.before(node); - } - return; - } - if (!html) { - tray.remove(); - return; - } - const template = document.createElement('template'); - template.innerHTML = html; - tray.replaceWith(template.content.firstElementChild); -} - -function patchExportOverlayDom(root) { - root.querySelectorAll('.rl-export-dialog, .rl-export-job').forEach((node) => node.remove()); - const html = renderExportOverlay().trim(); - if (!html) return; - const template = document.createElement('template'); - template.innerHTML = html; - const node = template.content.firstElementChild; - if (node) root.appendChild(node); - openExportDialog(root); -} - -function openExportDialog(root) { - const dialog = root.querySelector('.rl-export-dialog'); - if (!dialog || dialog.open) return; - try { - dialog.showModal(); - } catch { - dialog.setAttribute('open', ''); - } - queueMicrotask(() => dialog.querySelector('[autofocus], button')?.focus()); -} - -function patchPlayerOverlayDom(stage) { - stage.querySelectorAll('.rl-player-runtime-overlay').forEach((node) => node.remove()); - const html = renderPlayerOverlay().trim(); - if (!html) return; - const template = document.createElement('template'); - template.innerHTML = html; - const node = template.content.firstElementChild; - if (node) stage.appendChild(node); -} - -function patchInspectDom(stage) { - stage.classList.toggle('is-inspecting', state.interactionMode === 'inspect'); - syncInteractionLayersDom(); -} - -function patchStablePlayerRender(root) { - const frame = playerFrameNode(); - const stage = frame?.closest('.rl-stage'); - if (!frame || !stage || frame.dataset.stageKey !== playerStageKey()) return false; - if (!root.querySelector('.rl-workbench')) return false; - - replaceElementHtml('.rl-header', renderHeader()); - replaceElementHtml('.rl-status-strip', renderStatusStrip()); - const composition = currentComposition(); - if (composition) { - replaceElementHtml('.rl-transport', renderPlaybackTransport(composition, compositionDuration(composition), Number(composition.fps || 30))); - replaceElementHtml('.rl-review', renderTimelineInline(composition, compositionDuration(composition), Number(composition.fps || 30))); - } - patchPlayerOverlayDom(stage); - patchInspectDom(stage); - updateContextTrayDom(); - patchExportOverlayDom(root); - syncFrameDom(); - syncPlayingDom(); - fitPreviewStage(); - if (!state.playerRuntimeReady) requestPlayerHandshake(); - return true; -} - -function refreshSelectionDom() { - syncSelectionOverlayDom(); - commitSelectionMarkerDom(); - updateContextTrayDom(); -} - -function clearSelection() { - state.selection = null; - state.selectedElementId = null; - refreshSelectionDom(); -} - -function updateTimelineDom() { - const composition = currentComposition(); - if (!composition) return; - const duration = compositionDuration(composition); - const fps = Number(composition.fps || 30); - replaceElementHtml('.rl-transport', renderPlaybackTransport(composition, duration, fps)); - replaceElementHtml('.rl-review', renderTimelineInline(composition, duration, fps)); - syncFrameDom(); - syncPlayingDom(); -} - -function renderPreview() { - const composition = currentComposition(); - if (!composition) { - const error = state.error || state.playerHostError; - return ` -
- ${escapeHtml(error ? t('playerUnavailable') : t('noCompositions'))} - ${error ? `

${escapeHtml(error)}

` : ''} - -
- `; - } - const duration = compositionDuration(composition); - const fps = Number(composition.fps || 30); - const phase = previewPhase(); - const aspectRatio = `${composition.width || 1920}/${composition.height || 1080}`; - return ` -
-
-
- ${renderPlayerPreviewContent()} - ${renderPreviewClickLayer()} - ${renderSelectCaptureLayer()} - ${renderSelectionOverlay()} - ${renderSelectionMarker()} - -
-
- ${renderContextTray()} - ${renderPlaybackTransport(composition, duration, fps)} - ${renderTimelineInline(composition, duration, fps)} -
- `; -} - -function renderRouteContent() { - if (!state.workspacePath) return renderWorkspaceEmpty(); - const status = state.detection?.status; - if (status === 'notFound' || status === 'broken' || status === 'ambiguous') return renderDetectionState(); - if (state.phase === 'detecting' && !state.manifest) return renderDetectingState(); - return renderPreview(); -} - -function render() { - if (shouldDeferRenderForSelection()) { - state.renderQueued = true; - return; - } - const root = rootElement(); - if (!root) return; - state.renderQueued = false; - root.dataset.route = state.route; - root.dataset.previewPhase = previewPhase(); - root.dataset.projectPhase = state.phase || 'idle'; - root.dataset.detectionStatus = state.detection?.status || ''; - root.dataset.error = state.error || state.playerHostError || ''; - root.dataset.actualFrame = String(actualFrame()); - root.dataset.actualPlaying = state.playerRuntimePlaying ? 'true' : 'false'; - root.dataset.frameState = isFrameCommitted() ? 'committed' : 'pending'; - root.dataset.inspectMode = state.interactionMode === 'inspect' ? 'true' : 'false'; - root.dataset.buffering = state.playerBuffering ? 'true' : 'false'; - root.dataset.seeking = state.playerSeeking ? 'true' : 'false'; - root.dataset.playerHostReady = state.playerHost?.ready ? 'true' : 'false'; - root.dataset.playerConnectionState = state.playerConnectionState || 'disconnected'; - root.dataset.playerChannelConnected = state.playerChannelConnected ? 'true' : 'false'; - document.documentElement.dataset.route = state.route; - - if (patchStablePlayerRender(root)) return; - - const previousFrame = playerFrameNode(); - root.innerHTML = `${renderHeader()}${renderStatusStrip()}
${renderRouteContent()}
${renderExportOverlay()}`; - openExportDialog(root); - fitPreviewStage(); - const nextFrame = playerFrameNode(); - if (nextFrame) { - const stageKey = playerStageKey(); - nextFrame.dataset.stageKey = stageKey; - state.playerRenderedStageKey = stageKey; - } - if (nextFrame && nextFrame !== previousFrame) { - state.playerRuntimeReady = false; - state.playerRuntimePlaying = false; - nextFrame.addEventListener('load', () => notifyPlayerFrameLoaded(nextFrame), { once: true }); - } - if (nextFrame && !state.playerRuntimeReady) requestPlayerHandshake(); - syncPhaseDom(); -} - -export { - clearSelection, - refreshSelectionDom, - render, - setError, - updateTimelineDom, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/selection-geom.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/selection-geom.js deleted file mode 100644 index ed736d0c..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/selection-geom.js +++ /dev/null @@ -1,103 +0,0 @@ -import { activeFrameModel, currentComposition, selectedElementContext, selectedLayer } from './model.js'; -import { state } from './state.js'; -import { asElement, clamp, nodeInsideRoot, previewStageNode, projectName, round2, t } from './util.js'; - -function isSelectionStartTarget(target) { - if (state.interactionMode !== 'inspect') return false; - const element = asElement(target); - return Boolean(element && nodeInsideRoot(element) && element.closest('[data-select-capture],[data-preview-layer-id]')); -} - -function shouldDeferRenderForSelection() { - return state.selectionPointerDown || state.selectionDragging; -} - -function buildSelectedVideoContext() { - const composition = currentComposition(); - const model = activeFrameModel(); - const selection = state.selection; - const fps = composition?.fps ?? null; - const frame = Math.round(Number(state.playerRuntimeFrame ?? state.frame) || 0); - const context = { - workspacePath: state.workspacePath || null, - projectName: projectName(), - entryPoint: state.project?.entryPoint || state.detection?.entryPoint || null, - compositionId: composition?.id || null, - projectRevision: state.manifest?.projectRevision || state.manifest?.buildId || null, - frame, - frameState: 'committed', - timeSeconds: model?.timeSeconds ?? (fps ? round2(frame / fps) : null), - fps, - durationInFrames: composition?.durationInFrames ?? null, - descriptorRevision: composition?.descriptorRevision || state.manifest?.descriptorRevision || null, - playbackState: state.playerPhase, - }; - if (composition?.width && composition?.height) { - context.size = { width: composition.width, height: composition.height }; - } - if (selection?.type === 'element') { - const elementContext = selectedElementContext(); - if (elementContext) { - context.selection = { type: 'element', element: elementContext.element }; - context.contextSource = elementContext.contextSource; - } - } else if (selection?.type === 'point' && selection.point) { - context.selection = { type: 'point', point: selection.point }; - } else if (selection?.type === 'region' && selection.normalizedBox) { - context.selection = { type: 'region', normalizedBox: selection.normalizedBox }; - } - return context; -} - -function selectionContextSentence(context) { - const parts = [`${t('composition')} ${context.compositionId || '-'}`, `${t('frame')} ${context.frame}`]; - const selection = context.selection; - if (!selection) { - parts.push(t('selectionWhole')); - } else if (selection.type === 'point' && selection.point) { - parts.push(`${t('selectionPoint')} ${Math.round(selection.point.x)}%, ${Math.round(selection.point.y)}%`); - } else if (selection.type === 'region' && selection.normalizedBox) { - parts.push(`${t('selectionRegion')} ${Math.round(selection.normalizedBox.width)}% × ${Math.round(selection.normalizedBox.height)}%`); - } else if (selection.type === 'element' && selection.element) { - parts.push(`${t('selectionElement')} ${selection.element.label || selection.element.id || ''}`.trim()); - } - return parts.join(' · '); -} - -function stageNormalizedPoint(event) { - const stage = previewStageNode(); - if (!stage) return null; - const rect = stage.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return null; - return { - x: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100), - y: clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100), - }; -} - -function selectionSummary() { - const composition = currentComposition(); - const base = `${composition?.id || '-'} · ${t('frame')} ${Math.round(Number(state.frame) || 0)}`; - const selection = state.selection; - if (!selection) return `${base} · ${t('selectionWhole')}`; - if (selection.type === 'point' && selection.point) { - return `${base} · ${t('selectionPoint')} ${Math.round(selection.point.x)}%, ${Math.round(selection.point.y)}%`; - } - if (selection.type === 'region' && selection.normalizedBox) { - return `${base} · ${t('selectionRegion')} ${Math.round(selection.normalizedBox.width)}% × ${Math.round(selection.normalizedBox.height)}%`; - } - if (selection.type === 'element') { - const layer = selectedLayer()?.layer; - return `${base} · ${t('selectionElement')} ${layer?.label || layer?.id || layer?.type || ''}`.trim(); - } - return base; -} - -export { - buildSelectedVideoContext, - isSelectionStartTarget, - selectionContextSentence, - selectionSummary, - shouldDeferRenderForSelection, - stageNormalizedPoint, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/state.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/state.js deleted file mode 100644 index 0e683e1e..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/state.js +++ /dev/null @@ -1,65 +0,0 @@ -// Remotion Live surface state. The Player is the only preview authority. - -const state = { - locale: navigator.language || 'en-US', - route: '/preview', - tabId: null, - sessionId: null, - workspacePath: null, - - phase: 'idle', - error: null, - - project: null, - detection: null, - manifest: null, - selectedEntry: null, - activeCompositionId: null, - frame: 0, - frameTouched: false, - playerFrameModel: null, - - playerHost: null, - playerHostLoading: false, - playerHostError: null, - playerHostPollTimer: null, - playerRuntimeReady: false, - playerRuntimePlaying: false, - playerRuntimeFrame: null, - playerCommittedFrame: null, - playerPhase: 'disconnected', - playerBuffering: false, - playerSeeking: false, - playerRuntimeMuted: true, - playerRuntimeVolume: 1, - playerDesiredState: null, - playerActualState: null, - playerDesiredRevision: 0, - playerActualRevision: 0, - playerPendingCommand: null, - playerInFlightCommand: null, - playerInstanceId: null, - playerStageKey: null, - playerRenderedStageKey: null, - playerHandshakeTimer: null, - playerReloadNonce: 0, - playerChannelConnected: false, - playerConnectionState: 'disconnected', - playerChannelNonce: null, - - muted: true, - volume: 1, - interactionMode: 'preview', - - selectedElementId: null, - selection: null, - selectionPointerDown: false, - selectionDragging: false, - selectionDraft: null, - renderQueued: false, - - exportRun: null, - exportConfirmOpen: false, -}; - -export { state }; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/util.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/util.js deleted file mode 100644 index 53733219..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/util.js +++ /dev/null @@ -1,126 +0,0 @@ -import { MESSAGES } from './constants.js'; -import { state } from './state.js'; - -function runtime() { - return window.app || {}; -} - -function messages() { - const locale = String(state.locale || 'en-US'); - if (MESSAGES[locale]) return MESSAGES[locale]; - if (locale.toLowerCase().startsWith('zh')) return MESSAGES['zh-CN']; - return 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, - ); -} - -function escapeHtml(value) { - return String(value ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function asArray(value) { - return Array.isArray(value) ? value : []; -} - -function projectName() { - return state.project?.projectName || state.project?.name || 'Remotion'; -} - -function workspaceLabel() { - const path = state.workspacePath || ''; - if (!path) return '-'; - return path.split(/[\\/]/).filter(Boolean).pop() || path; -} - -function rootElement() { - return document.getElementById('remotionLiveRoot'); -} - -function asElement(node) { - if (!node) return null; - return node.nodeType === 1 ? node : node.parentElement || null; -} - -function nodeInsideRoot(node) { - const root = rootElement(); - const element = asElement(node); - return Boolean(root && element && root.contains(element)); -} - -function closestElement(target, selector) { - const element = asElement(target); - return typeof element?.closest === 'function' ? element.closest(selector) : null; -} - -function bridgeOutput(result) { - if (result?.bridgeResult?.output !== undefined) return result.bridgeResult.output; - if (result?.output !== undefined) return result.output; - return result; -} - -function round2(value) { - return Math.round((Number(value) || 0) * 100) / 100; -} - -function previewStageNode() { - return document.querySelector('.rl-stage'); -} - -function dropFrameNumber(frame, fps) { - const nominal = Math.round(fps); - const dropFrames = Math.round(nominal * 0.0666666667); - const framesPerMinute = nominal * 60; - const framesPerTenMinutes = framesPerMinute * 10 - dropFrames * 9; - const tenMinuteBlocks = Math.floor(frame / framesPerTenMinutes); - const remainder = frame % framesPerTenMinutes; - const extraMinutes = Math.max(0, Math.floor((remainder - dropFrames) / (framesPerMinute - dropFrames))); - return frame + dropFrames * 9 * tenMinuteBlocks + dropFrames * extraMinutes; -} - -function formatSMPTE(frame, fps) { - const sourceFrame = Math.max(0, Math.round(Number(frame) || 0)); - const rate = Math.max(1, Number(fps) || 30); - const nominal = Math.max(1, Math.round(rate)); - const dropFrame = Math.abs(rate - 29.97) < 0.02 || Math.abs(rate - 59.94) < 0.02; - const timecodeFrame = dropFrame ? dropFrameNumber(sourceFrame, rate) : sourceFrame; - const frames = timecodeFrame % nominal; - const totalSeconds = Math.floor(timecodeFrame / nominal); - const seconds = totalSeconds % 60; - const minutes = Math.floor(totalSeconds / 60) % 60; - const hours = Math.floor(totalSeconds / 3600); - const pad = (value) => String(value).padStart(2, '0'); - return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}${dropFrame ? ';' : ':'}${pad(frames)}`; -} - -export { - asArray, - asElement, - bridgeOutput, - clamp, - closestElement, - escapeHtml, - formatSMPTE, - nodeInsideRoot, - previewStageNode, - projectName, - rootElement, - round2, - runtime, - t, - workspaceLabel, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/views.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/views.js deleted file mode 100644 index 65216875..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/src/views.js +++ /dev/null @@ -1,636 +0,0 @@ -import { ICONS, PLAYER_CONTROL_PROTOCOL_VERSION, PLAYER_HOST_RUNTIME_VERSION } from './constants.js'; -import { compositionDuration, currentComposition, frameLayers, layerBox, layerElementId, timelineFramePercent } from './model.js'; -import { playerHostUrl } from './player-dom.js'; -import { playerPreviewReady, playerStageKey } from './preview-controller.js'; -import { state } from './state.js'; -import { asArray, clamp, escapeHtml, formatSMPTE, previewStageNode, t, workspaceLabel } from './util.js'; - -function actualFrame() { - const value = Number(state.playerActualState?.frame ?? state.playerRuntimeFrame ?? state.frame); - return Number.isFinite(value) ? Math.round(value) : 0; -} - -function transportFrame() { - const seeking = state.playerSeeking || state.playerInFlightCommand?.type === 'seek'; - const desired = Number(state.playerDesiredState?.frame ?? state.frame); - return seeking && Number.isFinite(desired) ? Math.round(desired) : actualFrame(); -} - -function isFrameCommitted() { - const composition = currentComposition(); - const projectRevision = state.manifest?.projectRevision || state.manifest?.sourceRevision; - const descriptorRevision = composition?.descriptorRevision || state.manifest?.descriptorRevision; - return Boolean( - state.playerRuntimeReady - && projectRevision - && descriptorRevision - && state.playerActualState?.projectRevision === projectRevision - && state.playerActualState?.descriptorRevision === descriptorRevision - && !state.playerSeeking - && !state.playerBuffering - && !state.playerRuntimePlaying - && state.playerCommittedFrame !== null - && Number(state.playerCommittedFrame) === actualFrame() - && actualFrame() === Math.round(Number(state.frame) || 0), - ); -} - -function previewPhase() { - if (state.error || state.playerHostError || state.playerPhase === 'error') return 'error'; - if (state.phase === 'detecting') return 'detecting'; - if (state.phase === 'snapshot') return 'snapshot'; - if (state.phase === 'bundling') return 'bundling'; - if (state.playerBuffering || state.playerPhase === 'buffering') return 'buffering'; - if (state.playerSeeking || state.playerPhase === 'seeking') return 'seeking'; - if (state.playerRuntimePlaying || state.playerPhase === 'playing') return 'playing'; - if (state.playerPhase === 'ended') return 'ended'; - if (state.playerRuntimeReady || state.playerPhase === 'paused' || state.playerPhase === 'ready') return 'ready'; - if (state.playerHostLoading || state.phase === 'hostStarting' || state.playerPhase === 'connecting') return 'connecting'; - return state.manifest ? 'loading' : state.phase || 'idle'; -} - -function phaseLabel(phase = previewPhase()) { - const keys = { - idle: 'waitingWorkspace', - detecting: 'detecting', - snapshot: 'loadingSnapshot', - bundling: 'bundling', - connecting: 'hostStarting', - loading: 'playerLoading', - ready: 'readyPaused', - playing: 'playing', - buffering: 'buffering', - seeking: 'seeking', - ended: 'readyPaused', - error: 'playerUnavailable', - notFound: 'projectMissing', - broken: 'projectBrokenTitle', - ambiguous: 'ambiguousTitle', - }; - return t(keys[phase] || 'readyPaused'); -} - -function phaseTone(phase = previewPhase()) { - if (phase === 'error' || phase === 'broken') return 'error'; - if (phase === 'ready') return 'success'; - if (phase === 'buffering' || phase === 'seeking' || phase === 'ambiguous') return 'warning'; - if (phase === 'playing') return 'accent'; - return 'info'; -} - -function renderStatus(phase = previewPhase()) { - return ` - - - ${escapeHtml(phaseLabel(phase))} - - `; -} - -function actualAudioState() { - return { - muted: state.playerRuntimeReady ? Boolean(state.playerRuntimeMuted) : Boolean(state.muted), - volume: state.playerRuntimeReady ? clamp(Number(state.playerRuntimeVolume) || 0, 0, 1) : clamp(Number(state.volume) || 0, 0, 1), - }; -} - -function syncAudioDom() { - const audio = actualAudioState(); - const button = document.querySelector('[data-action="toggle-muted"]'); - if (button) { - button.setAttribute('aria-label', audio.muted ? t('unmute') : t('mute')); - button.setAttribute('aria-pressed', audio.muted ? 'true' : 'false'); - button.innerHTML = audio.muted ? ICONS.muted : ICONS.volume; - } - const volume = document.querySelector('[data-action="volume"]'); - if (volume) volume.value = String(audio.muted ? 0 : audio.volume); -} - -function syncPhaseDom() { - const phase = previewPhase(); - const frameState = isFrameCommitted() ? 'committed' : 'pending'; - const inspectMode = state.interactionMode === 'inspect' ? 'true' : 'false'; - const root = document.getElementById('remotionLiveRoot'); - const workbench = document.querySelector('.rl-workbench'); - [root, workbench].forEach((node) => { - if (!node) return; - node.dataset.previewPhase = phase; - node.dataset.actualFrame = String(actualFrame()); - node.dataset.actualPlaying = state.playerRuntimePlaying ? 'true' : 'false'; - node.dataset.frameState = frameState; - node.dataset.inspectMode = inspectMode; - node.dataset.buffering = state.playerBuffering ? 'true' : 'false'; - node.dataset.seeking = state.playerSeeking ? 'true' : 'false'; - }); - - const current = document.querySelector('[data-preview-status]'); - if (current && current.dataset.phase !== phase) { - const template = document.createElement('template'); - template.innerHTML = renderStatus(phase).trim(); - current.replaceWith(template.content.firstElementChild); - } - - const sendContext = document.querySelector('[data-action="send-context"]'); - if (sendContext) sendContext.disabled = !isFrameCommitted(); - syncAudioDom(); - - const stage = previewStageNode(); - const interactionChanged = stage && ( - stage.dataset.runtimePhase !== phase - || stage.dataset.frameState !== frameState - || stage.dataset.inspectMode !== inspectMode - ); - if (stage && interactionChanged) { - stage.dataset.runtimePhase = phase; - stage.dataset.frameState = frameState; - stage.dataset.inspectMode = inspectMode; - stage.querySelectorAll('.rl-player-runtime-overlay').forEach((node) => node.remove()); - const overlayTemplate = document.createElement('template'); - overlayTemplate.innerHTML = renderPlayerOverlay().trim(); - const overlay = overlayTemplate.content.firstElementChild; - if (overlay) stage.appendChild(overlay); - syncInteractionLayersDom(); - } -} - -function syncFrameDom(options = {}) { - const composition = currentComposition(); - const duration = compositionDuration(composition); - const fps = Number(composition?.fps || 30); - const committedFrame = clamp(actualFrame(), 0, duration - 1); - const frame = clamp(transportFrame(), 0, duration - 1); - const percent = timelineFramePercent(frame, composition); - - document.querySelectorAll('input[data-action="frame-number"], input[data-action="frame-range"]').forEach((node) => { - node.value = String(frame); - }); - document.querySelectorAll('.rl-timecode__value').forEach((node) => { - node.textContent = formatSMPTE(frame, fps); - }); - document.querySelectorAll('.rl-frame-current').forEach((node) => { - node.textContent = String(frame); - }); - document.querySelectorAll('.rl-frame-actual').forEach((node) => { - node.textContent = String(committedFrame); - }); - document.querySelectorAll('.rl-review-scrub').forEach((node) => { - node.style.setProperty('--rl-progress', `${percent}%`); - }); - document.querySelectorAll('.rl-review-playhead').forEach((node) => { - node.style.left = `${percent}%`; - }); - if (options.syncPhase !== false) syncPhaseDom(); -} - -function syncPlayingDom(options = {}) { - const actualPlaying = Boolean(state.playerRuntimePlaying); - const desiredPlaying = Boolean(state.playerDesiredState?.playing ?? actualPlaying); - const pending = desiredPlaying !== actualPlaying; - document.querySelectorAll('[data-action="toggle-play"]').forEach((button) => { - button.classList.toggle('is-playing', actualPlaying); - button.classList.toggle('is-pending', pending); - button.setAttribute('aria-label', actualPlaying ? t('pause') : t('play')); - button.setAttribute('aria-pressed', actualPlaying ? 'true' : 'false'); - button.setAttribute('aria-busy', pending ? 'true' : 'false'); - if (button.classList.contains('rl-play-btn')) button.innerHTML = actualPlaying ? ICONS.pause : ICONS.play; - }); - if (options.syncPhase !== false) syncPhaseDom(); -} - -function syncPlaybackDom() { - syncFrameDom({ syncPhase: false }); - syncPlayingDom({ syncPhase: false }); - syncPhaseDom(); -} - -function setPlayingState(playing) { - state.playerRuntimePlaying = Boolean(playing); - syncPlayingDom(); -} - -function syncFrameFromPlayer(frame) { - state.playerRuntimeFrame = clamp(Math.round(Number(frame) || 0), 0, compositionDuration() - 1); - state.frame = state.playerRuntimeFrame; - state.frameTouched = true; - syncFrameDom(); -} - -function renderExportOverlay() { - const composition = currentComposition(); - if (state.exportConfirmOpen && composition) { - return ` - -
-
-
${escapeHtml(t('exportConfirmTitle'))}
-
${escapeHtml(composition.id)}
-
-
-
${escapeHtml(t('exportConfirmBody', { - composition: composition.id, - lastFrame: compositionDuration(composition) - 1, - }))}
- -
- `; - } - - const run = state.exportRun; - if (!run) return ''; - const busy = run.status === 'queued' || run.status === 'running' || run.status === 'cancelling'; - const progress = clamp(Number(run.progress ?? 0), 0, 100); - const label = run.status === 'cancelling' - ? t('exportCancelling') - : busy - ? t('exporting') - : run.status === 'completed' - ? t('exportDone') - : run.status === 'cancelled' - ? t('exportCancelled') - : t('exportFailed'); - return ` - - `; -} - -function renderHeader() { - const compositions = asArray(state.manifest?.compositions); - const composition = currentComposition(); - const committed = isFrameCommitted(); - const inspect = state.interactionMode === 'inspect'; - const exportBusy = ['queued', 'running', 'cancelling'].includes(state.exportRun?.status); - return ` -
-
- ${escapeHtml(state.workspacePath ? workspaceLabel() : t('title'))} - ${composition ? '' : ''} - ${compositions.length > 1 ? ` - - ` : composition ? `${escapeHtml(composition.id)}` : ''} -
-
${renderStatus()}
-
- - - - - -
-
- `; -} - -function renderWorkspaceEmpty() { - return ` -
-
${ICONS.film}
- ${escapeHtml(t('title'))} -

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

-
- `; -} - -function renderDetectingState() { - return ` -
- ${escapeHtml(t('detecting'))} -
- `; -} - -function renderDetectionState() { - const detection = state.detection || {}; - const status = detection.status || (detection.ok ? 'matched' : 'notFound'); - if (status === 'ambiguous') { - return ` -
-

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

-

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

-
- ${asArray(detection.entryPoints).map((entry) => ` - - `).join('')} -
-
- `; - } - const broken = status === 'broken'; - const firstError = asArray(detection.diagnostics).find((item) => item.level === 'error'); - const reason = detection.errorSummary || firstError?.message || (broken ? t('projectBrokenTitle') : t('notRemotion')); - return ` -
-
-
${escapeHtml(broken ? t('projectBrokenTitle') : t('notRemotionTitle'))}
-
${escapeHtml(reason)}
- ${broken && detection.hasNodeModules === false ? `

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

` : ''} - -
-
- `; -} - -function renderSelectionOverlay() { - const layers = frameLayers(); - if (state.interactionMode !== 'inspect' || !isFrameCommitted() || !layers.length) return ''; - return ` -
- ${layers.flatMap((layer, index) => { - const id = layerElementId(layer, index); - const box = layerBox(layer, index); - if (!box) return []; - const selected = id === state.selectedElementId; - const label = layer.label || layer.id || layer.type || `Layer ${index + 1}`; - return [` - - `]; - }).join('')} -
- `; -} - -function syncSelectionOverlayDom() { - const stage = previewStageNode(); - if (!stage) return false; - stage.querySelector('.rl-selection-overlay')?.remove(); - const template = document.createElement('template'); - template.innerHTML = renderSelectionOverlay().trim(); - const node = template.content.firstElementChild; - if (node) stage.appendChild(node); - return true; -} - -function renderSelectCaptureLayer() { - return state.interactionMode === 'inspect' && isFrameCommitted() - ? '' - : ''; -} - -function renderPreviewClickLayer() { - if (state.interactionMode !== 'preview' || !state.playerRuntimeReady) return ''; - return ``; -} - -function renderSelectionMarker() { - if (state.interactionMode !== 'inspect' || !isFrameCommitted()) return ''; - const selection = state.selection; - if (selection?.type === 'point' && selection.point) { - return ``; - } - if (selection?.type === 'region' && selection.normalizedBox) { - const box = selection.normalizedBox; - return ``; - } - return ''; -} - -function removeDraftMarker() { - document.querySelectorAll('.rl-sel-draft').forEach((node) => node.remove()); -} - -function updateDraftMarkerDom() { - const stage = previewStageNode(); - const draft = state.selectionDraft; - if (!stage) return; - let marker = stage.querySelector('.rl-sel-draft'); - if (state.interactionMode !== 'inspect' || !state.selectionDragging || !draft) { - marker?.remove(); - return; - } - if (!marker) { - marker = document.createElement('div'); - marker.className = 'rl-sel-region rl-sel-draft'; - marker.setAttribute('aria-hidden', 'true'); - stage.appendChild(marker); - } - marker.style.left = `${draft.x}%`; - marker.style.top = `${draft.y}%`; - marker.style.width = `${draft.width}%`; - marker.style.height = `${draft.height}%`; -} - -function commitSelectionMarkerDom() { - const stage = previewStageNode(); - if (!stage) return; - stage.querySelectorAll('.rl-sel-region:not(.rl-sel-draft), .rl-sel-point').forEach((node) => node.remove()); - const template = document.createElement('template'); - template.innerHTML = renderSelectionMarker().trim(); - const marker = template.content.firstElementChild; - if (marker) stage.appendChild(marker); -} - -function syncInteractionLayersDom() { - const stage = previewStageNode(); - if (!stage) return; - stage.classList.toggle('is-inspecting', state.interactionMode === 'inspect'); - stage.querySelectorAll('.rl-preview-click-layer, .rl-select-capture').forEach((node) => node.remove()); - for (const html of [renderPreviewClickLayer(), renderSelectCaptureLayer()]) { - if (!html.trim()) continue; - const template = document.createElement('template'); - template.innerHTML = html.trim(); - const node = template.content.firstElementChild; - if (node) stage.appendChild(node); - } - syncSelectionOverlayDom(); - commitSelectionMarkerDom(); -} - -function renderPlayerOverlay() { - const phase = previewPhase(); - if (phase === 'error') { - return ` -
-
-
-
${escapeHtml(t('playerUnavailable'))}
-
${escapeHtml(state.playerHostError || state.error || t('playerUnavailableHint'))}
- -
-
-
- `; - } - if (['detecting', 'snapshot', 'bundling', 'connecting', 'loading'].includes(phase)) { - return `
${escapeHtml(phaseLabel(phase))}
`; - } - if (phase === 'buffering' || phase === 'seeking') { - return `
${escapeHtml(phaseLabel(phase))}
`; - } - return ''; -} - -function renderPlayerPreviewContent() { - if (playerPreviewReady()) { - return ` - - ${renderPlayerOverlay()} - `; - } - const stale = state.playerHost?.ready && ( - state.playerHost?.runtimeVersion !== PLAYER_HOST_RUNTIME_VERSION - || state.playerHost?.protocolVersion !== PLAYER_CONTROL_PROTOCOL_VERSION - ); - const error = state.playerHostError || (stale ? t('protocolMismatch') : null); - if (error) { - return ` - -
-
-
-
${escapeHtml(t('playerUnavailable'))}
-
${escapeHtml(error)}
- -
-
-
- `; - } - return ` - -
- ${escapeHtml(phaseLabel())} -
- `; -} - -function replaceElementHtml(selector, html) { - const current = document.querySelector(selector); - if (!current) return false; - const template = document.createElement('template'); - template.innerHTML = html.trim(); - const next = template.content.firstElementChild; - if (!next) return false; - current.replaceWith(next); - return true; -} - -function renderPlaybackTransport(composition, duration, fps) { - const wantsToPlay = Boolean(state.playerRuntimePlaying); - const frame = clamp(transportFrame(), 0, duration - 1); - const audio = actualAudioState(); - return ` - - `; -} - -function renderTimelineInline(composition, duration, fps) { - const frame = clamp(transportFrame(), 0, duration - 1); - const percent = timelineFramePercent(frame, composition); - const sequences = asArray(state.playerFrameModel?.sequences).length - ? asArray(state.playerFrameModel?.sequences) - : asArray(composition?.sequences); - return ` -
-
- ${escapeHtml(t('timeline'))} - ${escapeHtml(t('duration', { frames: duration }))} · ${escapeHtml(t('fps', { fps }))} -
-
- - - -
- -
- `; -} - -function fitPreviewStage() { - const stage = previewStageNode(); - const area = stage?.closest('.rl-stage-area'); - const composition = currentComposition(); - if (!stage || !area || !composition) return; - const areaRect = area.getBoundingClientRect(); - const sourceWidth = Math.max(1, Number(composition.width) || 1920); - const sourceHeight = Math.max(1, Number(composition.height) || 1080); - const ratio = sourceWidth / sourceHeight; - let width = Math.max(1, areaRect.width); - let height = width / ratio; - if (height > areaRect.height) { - height = Math.max(1, areaRect.height); - width = height * ratio; - } - stage.style.width = `${Math.floor(width)}px`; - stage.style.height = `${Math.floor(height)}px`; -} - -export { - actualFrame, - commitSelectionMarkerDom, - fitPreviewStage, - isFrameCommitted, - previewPhase, - removeDraftMarker, - renderDetectingState, - renderDetectionState, - renderExportOverlay, - renderHeader, - renderPlaybackTransport, - renderPlayerOverlay, - renderPlayerPreviewContent, - renderPreviewClickLayer, - renderSelectCaptureLayer, - renderSelectionMarker, - renderSelectionOverlay, - renderTimelineInline, - renderWorkspaceEmpty, - replaceElementHtml, - setPlayingState, - syncFrameDom, - syncPlaybackDom, - syncFrameFromPlayer, - syncInteractionLayersDom, - syncPhaseDom, - syncPlayingDom, - syncSelectionOverlayDom, - updateDraftMarkerDom, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/style.css b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/style.css deleted file mode 100644 index fb2f47b8..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/style.css +++ /dev/null @@ -1,674 +0,0 @@ -:root { - color-scheme: light dark; - --rl-bg: var(--color-bg-primary, var(--sparo-app-bg, Canvas)); - --rl-surface: var(--color-bg-secondary, var(--sparo-app-surface, Canvas)); - --rl-panel: var(--color-bg-elevated, var(--sparo-app-panel, Canvas)); - --rl-text: var(--color-text-primary, var(--sparo-app-text, CanvasText)); - --rl-text-secondary: var(--color-text-secondary, var(--sparo-app-text-secondary, CanvasText)); - --rl-muted: var(--color-text-muted, var(--sparo-app-text-muted, GrayText)); - --rl-border: var(--border-base, var(--sparo-app-border, GrayText)); - --rl-border-subtle: var(--border-subtle, var(--sparo-app-border-subtle, GrayText)); - --rl-control: var(--element-bg-base, var(--sparo-app-control-bg, transparent)); - --rl-control-hover: var(--element-bg-medium, var(--sparo-app-control-hover, transparent)); - --rl-accent: var(--color-accent-500, var(--sparo-app-accent, Highlight)); - --rl-success: var(--color-success, var(--sparo-success, Highlight)); - --rl-warning: var(--color-warning, var(--sparo-warning, Highlight)); - --rl-error: var(--color-error, var(--sparo-error, Highlight)); - --rl-canvas: color-mix(in srgb, var(--rl-bg) 58%, black); - --rl-stage: color-mix(in srgb, var(--rl-canvas) 72%, black); - --rl-focus: color-mix(in srgb, var(--rl-accent) 58%, transparent); - --rl-radius-sm: var(--size-radius-sm, var(--sparo-app-radius-sm, 6px)); - --rl-radius: var(--size-radius-base, var(--sparo-app-radius, 10px)); - --rl-font: var(--font-family-sans, var(--sparo-font-sans, system-ui, sans-serif)); - --rl-mono: var(--sparo-font-mono, ui-monospace, "Cascadia Mono", monospace); - --rl-motion: var(--motion-fast, 150ms); - --rl-ease: var(--easing-standard, cubic-bezier(.4, 0, .2, 1)); - --rl-z-player: var(--ds-z-local, 1); - --rl-z-overlay: var(--ds-z-raised, 2); - --rl-z-inspect: var(--ds-z-overlay, 10); - --rl-z-modal: var(--ds-z-modal, 50); -} - -* { box-sizing: border-box; } - -html, -body { - width: 100%; - height: 100%; - margin: 0; - overflow: hidden; - background: var(--rl-bg); - color: var(--rl-text); - font-family: var(--rl-font); -} - -button, -input, -select { font: inherit; } - -button svg { - width: 16px; - height: 16px; - flex: 0 0 auto; -} - -input:focus-visible, -select:focus-visible, -.rl-selection-hotspot:focus-visible { - outline: 2px solid var(--rl-focus); - outline-offset: 2px; -} - -.remotion-live { - display: grid; - grid-template-rows: auto 2px minmax(0, 1fr); - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - background: var(--rl-bg); -} - -.remotion-live__boot { - display: grid; - place-items: center; - min-height: 100%; - color: var(--rl-muted); -} - -.rl-header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 12px; - min-height: 48px; - padding: 7px 10px; - border-bottom: 1px solid var(--rl-border-subtle); - background: var(--rl-surface); -} - -.rl-header__identity, -.rl-header__actions { - min-width: 0; -} - -.rl-header__workspace, -.rl-header__composition { - overflow: hidden; - color: var(--rl-text-secondary); - font-size: 13px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rl-header__workspace { max-width: 18ch; } -.rl-header__composition { max-width: 22ch; color: var(--rl-text); } -.rl-header__slash { color: var(--rl-muted); } - -.rl-composition-select { - width: min(220px, 28vw); - min-width: 0; - height: 30px; - padding: 0 26px 0 8px; - border: 1px solid transparent; - border-radius: var(--rl-radius-sm); - background: var(--rl-control); - color: var(--rl-text); - cursor: pointer; -} - -.rl-composition-select:hover { border-color: var(--rl-border); } -.rl-header__status { min-width: 0; } -.rl-status__label { overflow: hidden; max-width: 18ch; text-overflow: ellipsis; white-space: nowrap; } -.rl-header__actions { justify-self: end; } -.rl-labelled-action { white-space: nowrap; } -.rl-expand-action { display: none; } - -.rl-status-strip { - position: relative; - overflow: hidden; - background: transparent; -} - -.rl-status-strip span { - position: absolute; - inset: 0; - background: var(--rl-accent); - opacity: 0; - transform: translateX(-100%); -} - -.rl-status-strip.is-busy span { - opacity: .75; - animation: rl-progress 1.4s var(--rl-ease) infinite; -} - -@keyframes rl-progress { - 0% { transform: translateX(-100%) scaleX(.3); } - 55% { transform: translateX(20%) scaleX(.5); } - 100% { transform: translateX(100%) scaleX(.3); } -} - -.rl-content { - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.rl-workbench { - display: grid; - grid-template-rows: minmax(180px, 1fr) auto auto auto; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.rl-stage-area { - display: flex; - align-items: center; - justify-content: center; - min-width: 0; - min-height: 0; - padding: 16px; - overflow: hidden; - background: - radial-gradient(circle at center, color-mix(in srgb, var(--rl-text) 5%, transparent), transparent 66%), - var(--rl-canvas); -} - -.rl-stage { - position: relative; - width: 100%; - max-width: 100%; - max-height: 100%; - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--rl-text) 16%, transparent); - border-radius: var(--rl-radius-sm); - background: var(--rl-stage); - box-shadow: 0 12px 40px color-mix(in srgb, black 34%, transparent); -} - -.rl-stage.is-inspecting { - border-color: color-mix(in srgb, var(--rl-accent) 72%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--rl-accent) 24%, transparent), 0 12px 40px color-mix(in srgb, black 34%, transparent); -} - -.rl-player-frame, -.rl-stage-placeholder { - position: absolute; - inset: 0; - z-index: var(--rl-z-player); - width: 100%; - height: 100%; - border: 0; - background: var(--rl-stage); -} - -.rl-stage-placeholder { - display: grid; - place-items: center; - color: color-mix(in srgb, var(--rl-muted) 58%, transparent); -} - -.rl-stage-placeholder svg { width: 42px; height: 42px; } - -.rl-stage-overlay, -.rl-stage-state { - position: absolute; - inset: 0; - z-index: var(--rl-z-inspect); - display: grid; - place-items: center; - padding: 18px; - background: color-mix(in srgb, var(--rl-stage) 58%, transparent); - color: var(--rl-text-secondary); -} - -.rl-stage-overlay--error { background: color-mix(in srgb, var(--rl-stage) 84%, transparent); } -.rl-stage-overlay .alert { width: min(100%, 420px); } - -.rl-stage-state { - inset: auto 10px 10px auto; - display: block; - padding: 7px 10px; - border: 1px solid var(--rl-border); - border-radius: 999px; - background: color-mix(in srgb, var(--rl-panel) 92%, transparent); - pointer-events: none; -} - -.rl-stage-meta { - position: absolute; - inset: auto 8px 8px 8px; - z-index: var(--rl-z-overlay); - display: flex; - justify-content: space-between; - gap: 8px; - color: color-mix(in srgb, white 78%, transparent); - font-family: var(--rl-mono); - font-size: 10px; - pointer-events: none; - text-shadow: 0 1px 3px black; -} - -.rl-preview-click-layer { - position: absolute; - inset: 0; - z-index: var(--rl-z-overlay); - width: 100%; - height: 100%; - padding: 0; - border: 0; - background: transparent; - cursor: pointer; -} - -.rl-preview-click-layer:focus-visible { - outline: 2px solid var(--rl-focus); - outline-offset: -3px; -} - -.rl-select-capture, -.rl-selection-overlay { - position: absolute; - inset: 0; - z-index: var(--rl-z-inspect); -} - -.rl-select-capture { cursor: crosshair; } -.rl-selection-overlay { pointer-events: none; } - -.rl-selection-hotspot { - position: absolute; - padding: 0; - border: 1px solid color-mix(in srgb, var(--rl-layer-color, var(--rl-accent)) 78%, transparent); - border-radius: 2px; - background: color-mix(in srgb, var(--rl-layer-color, var(--rl-accent)) 8%, transparent); - color: white; - cursor: pointer; - pointer-events: auto; -} - -.rl-selection-hotspot:hover, -.rl-selection-hotspot.is-selected { - border-width: 2px; - background: color-mix(in srgb, var(--rl-layer-color, var(--rl-accent)) 16%, transparent); -} - -.rl-selection-hotspot span { - position: absolute; - left: -1px; - bottom: 100%; - max-width: 180px; - padding: 3px 6px; - overflow: hidden; - border-radius: 3px 3px 3px 0; - background: var(--rl-layer-color, var(--rl-accent)); - color: white; - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rl-sel-point, -.rl-sel-region { - position: absolute; - z-index: var(--rl-z-inspect); - pointer-events: none; -} - -.rl-sel-point { - width: 14px; - height: 14px; - border: 2px solid white; - border-radius: 50%; - background: var(--rl-accent); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--rl-accent) 44%, transparent); - transform: translate(-50%, -50%); -} - -.rl-sel-region { - border: 2px solid var(--rl-accent); - background: color-mix(in srgb, var(--rl-accent) 12%, transparent); -} - -.rl-sel-draft { border-style: dashed; } - -.rl-context-tray { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - min-width: 0; - min-height: 38px; - padding: 5px 10px; - border-top: 1px solid var(--rl-border-subtle); - background: color-mix(in srgb, var(--rl-accent) 7%, var(--rl-surface)); - color: var(--rl-text-secondary); - font-size: 12px; -} - -.rl-context-tray__copy { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.rl-context-tray__copy > span:last-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rl-transport { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - min-height: 54px; - padding: 7px 10px; - border-top: 1px solid var(--rl-border-subtle); - background: var(--rl-surface); -} - -.rl-transport__playback, -.rl-transport__audio { - display: flex; - align-items: center; - gap: 3px; -} - -.rl-play-btn { - width: 40px; - height: 40px; - border-radius: 50%; -} - -.rl-play-btn.is-pending { opacity: .72; } - -.rl-timecode { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - min-width: 0; - font-family: var(--rl-mono); - font-variant-numeric: tabular-nums; -} - -.rl-timecode__value { - color: var(--rl-text); - font-size: 13px; - font-weight: 600; - letter-spacing: .03em; -} - -.rl-timecode__frame { - display: inline-flex; - align-items: center; - color: var(--rl-muted); - font-size: 11px; - white-space: nowrap; -} - -.rl-timecode__frame input { - width: 54px; - height: 28px; - padding: 0 5px; - border: 1px solid var(--rl-border-subtle); - border-radius: var(--rl-radius-sm); - background: var(--rl-control); - color: var(--rl-text); - text-align: right; - font-family: var(--rl-mono); -} - -.rl-volume { - width: 76px; - accent-color: var(--rl-accent); - cursor: pointer; -} - -.rl-review { - min-width: 0; - padding: 8px 10px 10px; - border-top: 1px solid var(--rl-border-subtle); - background: var(--rl-panel); -} - -.rl-review__header, -.rl-review__ticks { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.rl-review__header { - margin-bottom: 6px; - color: var(--rl-muted); - font-size: 10px; -} - -.rl-review__header strong { - color: var(--rl-text-secondary); - font-size: 11px; - font-weight: 600; -} - -.rl-review-track { - position: relative; - height: 28px; -} - -.rl-review-track::before { - content: ""; - position: absolute; - inset: 10px 0; - border-radius: 999px; - background: var(--rl-control); -} - -.rl-review-track__segments { - position: absolute; - inset: 8px 0; - pointer-events: none; -} - -.rl-review-track__segments span { - position: absolute; - height: 12px; - border-left: 1px solid color-mix(in srgb, var(--rl-accent) 58%, transparent); - background: color-mix(in srgb, var(--rl-accent) 13%, transparent); -} - -.rl-review-scrub { - position: absolute; - inset: 0; - width: 100%; - height: 28px; - margin: 0; - appearance: none; - background: transparent; - cursor: ew-resize; -} - -.rl-review-scrub::-webkit-slider-runnable-track { - height: 4px; - border-radius: 999px; - background: linear-gradient(to right, var(--rl-accent) 0 var(--rl-progress), transparent var(--rl-progress) 100%); -} - -.rl-review-scrub::-webkit-slider-thumb { - width: 14px; - height: 14px; - margin-top: -5px; - appearance: none; - border: 2px solid var(--rl-panel); - border-radius: 50%; - background: var(--rl-accent); - box-shadow: 0 0 0 1px var(--rl-accent); -} - -.rl-review-scrub::-moz-range-track { height: 4px; border-radius: 999px; background: var(--rl-control); } -.rl-review-scrub::-moz-range-progress { height: 4px; border-radius: 999px; background: var(--rl-accent); } -.rl-review-scrub::-moz-range-thumb { width: 12px; height: 12px; border: 2px solid var(--rl-panel); border-radius: 50%; background: var(--rl-accent); } - -.rl-review-playhead { - position: absolute; - top: 2px; - width: 1px; - height: 24px; - background: var(--rl-accent); - pointer-events: none; -} - -.rl-review__ticks { - color: var(--rl-muted); - font-family: var(--rl-mono); - font-size: 9px; -} - -.rl-empty, -.rl-detection { - width: min(520px, calc(100% - 32px)); - margin: max(24px, 8vh) auto; -} - -.rl-empty__icon { color: var(--rl-muted); } -.rl-empty__icon svg { width: 34px; height: 34px; } -.rl-empty p { margin: 0; } - -.rl-detection h2 { - margin: 0 0 6px; - color: var(--rl-text); - font-size: 16px; -} - -.rl-detection p { color: var(--rl-muted); font-size: 13px; line-height: 1.5; } -.rl-detection .btn { margin-top: 12px; } - -.rl-entry { - justify-content: space-between; - width: 100%; - min-width: 0; - text-align: left; -} - -.rl-entry span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.rl-entry small { color: var(--rl-muted); } - -.rl-export-dialog { z-index: var(--rl-z-modal); } - -.rl-export-job { - position: fixed; - right: 12px; - bottom: 12px; - z-index: var(--rl-z-modal); - width: min(360px, calc(100vw - 24px)); - gap: 7px; - box-shadow: 0 16px 48px color-mix(in srgb, black 38%, transparent); -} - -.rl-export-job__row { display: flex; align-items: center; gap: 8px; } -.rl-export-job__row strong { flex: 1; color: var(--rl-text); font-size: 12px; } -.rl-export-job small { overflow: hidden; color: var(--rl-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } -.rl-export-job__error { color: var(--rl-error) !important; } - -.rl-export-progress { - height: 3px; - overflow: hidden; - border-radius: 999px; - background: var(--rl-control); -} - -.rl-export-progress span { display: block; height: 100%; background: var(--rl-accent); } - -@media (max-width: 719px) { - .rl-expand-action { display: inline-flex; } - .rl-header { - grid-template-columns: minmax(0, 1fr) auto auto; - gap: 7px; - padding-inline: 8px; - } - - .rl-header__workspace { max-width: 12ch; } - .rl-header__composition { max-width: 14ch; } - .rl-composition-select { width: min(150px, 26vw); } - .rl-header__actions { gap: 2px; } - .rl-labelled-action span { display: none; } - .rl-labelled-action[data-action="start-export"] span { display: inline; } - .rl-stage-area { padding: 8px; } - .rl-volume { display: none; } - .rl-transport { gap: 6px; padding-inline: 7px; } - .rl-timecode { gap: 6px; } - .rl-timecode__frame input { width: 46px; } -} - -@media (max-width: 479px) { - .remotion-live { grid-template-rows: auto 2px minmax(0, 1fr); } - - .rl-header { - grid-template-columns: minmax(0, 1fr) auto; - grid-template-areas: "identity status" "actions actions"; - min-height: 76px; - padding: 5px 7px; - } - - .rl-header__identity { grid-area: identity; } - .rl-header__status { grid-area: status; } - .rl-header__actions { grid-area: actions; justify-self: stretch; justify-content: flex-end; } - .rl-header__workspace { max-width: 9ch; } - .rl-header__composition { max-width: 11ch; } - .rl-composition-select { width: min(130px, 37vw); } - .rl-status__label { max-width: 10ch; } - .rl-workbench { grid-template-rows: minmax(140px, 1fr) auto auto auto; } - .rl-stage-area { padding: 5px; } - .rl-stage-meta span:first-child { display: none; } - .rl-transport { grid-template-columns: auto minmax(0, 1fr) auto; min-height: 50px; } - .rl-timecode { flex-direction: column; gap: 1px; } - .rl-timecode__frame { display: none; } - .rl-review { padding-inline: 7px; } - .rl-review__header > span { display: none; } - .rl-context-tray { align-items: flex-start; } - .rl-context-tray__copy { align-items: flex-start; flex-direction: column; gap: 3px; } -} - -@media (max-width: 359px) { - .rl-header { gap: 4px; padding-inline: 5px; } - .rl-header__workspace { max-width: 7ch; } - .rl-header__slash { display: none; } - .rl-header__composition { max-width: 8ch; } - .rl-composition-select { width: 96px; } - .rl-header__actions { justify-content: space-between; } - .rl-header__actions .btn { min-width: 30px; height: 30px; padding-inline: 7px; } - .rl-header__actions [data-action="start-export"] { padding-inline: 8px; font-size: 12px; } - .rl-status__label { max-width: 8ch; } - .rl-stage-area { padding: 3px; } - .rl-stage { border-radius: 3px; } - .rl-transport { grid-template-columns: auto 1fr auto; gap: 3px; padding-inline: 4px; } - .rl-transport__playback { gap: 0; } - .rl-transport__playback .btn:not(.rl-play-btn) { width: 28px; min-height: 28px; } - .rl-play-btn { width: 36px; height: 36px; min-height: 36px; } - .rl-timecode__value { font-size: 11px; } - .rl-transport__audio .btn { width: 30px; min-height: 30px; } - .rl-review { padding-top: 6px; padding-bottom: 7px; } - .rl-review__header { margin-bottom: 2px; } - .rl-review-track { height: 25px; } - .rl-context-tray { padding-inline: 6px; } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - transition-duration: 1ms !important; - animation-duration: 1ms !important; - animation-iteration-count: 1 !important; - } -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/ui.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/ui.js deleted file mode 100644 index 529d6000..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/ui.js +++ /dev/null @@ -1,288 +0,0 @@ -import { - cancelExport, - confirmExport, - dismissExportDialog, - dismissExportRun, - refreshProject, - requestExport, - resetExportState, - retryPreview, - selectEntry, - selectPreviewLayer, - sendContext, - setComposition, - setFrame, - setInteractionMode, - setPointSelection, - setRegionSelection, - setVolume, - stepFrame, - toggleInspect, - toggleMuted, - togglePlayback, -} from './src/actions.js'; -import { normalizeRoute } from './src/constants.js'; -import { compositionDuration } from './src/model.js'; -import { requestPlayerHandshake, resetPlayerChannelConnection } from './src/player-dom.js'; -import { sendOrQueuePlayerCommand } from './src/player-protocol.js'; -import { resetPlayerRuntimeState } from './src/preview-controller.js'; -import { clearPlayerHostPoll, ensurePlayerPreviewHost, pollPlayerPreviewHostStatus } from './src/preview-runtime.js'; -import { clearSelection, render, updateTimelineDom } from './src/render-core.js'; -import { isSelectionStartTarget, stageNormalizedPoint } from './src/selection-geom.js'; -import { state } from './src/state.js'; -import { closestElement, runtime } from './src/util.js'; -import { fitPreviewStage, isFrameCommitted, removeDraftMarker, updateDraftMarkerDom } from './src/views.js'; - -let layoutObserver = null; -let fitAnimationFrame = null; - -function scheduleStageFit() { - if (fitAnimationFrame) cancelAnimationFrame(fitAnimationFrame); - fitAnimationFrame = requestAnimationFrame(() => { - fitAnimationFrame = null; - fitPreviewStage(); - }); -} - -function observeLayout() { - if (layoutObserver || typeof ResizeObserver === 'undefined') return; - const root = document.getElementById('remotionLiveRoot'); - if (!root) return; - layoutObserver = new ResizeObserver(scheduleStageFit); - layoutObserver.observe(root); -} - -function handleRouteEvent(payload = {}) { - state.route = normalizeRoute(payload.route || state.route); - state.tabId = payload.tabId || state.tabId; - state.sessionId = payload.sessionId || state.sessionId; - const nextWorkspace = payload.workspacePath || payload.workbench?.workspacePath || state.workspacePath; - const workspaceChanged = Boolean(nextWorkspace && nextWorkspace !== state.workspacePath); - state.workspacePath = nextWorkspace || state.workspacePath; - - if (workspaceChanged) { - resetExportState(); - clearPlayerHostPoll(); - state.project = null; - state.error = null; - state.phase = 'idle'; - state.manifest = null; - state.detection = null; - state.selectedEntry = null; - state.activeCompositionId = null; - state.frame = 0; - state.playerFrameModel = null; - state.playerHost = null; - state.playerHostError = null; - state.playerHostLoading = false; - state.selection = null; - state.selectedElementId = null; - state.interactionMode = 'preview'; - resetPlayerChannelConnection({ rotateNonce: true }); - resetPlayerRuntimeState(); - } - - render(); - if (workspaceChanged || (!state.project && state.workspacePath)) void refreshProject(); -} - -function pauseForInspectGesture() { - if (!state.playerRuntimePlaying && !state.playerDesiredState?.playing) return; - sendOrQueuePlayerCommand('pause', { frame: state.frame }); -} - -document.addEventListener('pointerdown', (event) => { - if (!isSelectionStartTarget(event.target)) return; - state.selectionPointerDown = true; - pauseForInspectGesture(); -}, true); - -document.addEventListener('pointerdown', (event) => { - if (state.interactionMode !== 'inspect' || event.button !== 0) return; - const capture = closestElement(event.target, '[data-select-capture]'); - if (!capture) return; - const start = stageNormalizedPoint(event); - if (!start) return; - state.selectionDragging = true; - state.selectionDraft = { - startX: start.x, - startY: start.y, - x: start.x, - y: start.y, - width: 0, - height: 0, - moved: false, - }; - try { - capture.setPointerCapture(event.pointerId); - } catch { - // Pointer capture is an enhancement; document-level listeners still complete the gesture. - } - updateDraftMarkerDom(); -}, true); - -document.addEventListener('pointermove', (event) => { - if (state.interactionMode !== 'inspect' || !state.selectionDragging || !state.selectionDraft) return; - const current = stageNormalizedPoint(event); - if (!current) return; - const draft = state.selectionDraft; - draft.x = Math.min(draft.startX, current.x); - draft.y = Math.min(draft.startY, current.y); - draft.width = Math.abs(current.x - draft.startX); - draft.height = Math.abs(current.y - draft.startY); - draft.moved = draft.width > 1.5 || draft.height > 1.5; - updateDraftMarkerDom(); -}, true); - -document.addEventListener('pointerup', () => { - state.selectionPointerDown = false; - if (!state.selectionDragging) return; - state.selectionDragging = false; - const draft = state.selectionDraft; - state.selectionDraft = null; - removeDraftMarker(); - if (!draft) return; - if (draft.moved) setRegionSelection({ x: draft.x, y: draft.y, width: draft.width, height: draft.height }); - else setPointSelection({ x: draft.startX, y: draft.startY }); - if (state.renderQueued) render(); -}, true); - -document.addEventListener('load', (event) => { - const node = event.target; - if (!node?.classList?.contains('rl-player-frame')) return; - state.playerRuntimeReady = false; - resetPlayerChannelConnection({ rotateNonce: false }); - requestPlayerHandshake(); -}, true); - -window.addEventListener('resize', scheduleStageFit); - -window.addEventListener('remotion-live:ensure-player-host', (event) => { - void ensurePlayerPreviewHost(Boolean(event.detail?.force)); -}); - -window.addEventListener('remotion-live:poll-player-host-status', () => { - void pollPlayerPreviewHostStatus(); -}); - -window.addEventListener('remotion-live:render-request', render); - -document.addEventListener('click', (event) => { - const layerNode = closestElement(event.target, '[data-preview-layer-id]'); - if (layerNode && state.interactionMode === 'inspect') { - event.preventDefault(); - event.stopPropagation(); - selectPreviewLayer(layerNode.dataset.previewLayerId); - return; - } - - const actionNode = closestElement(event.target, '[data-action]'); - if (!actionNode || actionNode.disabled) return; - const action = actionNode.dataset.action; - if (action === 'refresh') void refreshProject({ fresh: true }); - if (action === 'retry-preview') retryPreview(); - if (action === 'select-entry') selectEntry(actionNode.dataset.entry); - if (action === 'clear-selection') clearSelection(); - if (action === 'send-context' && isFrameCommitted()) void sendContext(); - if (action === 'step-prev') stepFrame(-1); - if (action === 'step-next') stepFrame(1); - if (action === 'toggle-play') togglePlayback(); - if (action === 'toggle-inspect') toggleInspect(); - if (action === 'toggle-muted') toggleMuted(); - if (action === 'start-export') requestExport(); - if (action === 'export-confirm') void confirmExport(); - if (action === 'export-dismiss') dismissExportDialog(); - if (action === 'export-run-dismiss') dismissExportRun(); - if (action === 'cancel-export') void cancelExport(); - if (action === 'expand-panel') void runtime().host?.setPanelMode?.('expanded'); -}); - -document.addEventListener('cancel', (event) => { - if (!event.target?.classList?.contains('rl-export-dialog')) return; - event.preventDefault(); - dismissExportDialog(); -}, true); - -function editableTarget(target) { - const element = closestElement(target, 'input,select,textarea,[contenteditable="true"]'); - return Boolean(element); -} - -document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') { - if (state.exportConfirmOpen) { - event.preventDefault(); - dismissExportDialog(); - } else if (state.selection) { - event.preventDefault(); - clearSelection(); - } else if (state.interactionMode === 'inspect') { - event.preventDefault(); - setInteractionMode('preview'); - } - return; - } - if (editableTarget(event.target) || event.altKey || event.ctrlKey || event.metaKey) { - if ((event.ctrlKey || event.metaKey) && event.key === 'Enter' && isFrameCommitted()) { - event.preventDefault(); - void sendContext(); - } - return; - } - if (event.code === 'Space' || event.key.toLowerCase() === 'k') { - event.preventDefault(); - togglePlayback(); - } else if (event.key === 'ArrowLeft') { - event.preventDefault(); - stepFrame(event.shiftKey ? -10 : -1); - } else if (event.key === 'ArrowRight') { - event.preventDefault(); - stepFrame(event.shiftKey ? 10 : 1); - } else if (event.key.toLowerCase() === 'i') { - event.preventDefault(); - toggleInspect(); - } else if (event.key.toLowerCase() === 'm') { - event.preventDefault(); - toggleMuted(); - } else if (event.key === 'Home') { - event.preventDefault(); - setFrame(0); - } else if (event.key === 'End') { - event.preventDefault(); - setFrame(compositionDuration() - 1); - } -}); - -document.addEventListener('change', (event) => { - const node = event.target; - if (node?.dataset?.action === 'select-composition') setComposition(node.value); - if (node?.dataset?.action === 'frame-number') setFrame(node.value); - if (node?.dataset?.action === 'frame-range') setFrame(node.value); -}); - -document.addEventListener('input', (event) => { - const node = event.target; - if (node?.dataset?.action === 'frame-range') setFrame(node.value, { fastSync: true }); - if (node?.dataset?.action === 'volume') setVolume(node.value); -}); - -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; - render(); - } - if (message.event === 'productAppRuntimeRouteChange') handleRouteEvent(message.payload || {}); -}); - -runtime().onLocaleChange?.((locale) => { - state.locale = locale || state.locale; - render(); -}); - -window.addEventListener('DOMContentLoaded', () => { - render(); - observeLayout(); - scheduleStageFit(); -}); diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/worker.js b/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/worker.js deleted file mode 100644 index 7a65ed27..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/components/surfaces/builtin-remotion-live-surface/source/worker.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; - -async function status() { - return { - ok: true, - mode: 'composite', - runtime: 'host-panel', - }; -} - -module.exports = { - status, -}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/config/data-schema.json b/bundles/product-apps/builtin-remotion-live/19.0.0/config/data-schema.json deleted file mode 100644 index 1587a669..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/config/data-schema.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": "1.0.0" -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/config/default.json b/bundles/product-apps/builtin-remotion-live/19.0.0/config/default.json deleted file mode 100644 index 0967ef42..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/config/default.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/tests/eval.json b/bundles/product-apps/builtin-remotion-live/19.0.0/tests/eval.json deleted file mode 100644 index 6d1b7921..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/tests/eval.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "version": 1, - "cases": [ - { - "id": "primary-behavior", - "title": "Remotion 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 Remotion Live." - }, - "expectations": [ - { - "kind": "text-contains", - "value": "SPARO_APP_READY" - } - ], - "evidenceKind": "behavior", - "tags": [ - "release-gate" - ], - "required": true - } - ] -} diff --git a/bundles/product-apps/builtin-remotion-live/19.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-remotion-live/19.0.0/tests/rehearsal.json deleted file mode 100644 index 4be1aa57..00000000 --- a/bundles/product-apps/builtin-remotion-live/19.0.0/tests/rehearsal.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "version": 1, - "scenarios": [ - { - "id": "critical-user-path", - "title": "Open Remotion Live and complete its first useful action", - "description": "A Remotion video workbench for previewing, adjusting, fixing, and exporting motion projects.", - "kind": "user-path", - "steps": [ - { - "id": "open-app", - "action": "open", - "target": "primary", - "expect": [ - "Remotion 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.2/components/bridges/builtin-remotion-runtime/source/src/constants.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/src/constants.js index a9a4d2ab..99d063bf 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/src/constants.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/src/constants.js @@ -44,8 +44,9 @@ const REMOTION_RENDER_TIMEOUT_MS = 240_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_RUNTIME_VERSION = 14; -const PLAYER_CONTROL_PROTOCOL_VERSION = 3; +const PLAYER_HOST_RUNTIME_VERSION = 15; +const PLAYER_CONTROL_PROTOCOL_VERSION = 4; +const PLAYER_BASELINE_REVISION = -1; const ASSET_EXTENSIONS = new Map([ [".png", "image"], [".jpg", "image"], @@ -82,5 +83,6 @@ module.exports = { PLAYER_HOST_BOOT_WAIT_MS, PLAYER_HOST_RUNTIME_VERSION, PLAYER_CONTROL_PROTOCOL_VERSION, + PLAYER_BASELINE_REVISION, ASSET_EXTENSIONS, }; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/src/player-host.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/src/player-host.js index 404f6733..de937e54 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/src/player-host.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/src/player-host.js @@ -6,7 +6,12 @@ const { hashContent, readJson, writeJson, isProcessAlive, httpStatus, tailPlayer const { compositionForInput } = require("./project"); 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 { + PLAYER_BASELINE_REVISION, + PLAYER_CONTROL_PROTOCOL_VERSION, + PLAYER_HOST_RUNTIME_VERSION, + PLAYER_HOST_BOOT_WAIT_MS, +} = require("./constants"); const playerHostFlights = new Map(); const playerHostWorkspaceTails = new Map(); @@ -33,6 +38,7 @@ function playerHostBundleId(manifest) { return hashContent(JSON.stringify({ runtimeVersion: PLAYER_HOST_RUNTIME_VERSION, protocolVersion: PLAYER_CONTROL_PROTOCOL_VERSION, + baselineRevision: PLAYER_BASELINE_REVISION, buildId: manifest.buildId, entryPoint: manifest.entryPoint, })); @@ -100,10 +106,11 @@ const resolvedProps = composition.serializedResolvedProps : composition.resolvedProps; const instanceId = initialParams.get("instanceId") || "default"; const channelNonce = initialParams.get("channelNonce") || ""; +const connectionGeneration = Number(initialParams.get("connectionGeneration")) || 0; 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; +let activeConnectionId: string | null = null; function clampFrame(value: unknown) { const frame = Math.round(Number(value) || 0); @@ -111,8 +118,8 @@ function clampFrame(value: unknown) { } function post(type: string, payload: Record = {}) { - if (!controlPort) return false; - controlPort.postMessage({ + if (!activeConnectionId) return false; + window.parent?.postMessage({ ...payload, source: "sparo-remotion-player-host", runtimeVersion, @@ -123,7 +130,9 @@ function post(type: string, payload: Record = {}) { descriptorRevision: composition.descriptorRevision, instanceId, channelNonce, - }); + connectionGeneration, + connectionId: activeConnectionId, + }, "*"); return true; } @@ -138,6 +147,8 @@ function announceBootstrap() { descriptorRevision: composition.descriptorRevision, instanceId, channelNonce, + connectionGeneration, + transport: "window-message", }, "*"); } @@ -269,7 +280,7 @@ function PlayerRuntime({Component}: {Component: React.ComponentType { return clampFrame(initialParams.get("frame")); }, []); @@ -370,10 +381,19 @@ function PlayerRuntime({Component}: {Component: React.ComponentType { + post("commandFailed", { + commandId: message.commandId, + command: message.command, + revision, + message: error instanceof Error ? error.message : String(error), + }); + postActualState(); + }; try { - player.play(); + Promise.resolve(player.play()).catch(reportPlayFailure); } catch (error) { - post("error", {message: error instanceof Error ? error.message : String(error), revision}); + reportPlayFailure(error); } } else { player.pause(); @@ -536,42 +556,47 @@ function PlayerRuntime({Component}: {Component: React.ComponentType { - 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(); + // identity across postMessage. Authenticate every message with the + // URL-bound nonce, generation, connection id, and immutable revisions. + activeConnectionId = message.connectionId; + readyPostedRef.current = false; post("channelReady", {revision: activeRevisionRef.current}); ensurePlayerReady(); }; + const onCommand = (event: MessageEvent) => { + const command = event.data || {}; + if (command.source !== "sparo-remotion-live" || command.type === "connect") 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 (command.connectionGeneration !== connectionGeneration) return; + if (!activeConnectionId || command.connectionId !== activeConnectionId) return; + if (command.type === "ping") { + post("pong", {sentAt: command.sentAt, receivedAt: Date.now()}); + return; + } + if (!ensurePlayerReady()) { + if (command.type === "reconcile") { + pendingCommandsRef.current = pendingCommandsRef.current.filter((item) => item.type !== "reconcile"); + } + pendingCommandsRef.current.push(command); + return; + } + runCommand(command); + }; window.addEventListener("message", onConnect); + window.addEventListener("message", onCommand); announceBootstrap(); const bootstrapTimer = window.setInterval(() => { - if (controlPort) { + if (activeConnectionId) { window.clearInterval(bootstrapTimer); return; } @@ -580,8 +605,8 @@ function PlayerRuntime({Component}: {Component: React.ComponentType { window.clearInterval(bootstrapTimer); window.removeEventListener("message", onConnect); - controlPort?.close(); - controlPort = null; + window.removeEventListener("message", onCommand); + activeConnectionId = null; }; }, [ensurePlayerReady, runCommand]); diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/test/player-window-transport.test.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/test/player-window-transport.test.js new file mode 100644 index 00000000..cc2c50c0 --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/bridges/builtin-remotion-runtime/source/test/player-window-transport.test.js @@ -0,0 +1,102 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { pathToFileURL } = require("node:url"); + +const { + PLAYER_BASELINE_REVISION, + PLAYER_CONTROL_PROTOCOL_VERSION, + PLAYER_HOST_RUNTIME_VERSION, +} = require("../src/constants"); +const { writePlayerHostEntry } = require("../src/player-host"); + +test("generated Player host uses authenticated window messaging without MessagePort transfer", () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "sparo-remotion-transport-")); + try { + const entryPoint = path.join(workspacePath, "src", "index.ts"); + fs.mkdirSync(path.dirname(entryPoint), { recursive: true }); + fs.writeFileSync(entryPoint, "export {};\n", "utf8"); + + const generatedPath = writePlayerHostEntry(workspacePath, { + entryPoint: "src/index.ts", + projectRevision: "project-revision", + compositions: [{ + id: "TransportTest", + descriptorRevision: "descriptor-revision", + durationInFrames: 30, + fps: 30, + width: 1920, + height: 1080, + resolvedProps: {}, + }], + }, "transport-test"); + const generated = fs.readFileSync(generatedPath, "utf8"); + + assert.equal(PLAYER_CONTROL_PROTOCOL_VERSION, 4); + assert.match(generated, /transport: "window-message"/); + assert.match(generated, /connectionGeneration/); + assert.match(generated, /connectionId: activeConnectionId/); + assert.match(generated, new RegExp(`activeRevisionRef = useRef\\(${PLAYER_BASELINE_REVISION}\\)`)); + assert.match(generated, /Promise\.resolve\(player\.play\(\)\)\.catch\(reportPlayFailure\)/); + assert.match(generated, /post\("commandFailed"/); + assert.match(generated, /window\.parent\?\.postMessage/); + assert.doesNotMatch(generated, /new MessageChannel/); + assert.doesNotMatch(generated, /event\.ports/); + + const surfaceTransportPath = path.resolve( + __dirname, + "../../../../surfaces/builtin-remotion-live-surface/source/src/player-dom.js", + ); + const surfaceTransport = fs.readFileSync(surfaceTransportPath, "utf8"); + assert.match(surfaceTransport, /transport: 'window-message'/); + assert.match(surfaceTransport, /connectionGeneration/); + assert.doesNotMatch(surfaceTransport, /new MessageChannel/); + } finally { + fs.rmSync(workspacePath, { recursive: true, force: true }); + } +}); + +test("connection lifecycle is independent from playback state revision ordering", async () => { + const contractPath = path.resolve( + __dirname, + "../../../../surfaces/builtin-remotion-live-surface/source/src/player-state-contract.js", + ); + const surfaceConstantsPath = path.join(path.dirname(contractPath), "constants.js"); + const [contract, surfaceConstants] = await Promise.all([ + import(pathToFileURL(contractPath).href), + import(pathToFileURL(surfaceConstantsPath).href), + ]); + + assert.equal(contract.PLAYER_BASELINE_REVISION, PLAYER_BASELINE_REVISION); + assert.equal(surfaceConstants.PLAYER_HOST_RUNTIME_VERSION, PLAYER_HOST_RUNTIME_VERSION); + assert.equal(surfaceConstants.PLAYER_CONTROL_PROTOCOL_VERSION, PLAYER_CONTROL_PROTOCOL_VERSION); + assert.equal(contract.isStalePlayerStateMessage({ type: "channelReady", revision: -1 }, 4), false); + assert.equal(contract.isStalePlayerStateMessage({ type: "ready", revision: -1 }, 4), false); + assert.equal(contract.isStalePlayerStateMessage({ type: "error", revision: -1 }, 4), false); + assert.equal(contract.isStalePlayerStateMessage({ type: "actualState", revision: 3 }, 4), true); + assert.equal(contract.isStalePlayerStateMessage({ type: "actualState", revision: 4 }, 4), false); + assert.equal(contract.isPlayerConnectionLifecycleMessage({ type: "ready" }), true); + assert.equal(contract.isPlayerConnectionLifecycleMessage({ type: "actualState" }), false); + assert.equal(contract.isPlayerRuntimeEvidenceMessage({ type: "ready" }), true); + assert.equal(contract.isPlayerRuntimeEvidenceMessage({ type: "paused" }), true); + assert.equal(contract.isPlayerRuntimeEvidenceMessage({ type: "commandFailed" }), true); + assert.equal(contract.isPlayerRuntimeEvidenceMessage({ type: "channelReady" }), false); +}); + +test("surface reconnects a replaced frame and reconciles desired state on channel ready", () => { + const surfaceRoot = path.resolve( + __dirname, + "../../../../surfaces/builtin-remotion-live-surface/source/src", + ); + const protocol = fs.readFileSync(path.join(surfaceRoot, "player-protocol.js"), "utf8"); + const renderCore = fs.readFileSync(path.join(surfaceRoot, "render-core.js"), "utf8"); + + const channelReadyIndex = protocol.indexOf("message.type === 'channelReady'"); + const staleStateIndex = protocol.indexOf("isStalePlayerStateMessage(message"); + assert.ok(channelReadyIndex >= 0 && channelReadyIndex < staleStateIndex); + assert.match(protocol, /beginPlayerStateConnection\(message\);[\s\S]*flushPlayerCommand\(\);/); + assert.match(protocol, /message\.type === 'commandFailed'/); + assert.match(renderCore, /nextFrame !== previousFrame[\s\S]*resetPlayerChannelConnection\(\)/); +}); diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/constants.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/constants.js index a7ce4933..ee198587 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/constants.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/constants.js @@ -1,7 +1,7 @@ const ROUTES = new Set(['/preview']); -const PLAYER_HOST_RUNTIME_VERSION = 14; -const PLAYER_CONTROL_PROTOCOL_VERSION = 3; +const PLAYER_HOST_RUNTIME_VERSION = 15; +const PLAYER_CONTROL_PROTOCOL_VERSION = 4; const MESSAGES = { 'en-US': { diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-dom.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-dom.js index cf465e51..39235e60 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-dom.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-dom.js @@ -6,11 +6,20 @@ import { ensurePlayerInstanceId, playerPreviewReady } from './preview-controller import { state } from './state.js'; import { t } from './util.js'; +const HANDSHAKE_RETRY_DELAYS_MS = [500, 750, 1_250, 1_500]; +const PLAYER_READY_TIMEOUT_MS = 12_000; +const HEARTBEAT_INTERVAL_MS = 3_000; +const HEARTBEAT_TIMEOUT_MS = 7_000; +const MAX_FRAME_RECOVERY_ATTEMPTS = 1; + let channelIdentity = null; -let playerPort = null; let playerMessageHandler = null; let readinessHandshakeIdentityKey = null; const readinessHandshakeSignals = new Set(); +let connectionCounter = 0; +let connectionGeneration = 0; +let heartbeatTimer = null; +let reportedReadyConnectionId = null; function randomNonce() { const bytes = new Uint8Array(24); @@ -19,20 +28,58 @@ function randomNonce() { } +function reportPlayerRuntimeLog(level, message, details = {}) { + try { + window.parent?.postMessage({ + method: 'sparo/runtime-log', + params: { + level, + category: 'remotion-preview', + message, + source: 'player-dom.js', + details, + timestampMs: Date.now(), + }, + }, '*'); + } catch { + // Diagnostics must never interfere with preview recovery. + } +} + + function playerFrameNode() { return document.querySelector('.rl-player-frame'); } -function closePlayerPort() { - if (playerPort) { - playerPort.onmessage = null; - playerPort.onmessageerror = null; - playerPort.close(); +function clearHeartbeat() { + if (!heartbeatTimer) return; + clearInterval(heartbeatTimer); + heartbeatTimer = null; +} + + +function clearPlayerReadyTimer() { + if (!state.playerReadyTimer) return; + clearTimeout(state.playerReadyTimer); + state.playerReadyTimer = null; +} + + +function closePlayerConnection() { + clearHeartbeat(); + clearPlayerReadyTimer(); + if (state.playerCommandTimer) { + clearTimeout(state.playerCommandTimer); + state.playerCommandTimer = null; } - playerPort = null; + if (channelIdentity) channelIdentity.connectionId = null; + state.playerInFlightCommand = null; state.playerChannelConnected = false; state.playerConnectionState = 'disconnected'; + state.playerConnectionTransport = null; + state.playerConnectionId = null; + state.playerLastMessageAt = null; } @@ -64,25 +111,43 @@ function nonceFromRenderedFrame(instanceId) { } +function generationFromRenderedFrame(instanceId) { + const src = playerFrameNode()?.getAttribute('src'); + if (!src) return null; + try { + const url = new URL(src, window.location.href); + if (url.searchParams.get('instanceId') !== instanceId) return null; + const value = Number(url.searchParams.get('connectionGeneration')); + return Number.isInteger(value) && value > 0 ? value : null; + } catch { + return null; + } +} + + function ensurePlayerChannelIdentity() { const key = playerChannelKey(); if (!channelIdentity || channelIdentity.key !== key) { - closePlayerPort(); + closePlayerConnection(); resetReadinessHandshakeSignals(); const instanceId = ensurePlayerInstanceId(); + connectionGeneration += 1; channelIdentity = { key, instanceId, nonce: nonceFromRenderedFrame(instanceId) || randomNonce(), + generation: generationFromRenderedFrame(instanceId) || connectionGeneration, + connectionId: null, }; state.playerChannelNonce = channelIdentity.nonce; + state.playerConnectionGeneration = channelIdentity.generation; } return channelIdentity; } function resetPlayerChannelConnection(options = {}) { - closePlayerPort(); + closePlayerConnection(); if (state.playerHandshakeTimer) { clearTimeout(state.playerHandshakeTimer); state.playerHandshakeTimer = null; @@ -92,6 +157,10 @@ function resetPlayerChannelConnection(options = {}) { state.playerChannelNonce = null; resetReadinessHandshakeSignals(); } + if (options.resetRecovery) { + state.playerRecoveryAttempt = 0; + state.playerConnectionErrorCode = null; + } } @@ -105,10 +174,9 @@ function activatePlayerHandshake(signal) { if (readinessHandshakeSignals.has(signal)) return; readinessHandshakeSignals.add(signal); - // A pre-load transfer can be lost before the child installs its connect - // listener. The iframe load and bootstrap messages are monotonic readiness - // signals, so each may replace that speculative lease exactly once. Repeated - // bootstrap pulses never churn an in-flight MessagePort. + // A speculative connect may arrive before the child installs its listener. + // The frame load and bootstrap messages each restart that lease once, while + // repeated bootstrap pulses cannot churn an in-flight connection. if (state.playerHandshakeTimer) { clearTimeout(state.playerHandshakeTimer); state.playerHandshakeTimer = null; @@ -134,6 +202,7 @@ function playerHostUrl(options = {}) { url.searchParams.set('frame', String(Math.round(Number(state.frame) || 0))); url.searchParams.set('instanceId', identity.instanceId); url.searchParams.set('channelNonce', identity.nonce); + url.searchParams.set('connectionGeneration', String(identity.generation)); if (options.cacheBust || state.playerReloadNonce) { url.searchParams.set('_rl', String(state.playerReloadNonce)); } @@ -149,66 +218,171 @@ function setPlayerMessageHandler(handler) { } -function handlePlayerBootstrap(event) { - const node = playerFrameNode(); +function messageMatchesPreview(message, identity) { const composition = currentComposition(); - const message = event.data || {}; - if (!node?.contentWindow) return; - if (!composition || message.source !== 'sparo-remotion-player-host' || message.type !== 'bootstrapReady') return; - if (message.protocolVersion !== PLAYER_CONTROL_PROTOCOL_VERSION) return; - if (message.compositionId !== composition.id) return; - if (message.projectRevision !== (state.manifest?.projectRevision || state.manifest?.sourceRevision)) return; - if (message.descriptorRevision !== (composition.descriptorRevision || state.manifest?.descriptorRevision)) return; - const identity = ensurePlayerChannelIdentity(); - if (message.instanceId !== identity.instanceId || message.channelNonce !== identity.nonce) return; - // WebView2 may expose different WindowProxy identities for the same nested, - // opaque iframe. The bootstrap message is a monotonic child-ready signal; - // the private channel remains bound to this navigation by the high-entropy - // nonce and immutable preview identity. - activatePlayerHandshake('bootstrap-ready'); + if (!composition || message.source !== 'sparo-remotion-player-host') return false; + if (message.protocolVersion !== PLAYER_CONTROL_PROTOCOL_VERSION) return false; + if (message.compositionId !== composition.id) return false; + if (message.projectRevision !== (state.manifest?.projectRevision || state.manifest?.sourceRevision)) return false; + if (message.descriptorRevision !== (composition.descriptorRevision || state.manifest?.descriptorRevision)) return false; + if (message.instanceId !== identity.instanceId || message.channelNonce !== identity.nonce) return false; + return message.connectionGeneration === identity.generation; +} + + +function failPlayerConnection(errorCode) { + resetPlayerChannelConnection({ rotateNonce: false }); + state.playerConnectionState = 'failed'; + state.playerConnectionErrorCode = errorCode; + state.playerRuntimeReady = false; + state.playerPhase = 'error'; + state.playerHostError = t('connectionTimeout'); + reportPlayerRuntimeLog('error', 'Remotion preview connection failed', { + errorCode, + recoveryAttempt: state.playerRecoveryAttempt, + compositionId: currentComposition()?.id || null, + projectRevision: state.manifest?.projectRevision || state.manifest?.sourceRevision || null, + }); + window.dispatchEvent(new CustomEvent('remotion-live:render-request')); } -function handlePortMessage(identity, event) { +function recoverPlayerFrame(errorCode) { + resetPlayerChannelConnection({ rotateNonce: true }); + state.playerRuntimeReady = false; + state.playerRuntimePlaying = false; + if (state.playerRecoveryAttempt >= MAX_FRAME_RECOVERY_ATTEMPTS) { + failPlayerConnection(errorCode); + return; + } + + state.playerRecoveryAttempt += 1; + state.playerConnectionErrorCode = errorCode; + state.playerConnectionState = 'recovering'; + state.playerPhase = 'connecting'; + state.playerHostError = null; + state.playerReloadNonce += 1; + reportPlayerRuntimeLog('warn', 'Reloading Remotion preview frame after connection failure', { + errorCode, + recoveryAttempt: state.playerRecoveryAttempt, + compositionId: currentComposition()?.id || null, + projectRevision: state.manifest?.projectRevision || state.manifest?.sourceRevision || null, + }); + window.dispatchEvent(new CustomEvent('remotion-live:render-request')); +} + + +function startPlayerReadyTimer(identity) { + clearPlayerReadyTimer(); + state.playerReadyTimer = window.setTimeout(() => { + state.playerReadyTimer = null; + if (identity !== channelIdentity || state.playerRuntimeReady) return; + recoverPlayerFrame('PLAYER_MOUNT_TIMEOUT'); + }, PLAYER_READY_TIMEOUT_MS); +} + + +function startHeartbeat(identity) { + clearHeartbeat(); + state.playerLastMessageAt = Date.now(); + heartbeatTimer = window.setInterval(() => { + if (identity !== channelIdentity || !state.playerRuntimeReady || document.visibilityState === 'hidden') return; + const silenceMs = Date.now() - Number(state.playerLastMessageAt || 0); + if (silenceMs > HEARTBEAT_TIMEOUT_MS) { + reportPlayerRuntimeLog('warn', 'Remotion preview heartbeat timed out', { + silenceMs, + compositionId: currentComposition()?.id || null, + }); + resetPlayerChannelConnection({ rotateNonce: false }); + state.playerRuntimeReady = false; + state.playerPhase = 'connecting'; + requestPlayerHandshake(); + window.dispatchEvent(new CustomEvent('remotion-live:render-request')); + return; + } + postPlayerMessage('ping', { sentAt: Date.now() }, { requireReady: false }); + }, HEARTBEAT_INTERVAL_MS); +} + + +function handleVisibilityChange() { + if (document.visibilityState !== 'visible' || !state.playerRuntimeReady) return; + state.playerLastMessageAt = Date.now(); + postPlayerMessage('ping', { sentAt: Date.now() }, { requireReady: false }); +} + + +function handlePlayerWindowMessage(event) { + const node = playerFrameNode(); const message = event.data || {}; - if (identity !== channelIdentity) return; - if (message.source !== 'sparo-remotion-player-host') return; - if (message.protocolVersion !== PLAYER_CONTROL_PROTOCOL_VERSION) return; - if (message.instanceId !== identity.instanceId) return; - if (message.channelNonce !== identity.nonce) return; + if (!node?.contentWindow || message.source !== 'sparo-remotion-player-host') return; + const identity = ensurePlayerChannelIdentity(); + if (!messageMatchesPreview(message, identity)) return; + + if (message.type === 'bootstrapReady') { + // WebView2 may expose different WindowProxy identities for the same nested, + // opaque iframe. The URL-bound nonce and immutable preview identity bind + // the message to this exact navigation without relying on event.source. + activatePlayerHandshake('bootstrap-ready'); + return; + } + + if (!identity.connectionId || message.connectionId !== identity.connectionId) return; + state.playerLastMessageAt = Date.now(); if (message.type === 'channelReady') { state.playerChannelConnected = true; state.playerConnectionState = 'connected'; + state.playerConnectionTransport = 'window-message'; + state.playerConnectionId = identity.connectionId; + state.playerConnectionErrorCode = null; if (state.playerHandshakeTimer) { clearTimeout(state.playerHandshakeTimer); state.playerHandshakeTimer = null; } + startPlayerReadyTimer(identity); + } + + if (message.type === 'ready') { + const recoveryAttempt = state.playerRecoveryAttempt; + clearPlayerReadyTimer(); + state.playerRecoveryAttempt = 0; + state.playerConnectionErrorCode = null; + startHeartbeat(identity); + if (reportedReadyConnectionId !== identity.connectionId) { + reportedReadyConnectionId = identity.connectionId; + reportPlayerRuntimeLog('info', 'Remotion preview transport ready', { + transport: 'window-message', + connectionGeneration: identity.generation, + connectionId: identity.connectionId, + recoveryAttempt, + compositionId: currentComposition()?.id || null, + projectRevision: state.manifest?.projectRevision || state.manifest?.sourceRevision || null, + }); + } } + + if (message.type === 'error') { + clearHeartbeat(); + clearPlayerReadyTimer(); + } + + if (message.type === 'pong') return; playerMessageHandler?.(message, { trusted: true }); } -function openPlayerChannel(identity) { +function openPlayerConnection(identity) { const composition = currentComposition(); const node = playerFrameNode(); if (!composition || !node?.contentWindow || !playerPreviewReady()) return false; - closePlayerPort(); - const channel = new MessageChannel(); - playerPort = channel.port1; - playerPort.onmessage = (event) => handlePortMessage(identity, event); - playerPort.onmessageerror = () => { - if (identity !== channelIdentity) return; - resetPlayerChannelConnection(); - state.playerRuntimeReady = false; - requestPlayerHandshake(); - }; - playerPort.start(); + connectionCounter += 1; + identity.connectionId = `${identity.instanceId}-c${connectionCounter}`; + state.playerConnectionId = identity.connectionId; state.playerConnectionState = 'connecting'; + state.playerConnectionTransport = 'window-message'; - // The nested Player inherits an opaque sandbox origin. A transferred port is - // the capability; the nonce binds it to this exact iframe navigation. node.contentWindow.postMessage({ source: 'sparo-remotion-live', type: 'connect', @@ -218,7 +392,10 @@ function openPlayerChannel(identity) { descriptorRevision: composition.descriptorRevision || state.manifest?.descriptorRevision, instanceId: identity.instanceId, channelNonce: identity.nonce, - }, '*', [channel.port2]); + connectionGeneration: identity.generation, + connectionId: identity.connectionId, + transport: 'window-message', + }, '*'); return true; } @@ -226,9 +403,10 @@ function openPlayerChannel(identity) { function postPlayerMessage(type, payload = {}, options = {}) { const composition = currentComposition(); const identity = ensurePlayerChannelIdentity(); - if (!composition || !playerPort || !state.playerChannelConnected || !playerPreviewReady()) return false; + const node = playerFrameNode(); + if (!composition || !node?.contentWindow || !identity.connectionId || !state.playerChannelConnected || !playerPreviewReady()) return false; if (options.requireReady !== false && !state.playerRuntimeReady) return false; - playerPort.postMessage({ + node.contentWindow.postMessage({ ...payload, source: 'sparo-remotion-live', type, @@ -238,7 +416,9 @@ function postPlayerMessage(type, payload = {}, options = {}) { descriptorRevision: composition.descriptorRevision || state.manifest?.descriptorRevision, instanceId: identity.instanceId, channelNonce: identity.nonce, - }); + connectionGeneration: identity.generation, + connectionId: identity.connectionId, + }, '*'); return true; } @@ -257,22 +437,21 @@ function requestPlayerHandshake(attempt = 0) { if (attempt === 0 && state.playerHandshakeTimer) return; const identity = ensurePlayerChannelIdentity(); - openPlayerChannel(identity); + openPlayerConnection(identity); if (state.playerChannelConnected) return; if (state.playerHandshakeTimer) clearTimeout(state.playerHandshakeTimer); state.playerHandshakeTimer = window.setTimeout(() => { state.playerHandshakeTimer = null; - if (attempt >= 24) { - closePlayerPort(); - state.playerConnectionState = 'failed'; - state.playerRuntimeReady = false; - state.playerPhase = 'error'; - state.playerHostError = t('connectionTimeout'); - window.dispatchEvent(new CustomEvent('remotion-live:render-request')); + if (attempt >= HANDSHAKE_RETRY_DELAYS_MS.length - 1) { + recoverPlayerFrame( + readinessHandshakeSignals.has('bootstrap-ready') + ? 'CONNECTION_TIMEOUT' + : 'FRAME_BOOTSTRAP_TIMEOUT', + ); return; } requestPlayerHandshake(attempt + 1); - }, attempt < 4 ? 750 : 1500); + }, HANDSHAKE_RETRY_DELAYS_MS[Math.min(attempt, HANDSHAKE_RETRY_DELAYS_MS.length - 1)]); } @@ -282,7 +461,8 @@ function syncPlayerRuntimeDom() { } -window.addEventListener('message', handlePlayerBootstrap); +window.addEventListener('message', handlePlayerWindowMessage); +document.addEventListener('visibilitychange', handleVisibilityChange); export { @@ -290,6 +470,7 @@ export { playerFrameNode, playerHostUrl, postPlayerMessage, + reportPlayerRuntimeLog, requestPlayerHandshake, resetPlayerChannelConnection, setPlayerMessageHandler, diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-protocol.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-protocol.js index 70cf86e4..7eadee2f 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-protocol.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-protocol.js @@ -5,15 +5,24 @@ import { applyPlayerFrame, currentPreviewSnapshot, ensurePlayerInstanceId, nextP import { playerFrameNode, postPlayerMessage, + reportPlayerRuntimeLog, requestPlayerHandshake, + resetPlayerChannelConnection, setPlayerMessageHandler, syncPlayerRuntimeDom, } from './player-dom.js'; +import { + PLAYER_BASELINE_REVISION, + isPlayerRuntimeEvidenceMessage, + isStalePlayerStateMessage, + playerMessageRevision, +} from './player-state-contract.js'; import { state } from './state.js'; import { asArray, clamp, rootElement } from './util.js'; import { setPlayingState, syncPhaseDom, syncPlaybackDom, syncPlayingDom, syncSelectionOverlayDom } from './views.js'; const snapshotRequests = new Map(); +const PLAYER_COMMAND_SETTLE_TIMEOUT_MS = 2_500; let playbackDomFrame = null; function schedulePlaybackDomSync() { @@ -35,6 +44,97 @@ function requestRender() { } +function clearPlayerCommandTimer() { + if (!state.playerCommandTimer) return; + clearTimeout(state.playerCommandTimer); + state.playerCommandTimer = null; +} + + +function commandLogDetails(command = {}, details = {}) { + return { + commandId: command.commandId || null, + command: command.command || command.type || null, + revision: Number.isFinite(Number(command.revision)) ? Number(command.revision) : null, + connectionGeneration: state.playerConnectionGeneration, + connectionId: state.playerConnectionId, + ...details, + }; +} + + +function reportPlayerControl(level, message, command, details = {}) { + if (command?.type === 'seek' || command?.command === 'seek') return; + reportPlayerRuntimeLog(level, message, commandLogDetails(command, details)); +} + + +function resetDesiredStateToActual() { + const actual = state.playerActualState || {}; + const revision = Math.max( + Number(state.playerDesiredRevision) || 0, + Number(state.playerActualRevision) || PLAYER_BASELINE_REVISION, + ); + state.playerDesiredRevision = revision; + state.playerDesiredState = { + instanceId: state.playerInstanceId || null, + revision, + frame: normalizePreviewFrame(actual.frame ?? state.playerRuntimeFrame ?? state.frame), + playing: Boolean(actual.playing ?? state.playerRuntimePlaying), + muted: Boolean(actual.muted ?? state.playerRuntimeMuted ?? state.muted), + volume: clamp(Number(actual.volume ?? state.playerRuntimeVolume ?? state.volume) || 0, 0, 1), + }; + syncPlayingDom(); +} + + +function startPlayerCommandTimer(command) { + clearPlayerCommandTimer(); + state.playerCommandTimer = window.setTimeout(() => { + state.playerCommandTimer = null; + void verifyTimedOutPlayerCommand(command); + }, PLAYER_COMMAND_SETTLE_TIMEOUT_MS); +} + + +async function verifyTimedOutPlayerCommand(command) { + if (state.playerInFlightCommand?.commandId !== command.commandId) return; + const snapshot = state.playerChannelConnected + ? await requestPlayerSnapshot(750) + : null; + if (state.playerInFlightCommand?.commandId !== command.commandId) return; + + const timedOut = state.playerInFlightCommand; + if (snapshot && settleInFlightCommand(state.playerActualState || snapshot)) { + syncPlaybackDom(); + return; + } + + state.playerInFlightCommand = null; + state.playerPendingCommand = null; + state.playerNeedsReconcile = false; + if (!snapshot) { + reportPlayerControl('warn', 'Remotion preview command response timed out', timedOut, { + accepted: Boolean(timedOut.accepted), + timeoutMs: PLAYER_COMMAND_SETTLE_TIMEOUT_MS + 750, + }); + resetPlayerChannelConnection(); + state.playerRuntimeReady = false; + setPlayerPhase('connecting'); + requestPlayerHandshake(); + syncPlaybackDom(); + return; + } + + resetDesiredStateToActual(); + reportPlayerControl('warn', 'Remotion preview command did not settle', timedOut, { + accepted: Boolean(timedOut.accepted), + timeoutMs: PLAYER_COMMAND_SETTLE_TIMEOUT_MS, + }); + syncPlaybackDom(); +} + + function syncPlayerStateAttributes() { const root = rootElement(); if (!root) return; @@ -125,6 +225,7 @@ function markCommandInFlight(command) { accepted: false, startedAt: Date.now(), }; + startPlayerCommandTimer(command); if (command.type === 'seek') { state.playerCommittedFrame = null; state.playerSeeking = true; @@ -138,6 +239,7 @@ function postPreparedPlayerCommand(command) { if (posted) { markCommandInFlight(command); state.playerPendingCommand = null; + reportPlayerControl('debug', 'Remotion preview command sent', command); } return posted; } @@ -145,7 +247,16 @@ function postPreparedPlayerCommand(command) { function queuePlayerCommand(command) { // This is a complete desired-state snapshot, not a lossy one-command queue. + if (Number(command.revision) > Number(state.playerInFlightCommand?.revision ?? PLAYER_BASELINE_REVISION)) { + clearPlayerCommandTimer(); + state.playerInFlightCommand = null; + state.playerNeedsReconcile = false; + } state.playerPendingCommand = command; + reportPlayerControl('debug', 'Remotion preview command queued', command, { + channelConnected: Boolean(state.playerChannelConnected), + runtimeReady: Boolean(state.playerRuntimeReady), + }); } @@ -199,6 +310,8 @@ function currentMessageBelongsToPreview(message, options = {}) { if (message.descriptorRevision !== (composition?.descriptorRevision || state.manifest?.descriptorRevision)) return false; if (message.instanceId !== state.playerInstanceId) return false; if (message.channelNonce !== state.playerChannelNonce) return false; + if (message.connectionGeneration !== state.playerConnectionGeneration) return false; + if (message.connectionId !== state.playerConnectionId) return false; return true; } @@ -284,9 +397,17 @@ function commandIsSettled(command, actual) { function settleInFlightCommand(actual) { - if (!commandIsSettled(state.playerInFlightCommand, actual)) return; + if (!commandIsSettled(state.playerInFlightCommand, actual)) return false; + const settled = state.playerInFlightCommand; + clearPlayerCommandTimer(); state.playerInFlightCommand = null; state.playerPendingCommand = null; + state.playerNeedsReconcile = false; + reportPlayerControl('debug', 'Remotion preview command settled', settled, { + actualPlaying: Boolean(actual.playing), + actualFrame: actual.frame, + }); + return true; } @@ -324,24 +445,83 @@ function resolveSnapshotRequest(message) { } +function beginPlayerStateConnection(message) { + if (state.playerStateConnectionId === message.connectionId) return false; + clearPlayerCommandTimer(); + state.playerStateConnectionId = message.connectionId; + state.playerActualRevision = PLAYER_BASELINE_REVISION; + state.playerActualState = null; + state.playerCommittedFrame = null; + state.playerInFlightCommand = null; + state.playerNeedsReconcile = false; + state.playerRuntimeReady = false; + state.playerRuntimePlaying = false; + state.playerRuntimeFrame = null; + state.playerBuffering = false; + state.playerSeeking = false; + reportPlayerRuntimeLog('info', 'Remotion preview state connection opened', { + connectionGeneration: state.playerConnectionGeneration, + connectionId: message.connectionId, + desiredRevision: state.playerDesiredState?.revision ?? null, + }); + return true; +} + + +function markPlayerRuntimeEvidence(message) { + if (!isPlayerRuntimeEvidenceMessage(message)) return; + const wasReady = state.playerRuntimeReady; + state.playerRuntimeReady = true; + state.playerConnectionState = 'connected'; + state.playerHostError = null; + if (!wasReady) syncPlayerRuntimeDom(); +} + + +function rebaseDesiredStateRevision() { + const desired = desiredState(); + const revision = Math.max( + Number(desired.revision) || 0, + Number(state.playerActualRevision) + 1, + ); + state.playerDesiredRevision = revision; + state.playerDesiredState = { ...desired, revision }; +} + + +function reconcileRejectedCommandAfterActualState() { + if (!state.playerNeedsReconcile) return; + state.playerNeedsReconcile = false; + state.playerInFlightCommand = null; + clearPlayerCommandTimer(); + rebaseDesiredStateRevision(); + flushPlayerCommand(); +} + + function handlePlayerHostMessage(message = {}, options = {}) { if (!currentMessageBelongsToPreview(message, options)) return false; - const messageRevision = Number(message.revision); - if ( - message.type !== 'commandAccepted' - && Number.isFinite(messageRevision) - && messageRevision < (Number(state.playerActualRevision) || 0) - ) return true; if (message.type === 'channelReady') { + beginPlayerStateConnection(message); setPlayerPhase('connecting'); flushPlayerCommand(); return true; } + markPlayerRuntimeEvidence(message); + if (isStalePlayerStateMessage(message, state.playerActualRevision)) { + reportPlayerRuntimeLog('debug', 'Ignored stale Remotion preview state', { + messageType: message.type, + messageRevision: playerMessageRevision(message), + actualRevision: state.playerActualRevision, + connectionGeneration: state.playerConnectionGeneration, + connectionId: state.playerConnectionId, + }); + return true; + } + if (message.type === 'ready') { - state.playerRuntimeReady = true; - state.playerHostError = null; const actual = mergeActualState({ ...message, buffering: false, seeking: false }); setPlayingState(Boolean(actual.playing)); setPlayerPhase(actual.playing ? 'playing' : 'paused'); @@ -362,9 +542,32 @@ function handlePlayerHostMessage(message = {}, options = {}) { } if (message.type === 'commandAccepted') { + const accepted = message.accepted !== false; + reportPlayerControl(accepted ? 'debug' : 'warn', accepted + ? 'Remotion preview command accepted' + : 'Remotion preview command rejected', message, { + accepted, + reason: message.reason || null, + }); if (state.playerInFlightCommand?.commandId === message.commandId) { - state.playerInFlightCommand.accepted = true; + state.playerInFlightCommand.accepted = accepted; state.playerInFlightCommand.acceptedAt = Date.now(); + if (!accepted) state.playerNeedsReconcile = true; + } + return true; + } + + if (message.type === 'commandFailed') { + reportPlayerControl('warn', 'Remotion preview command failed', message, { + reason: message.message || null, + }); + if (state.playerInFlightCommand?.commandId === message.commandId) { + clearPlayerCommandTimer(); + state.playerInFlightCommand = null; + state.playerPendingCommand = null; + state.playerNeedsReconcile = false; + resetDesiredStateToActual(); + syncPlaybackDom(); } return true; } @@ -373,6 +576,7 @@ function handlePlayerHostMessage(message = {}, options = {}) { const actual = mergeActualState(message); if (message.type === 'frameCommitted') state.playerCommittedFrame = actual.frame; settleInFlightCommand(actual); + reconcileRejectedCommandAfterActualState(); state.playerPhase = actual.buffering ? 'buffering' : actual.seeking @@ -427,7 +631,9 @@ function handlePlayerHostMessage(message = {}, options = {}) { } state.playerBuffering = false; state.playerSeeking = false; + clearPlayerCommandTimer(); state.playerInFlightCommand = null; + state.playerNeedsReconcile = false; setPlayingState(false); setPlayerPhase('ended'); syncPlaybackDom(); @@ -435,12 +641,14 @@ function handlePlayerHostMessage(message = {}, options = {}) { } if (message.type === 'error') { + clearPlayerCommandTimer(); state.playerHostError = String(message.message || 'Player preview failed.'); state.playerRuntimeReady = false; state.playerRuntimePlaying = false; state.playerBuffering = false; state.playerSeeking = false; state.playerInFlightCommand = null; + state.playerNeedsReconcile = false; if (state.playerDesiredState) state.playerDesiredState = { ...state.playerDesiredState, playing: false }; setPlayingState(false); setPlayerPhase('error'); diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-state-contract.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-state-contract.js new file mode 100644 index 00000000..e4d85d16 --- /dev/null +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/player-state-contract.js @@ -0,0 +1,59 @@ +const PLAYER_BASELINE_REVISION = -1; + +const PLAYER_CONNECTION_LIFECYCLE_TYPES = new Set([ + 'channelReady', + 'ready', + 'error', +]); + +const PLAYER_REVISION_ORDERED_STATE_TYPES = new Set([ + 'snapshot', + 'frameCommitted', + 'actualState', + 'seekSettled', + 'playing', + 'paused', + 'buffering', + 'ended', +]); + +const PLAYER_RUNTIME_EVIDENCE_TYPES = new Set([ + 'ready', + 'commandFailed', + 'snapshot', + 'frameContext', + 'frameCommitted', + 'actualState', + 'seekSettled', + 'playing', + 'paused', + 'buffering', + 'ended', +]); + +function playerMessageRevision(message = {}) { + const revision = Number(message.revision); + return Number.isFinite(revision) ? revision : null; +} + +function isStalePlayerStateMessage(message = {}, currentRevision = PLAYER_BASELINE_REVISION) { + if (!PLAYER_REVISION_ORDERED_STATE_TYPES.has(message.type)) return false; + const revision = playerMessageRevision(message); + return revision !== null && revision < Number(currentRevision); +} + +function isPlayerConnectionLifecycleMessage(message = {}) { + return PLAYER_CONNECTION_LIFECYCLE_TYPES.has(message.type); +} + +function isPlayerRuntimeEvidenceMessage(message = {}) { + return PLAYER_RUNTIME_EVIDENCE_TYPES.has(message.type); +} + +export { + PLAYER_BASELINE_REVISION, + isPlayerConnectionLifecycleMessage, + isPlayerRuntimeEvidenceMessage, + isStalePlayerStateMessage, + playerMessageRevision, +}; diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/preview-controller.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/preview-controller.js index 6688b1a7..2005a46c 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/preview-controller.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/preview-controller.js @@ -2,6 +2,7 @@ import { PLAYER_CONTROL_PROTOCOL_VERSION, PLAYER_HOST_RUNTIME_VERSION } from './constants.js'; import { compositionDuration, currentComposition } from './model.js'; +import { PLAYER_BASELINE_REVISION } from './player-state-contract.js'; import { state } from './state.js'; import { clamp } from './util.js'; @@ -35,12 +36,23 @@ function resetPlayerRuntimeState() { state.playerDesiredState = null; state.playerActualState = null; state.playerDesiredRevision = 0; - state.playerActualRevision = 0; + state.playerActualRevision = PLAYER_BASELINE_REVISION; state.playerChannelConnected = false; state.playerConnectionState = 'disconnected'; + state.playerConnectionTransport = null; + state.playerConnectionId = null; + state.playerStateConnectionId = null; + state.playerConnectionErrorCode = null; + state.playerRecoveryAttempt = 0; + state.playerLastMessageAt = null; state.playerChannelNonce = null; state.playerPendingCommand = null; state.playerInFlightCommand = null; + state.playerNeedsReconcile = false; + if (state.playerCommandTimer) { + clearTimeout(state.playerCommandTimer); + state.playerCommandTimer = null; + } state.playerInstanceId = null; state.playerStageKey = null; state.playerRenderedStageKey = null; @@ -50,6 +62,10 @@ function resetPlayerRuntimeState() { clearTimeout(state.playerHandshakeTimer); state.playerHandshakeTimer = null; } + if (state.playerReadyTimer) { + clearTimeout(state.playerReadyTimer); + state.playerReadyTimer = null; + } } function playerStageKey(composition = currentComposition()) { diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/preview-runtime.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/preview-runtime.js index 416b5a53..3f273ea4 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/preview-runtime.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/preview-runtime.js @@ -59,7 +59,7 @@ function applyPlayerHostOutput(output) { state.playerRuntimeReady = false; state.playerConnectionState = 'disconnected'; state.playerPhase = 'connecting'; - resetPlayerChannelConnection({ rotateNonce: true }); + resetPlayerChannelConnection({ rotateNonce: true, resetRecovery: true }); } if (output?.ready diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/render-core.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/render-core.js index b65c664a..d0c75640 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/render-core.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/render-core.js @@ -1,5 +1,5 @@ import { compositionDuration, currentComposition } from './model.js'; -import { notifyPlayerFrameLoaded, playerFrameNode, requestPlayerHandshake } from './player-dom.js'; +import { notifyPlayerFrameLoaded, playerFrameNode, requestPlayerHandshake, resetPlayerChannelConnection } from './player-dom.js'; import { playerStageKey } from './preview-controller.js'; import { selectionSummary, shouldDeferRenderForSelection } from './selection-geom.js'; import { state } from './state.js'; @@ -47,7 +47,7 @@ function setError(error) { function renderStatusStrip() { const phase = previewPhase(); - const busy = ['detecting', 'bundling', 'connecting', 'loading'].includes(phase); + const busy = ['detecting', 'snapshot', 'bundling', 'connecting', 'loading'].includes(phase); return ``; } @@ -135,7 +135,7 @@ function patchStablePlayerRender(root) { const composition = currentComposition(); if (composition) { replaceElementHtml('.rl-transport', renderPlaybackTransport(composition, compositionDuration(composition), Number(composition.fps || 30))); - replaceElementHtml('.rl-review', renderTimelineInline(composition, compositionDuration(composition), Number(composition.fps || 30))); + replaceElementHtml('.rl-review', renderTimelineInline(composition, compositionDuration(composition))); } patchPlayerOverlayDom(stage); patchInspectDom(stage); @@ -166,7 +166,7 @@ function updateTimelineDom() { const duration = compositionDuration(composition); const fps = Number(composition.fps || 30); replaceElementHtml('.rl-transport', renderPlaybackTransport(composition, duration, fps)); - replaceElementHtml('.rl-review', renderTimelineInline(composition, duration, fps)); + replaceElementHtml('.rl-review', renderTimelineInline(composition, duration)); syncFrameDom(); syncPlayingDom(); } @@ -197,6 +197,8 @@ function renderPreview() { data-seeking="${state.playerSeeking ? 'true' : 'false'}" data-player-host-ready="${state.playerHost?.ready ? 'true' : 'false'}" data-player-connection-state="${escapeHtml(state.playerConnectionState || 'disconnected')}" + data-player-connection-transport="${escapeHtml(state.playerConnectionTransport || '')}" + data-player-connection-error="${escapeHtml(state.playerConnectionErrorCode || '')}" data-player-channel-connected="${state.playerChannelConnected ? 'true' : 'false'}">
@@ -205,15 +207,11 @@ function renderPreview() { ${renderSelectCaptureLayer()} ${renderSelectionOverlay()} ${renderSelectionMarker()} -
${renderContextTray()} ${renderPlaybackTransport(composition, duration, fps)} - ${renderTimelineInline(composition, duration, fps)} + ${renderTimelineInline(composition, duration)} `; } @@ -247,6 +245,8 @@ function render() { root.dataset.seeking = state.playerSeeking ? 'true' : 'false'; root.dataset.playerHostReady = state.playerHost?.ready ? 'true' : 'false'; root.dataset.playerConnectionState = state.playerConnectionState || 'disconnected'; + root.dataset.playerConnectionTransport = state.playerConnectionTransport || ''; + root.dataset.playerConnectionError = state.playerConnectionErrorCode || ''; root.dataset.playerChannelConnected = state.playerChannelConnected ? 'true' : 'false'; document.documentElement.dataset.route = state.route; @@ -263,6 +263,7 @@ function render() { state.playerRenderedStageKey = stageKey; } if (nextFrame && nextFrame !== previousFrame) { + resetPlayerChannelConnection(); state.playerRuntimeReady = false; state.playerRuntimePlaying = false; nextFrame.addEventListener('load', () => notifyPlayerFrameLoaded(nextFrame), { once: true }); diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/state.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/state.js index 0e683e1e..a1c01f1d 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/state.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/state.js @@ -1,5 +1,7 @@ // Remotion Live surface state. The Player is the only preview authority. +import { PLAYER_BASELINE_REVISION } from './player-state-contract.js'; + const state = { locale: navigator.language || 'en-US', route: '/preview', @@ -35,16 +37,26 @@ const state = { playerDesiredState: null, playerActualState: null, playerDesiredRevision: 0, - playerActualRevision: 0, + playerActualRevision: PLAYER_BASELINE_REVISION, playerPendingCommand: null, playerInFlightCommand: null, + playerNeedsReconcile: false, + playerCommandTimer: null, playerInstanceId: null, playerStageKey: null, playerRenderedStageKey: null, playerHandshakeTimer: null, + playerReadyTimer: null, playerReloadNonce: 0, playerChannelConnected: false, playerConnectionState: 'disconnected', + playerConnectionTransport: null, + playerConnectionId: null, + playerConnectionGeneration: 0, + playerStateConnectionId: null, + playerConnectionErrorCode: null, + playerRecoveryAttempt: 0, + playerLastMessageAt: null, playerChannelNonce: null, muted: true, diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/views.js b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/views.js index 65216875..0839f958 100644 --- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/views.js +++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/src/views.js @@ -44,7 +44,7 @@ function previewPhase() { if (state.playerSeeking || state.playerPhase === 'seeking') return 'seeking'; if (state.playerRuntimePlaying || state.playerPhase === 'playing') return 'playing'; if (state.playerPhase === 'ended') return 'ended'; - if (state.playerRuntimeReady || state.playerPhase === 'paused' || state.playerPhase === 'ready') return 'ready'; + if (state.playerRuntimeReady) return 'ready'; if (state.playerHostLoading || state.phase === 'hostStarting' || state.playerPhase === 'connecting') return 'connecting'; return state.manifest ? 'loading' : state.phase || 'idle'; } @@ -70,23 +70,6 @@ function phaseLabel(phase = previewPhase()) { return t(keys[phase] || 'readyPaused'); } -function phaseTone(phase = previewPhase()) { - if (phase === 'error' || phase === 'broken') return 'error'; - if (phase === 'ready') return 'success'; - if (phase === 'buffering' || phase === 'seeking' || phase === 'ambiguous') return 'warning'; - if (phase === 'playing') return 'accent'; - return 'info'; -} - -function renderStatus(phase = previewPhase()) { - return ` - - - ${escapeHtml(phaseLabel(phase))} - - `; -} - function actualAudioState() { return { muted: state.playerRuntimeReady ? Boolean(state.playerRuntimeMuted) : Boolean(state.muted), @@ -94,6 +77,20 @@ function actualAudioState() { }; } +function timelineTickFrames(duration, maxTicks = 7) { + const lastFrame = Math.max(0, Math.round(Number(duration) || 1) - 1); + if (lastFrame === 0) return [0]; + const tickCount = Math.min(maxTicks, lastFrame + 1); + return Array.from( + new Set( + Array.from( + { length: tickCount }, + (_, index) => Math.round(lastFrame * (index / (tickCount - 1))), + ), + ), + ); +} + function syncAudioDom() { const audio = actualAudioState(); const button = document.querySelector('[data-action="toggle-muted"]'); @@ -121,13 +118,15 @@ function syncPhaseDom() { node.dataset.inspectMode = inspectMode; node.dataset.buffering = state.playerBuffering ? 'true' : 'false'; node.dataset.seeking = state.playerSeeking ? 'true' : 'false'; + node.dataset.playerConnectionState = state.playerConnectionState || 'disconnected'; + node.dataset.playerConnectionTransport = state.playerConnectionTransport || ''; + node.dataset.playerConnectionError = state.playerConnectionErrorCode || ''; }); - const current = document.querySelector('[data-preview-status]'); - if (current && current.dataset.phase !== phase) { - const template = document.createElement('template'); - template.innerHTML = renderStatus(phase).trim(); - current.replaceWith(template.content.firstElementChild); + const announcer = document.querySelector('[data-preview-announcer]'); + if (announcer && announcer.dataset.phase !== phase) { + announcer.dataset.phase = phase; + announcer.textContent = phaseLabel(phase); } const sendContext = document.querySelector('[data-action="send-context"]'); @@ -178,6 +177,13 @@ function syncFrameDom(options = {}) { }); document.querySelectorAll('.rl-review-playhead').forEach((node) => { node.style.left = `${percent}%`; + node.classList.toggle('is-start', percent <= 4); + node.classList.toggle('is-end', percent >= 96); + }); + document.querySelectorAll('.rl-review__ticks [data-frame]').forEach((node) => { + const tickFrame = Number(node.dataset.frame); + const distance = duration > 1 ? Math.abs(tickFrame - frame) / (duration - 1) * 100 : 0; + node.classList.toggle('is-obscured', distance < 4.5); }); if (options.syncPhase !== false) syncPhaseDom(); } @@ -275,23 +281,39 @@ function renderHeader() { return `
- ${escapeHtml(state.workspacePath ? workspaceLabel() : t('title'))} - ${composition ? '' : ''} - ${compositions.length > 1 ? ` - - ` : composition ? `${escapeHtml(composition.id)}` : ''} + + + ${compositions.length > 1 ? ` + + ` : composition ? `${escapeHtml(composition.id)}` : ''} + + + ${escapeHtml(state.workspacePath ? workspaceLabel() : t('title'))} + ${composition ? ` + + ` : ''} + +
-
${renderStatus()}
+ ${escapeHtml(phaseLabel())}
- - - - - + + + + + + + + + + +
`; @@ -540,7 +562,7 @@ function renderPlaybackTransport(composition, duration, fps) {