diff --git a/.github/workflows/sheets-web-bundle-audit.yml b/.github/workflows/sheets-web-bundle-audit.yml new file mode 100644 index 000000000..eed443354 --- /dev/null +++ b/.github/workflows/sheets-web-bundle-audit.yml @@ -0,0 +1,44 @@ +name: Sheets Web Bundle Audit + +on: + pull_request: + branches: [main, agent/ppt-web-foundation] + workflow_dispatch: + +permissions: + contents: read + +jobs: + bundle-report: + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build Sheets Web + run: npm run build:web:sheets + + - name: Measure production bundle + run: | + node apps/sheets/scripts/report-web-bundle.mjs \ + apps/sheets/dist-web \ + apps/sheets/reports/web-bundle.json + cat apps/sheets/reports/web-bundle.json + + - name: Upload bundle report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sheets-web-bundle-report + path: apps/sheets/reports/web-bundle.json + retention-days: 14 diff --git a/.github/workflows/sheets-web-e2e.yml b/.github/workflows/sheets-web-e2e.yml new file mode 100644 index 000000000..55a59315e --- /dev/null +++ b/.github/workflows/sheets-web-e2e.yml @@ -0,0 +1,254 @@ +name: Sheets Web E2E + +on: + pull_request: + branches: [main, agent/ppt-web-foundation] + workflow_dispatch: + +permissions: + contents: read + +jobs: + sheets-web-foundation: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Typecheck Sheets + run: npm run typecheck -w @genoffice/sheets + + - name: Web client unit tests + run: npx vitest run tests/web-engine-client.test.ts + working-directory: apps/sheets + + - name: Build XLSX Engine Service + run: npm run build:xlsx-engine + + - name: Start XLSX Engine Service + run: | + ./services/xlsx-engine-service/target/release/xlsx-engine-service > /tmp/xlsx-engine.log 2>&1 & + echo $! > /tmp/xlsx-engine.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:7301/health >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/xlsx-engine.log + exit 1 + + - name: Verify session lifecycle + run: | + SESSION_JSON=$(curl --fail --silent \ + -H 'content-type: application/json' \ + -d '{"source":"blank"}' \ + http://127.0.0.1:7301/v1/sessions) + echo "$SESSION_JSON" + SESSION_ID=$(node -e "const v=JSON.parse(process.argv[1]); if(!v.sessionId) process.exit(1); process.stdout.write(v.sessionId)" "$SESSION_JSON") + curl --fail --silent -X DELETE "http://127.0.0.1:7301/v1/sessions/$SESSION_ID" >/dev/null + + - name: Verify real blank XLSX workbook + run: | + BLANK=$(curl --fail --silent -X POST \ + 'http://127.0.0.1:7301/v1/workbooks/blank?name=Untitled.xlsx') + echo "$BLANK" >/tmp/blank-meta.json + SESSION_ID=$(node -e "const v=require('/tmp/blank-meta.json'); if(!v.sessionId||!v.sheets?.length) process.exit(1); process.stdout.write(v.sessionId)") + curl --fail --silent -X DELETE "http://127.0.0.1:7301/v1/sessions/$SESSION_ID" >/dev/null + + - name: Verify real XLSX open and lazy range + run: | + python3 - <<'PY' + from zipfile import ZipFile, ZIP_DEFLATED + + parts = { + '[Content_Types].xml': ''' + + + + + + + ''', + '_rels/.rels': ''' + + + ''', + 'xl/workbook.xml': ''' + + + ''', + 'xl/_rels/workbook.xml.rels': ''' + + + + ''', + 'xl/styles.xml': ''' + + + + + + + ''', + 'xl/worksheets/sheet1.xml': ''' + + + Web Excel E2E42 + ''', + } + + with ZipFile('/tmp/web-excel-e2e.xlsx', 'w', ZIP_DEFLATED) as archive: + for name, content in parts.items(): + archive.writestr(name, content) + PY + + META=$(curl --fail --silent \ + --data-binary @/tmp/web-excel-e2e.xlsx \ + 'http://127.0.0.1:7301/v1/workbooks?name=web-excel-e2e.xlsx') + echo "$META" > /tmp/workbook-meta.json + + SESSION_ID=$(node -e "const v=require('/tmp/workbook-meta.json'); if(!v.sessionId||!v.sheets?.[0]?.id) process.exit(1); process.stdout.write(v.sessionId)") + SHEET_ID=$(node -e "const v=require('/tmp/workbook-meta.json'); process.stdout.write(v.sheets[0].id)") + + RANGE=$(curl --fail --silent \ + -H 'content-type: application/json' \ + -H "X-Xlsx-Session: $SESSION_ID" \ + -d "{\"sheetId\":\"$SHEET_ID\",\"range\":{\"startRow\":0,\"endRow\":0,\"startColumn\":0,\"endColumn\":1}}" \ + "http://127.0.0.1:7301/v1/sessions/$SESSION_ID/ranges") + + node -e "const v=JSON.parse(process.argv[1]); const a=v.cells?.find(c=>c.row===0&&c.column===0); const b=v.cells?.find(c=>c.row===0&&c.column===1); if(a?.value!=='Web Excel E2E'||b?.value!==42) { console.error(v); process.exit(1) }" "$RANGE" + echo "$SESSION_ID" >/tmp/source-session-id + echo "$SHEET_ID" >/tmp/source-sheet-id + + - name: Verify XLSX archive save round-trip + run: | + SESSION_ID=$(cat /tmp/source-session-id) + READ=$(curl --fail --silent \ + -H 'content-type: application/json' \ + -H "X-Xlsx-Session: $SESSION_ID" \ + -d '{"entries":["xl/worksheets/sheet1.xml"]}' \ + "http://127.0.0.1:7301/v1/sessions/$SESSION_ID/archive/read") + node - <<'NODE' "$READ" + const input = JSON.parse(process.argv[2]) + const entry = input.entries.find((item) => item.name === 'xl/worksheets/sheet1.xml') + if (!entry) process.exit(1) + const xml = Buffer.from(entry.contentBase64, 'base64').toString('utf8') + const saved = xml.replace('Web Excel E2E', 'Web Excel Saved') + const payload = { + name: 'saved.xlsx', + replacements: [{ + name: 'xl/worksheets/sheet1.xml', + contentBase64: Buffer.from(saved).toString('base64'), + }], + removals: [], + additions: [], + } + require('fs').writeFileSync('/tmp/save-payload.json', JSON.stringify(payload)) + NODE + + curl --fail --silent \ + -D /tmp/save-headers.txt \ + -o /tmp/saved.xlsx \ + -H 'content-type: application/json' \ + -H "X-Xlsx-Session: $SESSION_ID" \ + --data-binary @/tmp/save-payload.json \ + "http://127.0.0.1:7301/v1/sessions/$SESSION_ID/archive/save" + + SAVED_SESSION=$(awk 'BEGIN{IGNORECASE=1} /^x-xlsx-session:/ {gsub("\r", "", $2); print $2}' /tmp/save-headers.txt) + test -n "$SAVED_SESSION" + SAVED_META=$(curl --fail --silent \ + -H "X-Xlsx-Session: $SAVED_SESSION" \ + "http://127.0.0.1:7301/v1/sessions/$SAVED_SESSION") + SAVED_SHEET=$(node -e "const v=JSON.parse(process.argv[1]); if(!v.sheets?.[0]?.id) process.exit(1); process.stdout.write(v.sheets[0].id)" "$SAVED_META") + SAVED_RANGE=$(curl --fail --silent \ + -H 'content-type: application/json' \ + -H "X-Xlsx-Session: $SAVED_SESSION" \ + -d "{\"sheetId\":\"$SAVED_SHEET\",\"range\":{\"startRow\":0,\"endRow\":0,\"startColumn\":0,\"endColumn\":1}}" \ + "http://127.0.0.1:7301/v1/sessions/$SAVED_SESSION/ranges") + node -e "const v=JSON.parse(process.argv[1]); const a=v.cells?.find(c=>c.row===0&&c.column===0); if(a?.value!=='Web Excel Saved') { console.error(v); process.exit(1) }" "$SAVED_RANGE" + curl --fail --silent -X DELETE "http://127.0.0.1:7301/v1/sessions/$SAVED_SESSION" >/dev/null + curl --fail --silent -X DELETE "http://127.0.0.1:7301/v1/sessions/$SESSION_ID" >/dev/null + + - name: Generate compatibility fixtures + run: npm run fixtures -w @genoffice/sheets + + - name: Verify Rust-backed compatibility corpus + env: + XLSX_ENGINE_URL: http://127.0.0.1:7301 + run: npm run compat:web-engine -w @genoffice/sheets + + - name: Build Sheets Web + run: npm run build:web:sheets + + - name: Build XLSX Host Demo + run: npm run build:web-xlsx-host + + - name: Start Sheets Web preview + run: | + npx vite preview --config vite.web.config.ts --host 127.0.0.1 --port 5275 > /tmp/sheets-web.log 2>&1 & + echo $! > /tmp/sheets-web.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:5275/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/sheets-web.log + exit 1 + working-directory: apps/sheets + + - name: Start XLSX Host Demo preview + run: | + npx vite preview --config examples/web-xlsx-host/vite.config.ts --host 127.0.0.1 --port 8082 > /tmp/web-xlsx-host.log 2>&1 & + echo $! > /tmp/web-xlsx-host.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8082/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/web-xlsx-host.log + exit 1 + + - name: Browser E2E (Sheets Web) + env: + SHEETS_WEB_E2E_URL: http://127.0.0.1:5275/ + SHEETS_WEB_HOST_E2E_URL: http://127.0.0.1:8082/ + run: | + set -o pipefail + npx playwright test e2e/sheets-web.spec.ts e2e/sheets-web-defined-names.spec.ts e2e/sheets-web-chart.spec.ts e2e/sheets-web-image.spec.ts e2e/sheets-web-table-sparkline.spec.ts e2e/sheets-web-pivot-read.spec.ts e2e/sheets-web-pivot-refresh.spec.ts --config e2e/playwright.config.ts 2>&1 | tee /tmp/sheets-web-e2e.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sheets-web-foundation-artifacts + path: | + apps/sheets/reports/web-engine-compatibility.json + e2e/test-results + e2e/playwright-report + test-results + playwright-report + /tmp/xlsx-engine.log + /tmp/sheets-web.log + /tmp/web-xlsx-host.log + /tmp/sheets-web-e2e.log + retention-days: 7 diff --git a/.github/workflows/sheets-web-engine-limits.yml b/.github/workflows/sheets-web-engine-limits.yml new file mode 100644 index 000000000..430c21c31 --- /dev/null +++ b/.github/workflows/sheets-web-engine-limits.yml @@ -0,0 +1,221 @@ +name: Sheets Web Engine Limits + +on: + pull_request: + branches: [main, agent/ppt-web-foundation] + workflow_dispatch: + +permissions: + contents: read + +jobs: + engine-limits: + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build XLSX Engine Service + run: cargo build --release --manifest-path services/xlsx-engine-service/Cargo.toml + + - name: Verify heavy request admission timeout + run: | + cargo test --release \ + --manifest-path services/xlsx-engine-service/Cargo.toml \ + heavy_request_admission_times_out_before_work_starts + + - name: Reject invalid limit configuration + run: | + set +e + XLSX_ENGINE_LISTEN=127.0.0.1:7398 \ + XLSX_ENGINE_MAX_WORKBOOK_MB=0 \ + XLSX_ENGINE_MAX_REQUEST_MB=8 \ + ./services/xlsx-engine-service/target/release/xlsx-engine-service \ + > /tmp/xlsx-engine-invalid-limit.log 2>&1 + STATUS=$? + set -e + test "$STATUS" -ne 0 + grep -q 'XLSX_ENGINE_MAX_WORKBOOK_MB must be greater than zero' /tmp/xlsx-engine-invalid-limit.log + + - name: Reject invalid admission configuration + run: | + set +e + XLSX_ENGINE_LISTEN=127.0.0.1:7394 \ + XLSX_ENGINE_MAX_HEAVY_REQUESTS=0 \ + ./services/xlsx-engine-service/target/release/xlsx-engine-service \ + > /tmp/xlsx-engine-invalid-admission.log 2>&1 + STATUS=$? + set -e + test "$STATUS" -ne 0 + grep -q 'XLSX_ENGINE_MAX_HEAVY_REQUESTS must be greater than zero' /tmp/xlsx-engine-invalid-admission.log + + set +e + XLSX_ENGINE_LISTEN=127.0.0.1:7394 \ + XLSX_ENGINE_HEAVY_QUEUE_TIMEOUT_SECS=0 \ + ./services/xlsx-engine-service/target/release/xlsx-engine-service \ + >> /tmp/xlsx-engine-invalid-admission.log 2>&1 + STATUS=$? + set -e + test "$STATUS" -ne 0 + grep -q 'XLSX_ENGINE_HEAVY_QUEUE_TIMEOUT_SECS must be greater than zero' /tmp/xlsx-engine-invalid-admission.log + + - name: Start XLSX Engine Service with a 1MiB workbook limit + env: + XLSX_ENGINE_LISTEN: 127.0.0.1:7399 + XLSX_ENGINE_MAX_WORKBOOK_MB: 1 + XLSX_ENGINE_MAX_REQUEST_MB: 8 + XLSX_ENGINE_MAX_HEAVY_REQUESTS: 2 + XLSX_ENGINE_HEAVY_QUEUE_TIMEOUT_SECS: 1 + run: | + ./services/xlsx-engine-service/target/release/xlsx-engine-service > /tmp/xlsx-engine-limit.log 2>&1 & + echo $! > /tmp/xlsx-engine-limit.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:7399/health >/dev/null; then + break + fi + sleep 1 + done + HEALTH=$(curl --fail --silent http://127.0.0.1:7399/health) + node -e "const v=JSON.parse(process.argv[1]); if(v.maxHeavyRequests!==2 || v.heavyQueueTimeoutSecs!==1 || v.availableHeavySlots!==2) process.exit(1)" "$HEALTH" + + - name: Verify request ids and structured logs + run: | + curl --fail --silent \ + --dump-header /tmp/health-headers.txt \ + --header 'X-Request-Id: ci-health-123' \ + http://127.0.0.1:7399/health \ + > /tmp/health-response.json + grep -qi '^x-request-id: ci-health-123' /tmp/health-headers.txt + grep -q '"requestId":"ci-health-123"' /tmp/xlsx-engine-limit.log + grep -q '"event":"http_request"' /tmp/xlsx-engine-limit.log + grep -q '"path":"/health"' /tmp/xlsx-engine-limit.log + + - name: Verify operational metrics + run: | + curl --fail --silent \ + --header 'X-Request-Id: ci-metrics-123' \ + http://127.0.0.1:7399/metrics \ + > /tmp/xlsx-engine-metrics.txt + grep -q '^genoffice_xlsx_requests_total ' /tmp/xlsx-engine-metrics.txt + grep -q '^genoffice_xlsx_server_errors_total ' /tmp/xlsx-engine-metrics.txt + grep -q '^genoffice_xlsx_heavy_admission_rejects_total ' /tmp/xlsx-engine-metrics.txt + grep -q '^genoffice_xlsx_heavy_slots 2$' /tmp/xlsx-engine-metrics.txt + grep -q '^genoffice_xlsx_heavy_slots_available 2$' /tmp/xlsx-engine-metrics.txt + grep -q '^genoffice_xlsx_workbook_sessions ' /tmp/xlsx-engine-metrics.txt + grep -q '^genoffice_xlsx_lightweight_sessions ' /tmp/xlsx-engine-metrics.txt + grep -q '"requestId":"ci-metrics-123"' /tmp/xlsx-engine-limit.log + + - name: Reject oversized workbook with HTTP 413 + run: | + python3 - <<'PY' + with open('/tmp/oversized.xlsx', 'wb') as handle: + handle.write(b'X' * (1024 * 1024 + 1)) + PY + STATUS=$(curl --silent \ + --output /tmp/oversized-response.txt \ + --write-out '%{http_code}' \ + --data-binary @/tmp/oversized.xlsx \ + 'http://127.0.0.1:7399/v1/workbooks?name=oversized.xlsx') + test "$STATUS" = '413' + grep -q 'configured 1MB upload limit' /tmp/oversized-response.txt + + - name: Keep normal requests below the limit reachable + run: | + STATUS=$(curl --silent \ + --output /tmp/small-response.txt \ + --write-out '%{http_code}' \ + --data-binary 'not-a-real-xlsx' \ + 'http://127.0.0.1:7399/v1/workbooks?name=small.xlsx') + test "$STATUS" = '422' + + - name: Prepare stale endpoint workspace + run: | + rm -rf /tmp/genoffice-xlsx-engine-ci + mkdir -p /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396/workbooks/orphan + mkdir -p /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396/scratch/orphan + mkdir -p /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7395/workbooks/live-sibling + printf 'stale' > /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396/workbooks/orphan/stale.xlsx + printf 'stale' > /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396/scratch/orphan/stale.bin + printf 'keep' > /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7395/workbooks/live-sibling/keep.txt + + - name: Clean only the bound endpoint workspace + env: + XLSX_ENGINE_LISTEN: 127.0.0.1:7396 + XLSX_ENGINE_WORK_ROOT: /tmp/genoffice-xlsx-engine-ci + XLSX_ENGINE_MAX_WORKBOOK_MB: 1 + XLSX_ENGINE_MAX_REQUEST_MB: 8 + run: | + ./services/xlsx-engine-service/target/release/xlsx-engine-service > /tmp/xlsx-engine-workspace.log 2>&1 & + PID=$! + echo "$PID" > /tmp/xlsx-engine-workspace.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:7396/health >/dev/null; then + break + fi + sleep 1 + done + curl --fail --silent http://127.0.0.1:7396/health >/dev/null + test ! -e /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396/workbooks/orphan/stale.xlsx + test ! -e /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396/scratch/orphan/stale.bin + test -d /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396/workbooks + test -d /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396/scratch + test -f /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7395/workbooks/live-sibling/keep.txt + kill -INT "$PID" + wait "$PID" + test ! -e /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7396 + test -f /tmp/genoffice-xlsx-engine-ci/127_0_0_1_7395/workbooks/live-sibling/keep.txt + + - name: Start short-TTL XLSX Engine Service + env: + XLSX_ENGINE_LISTEN: 127.0.0.1:7397 + XLSX_ENGINE_MAX_WORKBOOK_MB: 1 + XLSX_ENGINE_MAX_REQUEST_MB: 8 + XLSX_ENGINE_SESSION_TTL_SECS: 1 + XLSX_ENGINE_CLEANUP_INTERVAL_SECS: 1 + run: | + ./services/xlsx-engine-service/target/release/xlsx-engine-service > /tmp/xlsx-engine-ttl.log 2>&1 & + echo $! > /tmp/xlsx-engine-ttl.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:7397/health >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/xlsx-engine-ttl.log + exit 1 + + - name: Expire abandoned workbook sessions + run: | + BLANK=$(curl --fail --silent -X POST \ + 'http://127.0.0.1:7397/v1/workbooks/blank?name=ttl.xlsx') + SESSION_ID=$(node -e "const v=JSON.parse(process.argv[1]); if(!v.sessionId) process.exit(1); process.stdout.write(v.sessionId)" "$BLANK") + sleep 3 + STATUS=$(curl --silent \ + --output /tmp/expired-session-response.txt \ + --write-out '%{http_code}' \ + "http://127.0.0.1:7397/v1/sessions/$SESSION_ID") + test "$STATUS" = '404' + grep -q 'Unknown workbook session' /tmp/expired-session-response.txt + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sheets-web-engine-limit-logs + path: | + /tmp/xlsx-engine-invalid-limit.log + /tmp/xlsx-engine-invalid-admission.log + /tmp/xlsx-engine-limit.log + /tmp/xlsx-engine-workspace.log + /tmp/xlsx-engine-ttl.log + /tmp/health-headers.txt + /tmp/health-response.json + /tmp/xlsx-engine-metrics.txt + /tmp/oversized-response.txt + /tmp/small-response.txt + /tmp/expired-session-response.txt + retention-days: 7 diff --git a/.github/workflows/sheets-web-file-ui.yml b/.github/workflows/sheets-web-file-ui.yml new file mode 100644 index 000000000..184ba227d --- /dev/null +++ b/.github/workflows/sheets-web-file-ui.yml @@ -0,0 +1,112 @@ +name: Sheets Web File UI + +on: + pull_request: + branches: [main, agent/ppt-web-foundation] + workflow_dispatch: + +permissions: + contents: read + +jobs: + sheets-web-file-ui: + runs-on: ubuntu-22.04 + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Typecheck Sheets + run: npm run typecheck -w @genoffice/sheets + + - name: Generate Sheets fixtures + run: npm run fixtures -w @genoffice/sheets + + - name: Build XLSX Engine Service + run: npm run build:xlsx-engine + + - name: Start XLSX Engine Service + run: | + ./services/xlsx-engine-service/target/release/xlsx-engine-service > /tmp/xlsx-engine.log 2>&1 & + echo $! > /tmp/xlsx-engine.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:7301/health >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/xlsx-engine.log + exit 1 + + - name: Build Sheets Web + run: npm run build:web:sheets + + - name: Build XLSX Host Demo + run: npm run build:web-xlsx-host + + - name: Start Sheets Web preview + run: | + npx vite preview --config vite.web.config.ts --host 127.0.0.1 --port 5275 > /tmp/sheets-web.log 2>&1 & + echo $! > /tmp/sheets-web.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:5275/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/sheets-web.log + exit 1 + working-directory: apps/sheets + + - name: Start XLSX Host Demo preview + run: | + npx vite preview --config examples/web-xlsx-host/vite.config.ts --host 127.0.0.1 --port 8082 > /tmp/web-xlsx-host.log 2>&1 & + echo $! > /tmp/web-xlsx-host.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8082/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/web-xlsx-host.log + exit 1 + + - name: Browser E2E (File UI + Host Close) + env: + SHEETS_WEB_E2E_URL: http://127.0.0.1:5275/ + SHEETS_WEB_HOST_E2E_URL: http://127.0.0.1:8082/ + run: | + set -o pipefail + npx playwright test e2e/sheets-web-file-menu.spec.ts --config e2e/playwright.config.ts 2>&1 | tee /tmp/sheets-web-file-ui.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sheets-web-file-ui-artifacts + path: | + e2e/test-results + e2e/playwright-report + test-results + playwright-report + /tmp/xlsx-engine.log + /tmp/sheets-web.log + /tmp/web-xlsx-host.log + /tmp/sheets-web-file-ui.log + retention-days: 7 diff --git a/.github/workflows/sheets-web-pivot-add-e2e.yml b/.github/workflows/sheets-web-pivot-add-e2e.yml new file mode 100644 index 000000000..6e954083d --- /dev/null +++ b/.github/workflows/sheets-web-pivot-add-e2e.yml @@ -0,0 +1,104 @@ +name: Sheets Web Pivot Add E2E + +on: + pull_request: + branches: [main, agent/ppt-web-foundation] + workflow_dispatch: + +permissions: + contents: read + +jobs: + pivot-add-browser: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Typecheck Sheets + run: npm run typecheck -w @genoffice/sheets + + - name: Build XLSX Engine Service + run: npm run build:xlsx-engine + + - name: Start XLSX Engine Service + run: | + ./services/xlsx-engine-service/target/release/xlsx-engine-service > /tmp/xlsx-engine.log 2>&1 & + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:7301/health >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/xlsx-engine.log + exit 1 + + - name: Build Sheets Web + run: npm run build:web:sheets + + - name: Build XLSX Host Demo + run: npm run build:web-xlsx-host + + - name: Start Sheets Web preview + run: | + npx vite preview --config vite.web.config.ts --host 127.0.0.1 --port 5275 > /tmp/sheets-web.log 2>&1 & + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:5275/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/sheets-web.log + exit 1 + working-directory: apps/sheets + + - name: Start XLSX Host Demo preview + run: | + npx vite preview --config examples/web-xlsx-host/vite.config.ts --host 127.0.0.1 --port 8082 > /tmp/web-xlsx-host.log 2>&1 & + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8082/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/web-xlsx-host.log + exit 1 + + - name: Browser E2E (Pivot Add) + env: + SHEETS_WEB_E2E_URL: http://127.0.0.1:5275/ + SHEETS_WEB_HOST_E2E_URL: http://127.0.0.1:8082/ + run: | + set -o pipefail + npx playwright test e2e/sheets-web-pivot-add.spec.ts --config e2e/playwright.config.ts 2>&1 | tee /tmp/sheets-web-pivot-add-e2e.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sheets-web-pivot-add-artifacts + path: | + e2e/test-results + e2e/playwright-report + /tmp/xlsx-engine.log + /tmp/sheets-web.log + /tmp/web-xlsx-host.log + /tmp/sheets-web-pivot-add-e2e.log + retention-days: 7 diff --git a/.github/workflows/sheets-web-production-audit.yml b/.github/workflows/sheets-web-production-audit.yml new file mode 100644 index 000000000..1949f3097 --- /dev/null +++ b/.github/workflows/sheets-web-production-audit.yml @@ -0,0 +1,81 @@ +name: Sheets Web Production Audit + +on: + pull_request: + branches: [main, agent/ppt-web-foundation] + workflow_dispatch: + +permissions: + contents: read + +jobs: + npm-production-audit: + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Audit Sheets production dependencies + id: audit + shell: bash + run: | + mkdir -p reports + set +e + npm audit \ + --omit=dev \ + --workspace @genoffice/sheets \ + --json \ + > reports/sheets-production-audit.json + STATUS=$? + set -e + echo "npm_audit_status=$STATUS" >> "$GITHUB_OUTPUT" + + node <<'NODE' + const fs = require('fs') + const input = JSON.parse(fs.readFileSync('reports/sheets-production-audit.json', 'utf8')) + const vulnerabilities = Object.values(input.vulnerabilities || {}) + .map((item) => ({ + name: item.name, + severity: item.severity, + isDirect: item.isDirect === true, + range: item.range, + fixAvailable: + item.fixAvailable === true || + (item.fixAvailable && typeof item.fixAvailable === 'object' + ? { + name: item.fixAvailable.name, + version: item.fixAvailable.version, + isSemVerMajor: item.fixAvailable.isSemVerMajor === true, + } + : false), + })) + .sort((a, b) => a.severity.localeCompare(b.severity) || a.name.localeCompare(b.name)) + + const summary = { + generatedFrom: 'npm audit --omit=dev --workspace @genoffice/sheets', + counts: input.metadata?.vulnerabilities || {}, + vulnerabilities, + } + fs.writeFileSync( + 'reports/sheets-production-audit-summary.json', + `${JSON.stringify(summary, null, 2)}\n`, + ) + console.log(JSON.stringify(summary, null, 2)) + NODE + + - name: Upload audit report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sheets-production-audit + path: | + reports/sheets-production-audit.json + reports/sheets-production-audit-summary.json + retention-days: 14 diff --git a/.github/workflows/uc-webos-xlsx-host.yml b/.github/workflows/uc-webos-xlsx-host.yml new file mode 100644 index 000000000..b462eded7 --- /dev/null +++ b/.github/workflows/uc-webos-xlsx-host.yml @@ -0,0 +1,113 @@ +name: UC Web OS XLSX Host + +on: + pull_request: + branches: [main, agent/ppt-web-foundation] + workflow_dispatch: + +permissions: + contents: read + +jobs: + host-build: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Typecheck UC Web OS XLSX Host + run: npx tsc -p examples/uc-webos-xlsx-host/tsconfig.json --noEmit + + - name: Build XLSX Engine Service + run: npm run build:xlsx-engine + + - name: Build Sheets Web + run: npm run build:web:sheets + + - name: Build UC Web OS XLSX Host + run: npm run build:uc-webos-xlsx-host + + - name: Start XLSX Engine Service + run: | + ./services/xlsx-engine-service/target/release/xlsx-engine-service > /tmp/uc-xlsx-engine.log 2>&1 & + echo $! > /tmp/uc-xlsx-engine.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:7301/health >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/uc-xlsx-engine.log + exit 1 + + - name: Start Sheets Web preview + run: | + npx vite preview --config vite.web.config.ts --host 127.0.0.1 --port 5275 > /tmp/uc-sheets-web.log 2>&1 & + echo $! > /tmp/uc-sheets-web.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:5275/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/uc-sheets-web.log + exit 1 + working-directory: apps/sheets + + - name: Start UC Web OS XLSX Host preview + run: | + npx vite preview --config examples/uc-webos-xlsx-host/vite.config.ts --host 127.0.0.1 --port 8083 > /tmp/uc-xlsx-host.log 2>&1 & + echo $! > /tmp/uc-xlsx-host.pid + for i in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8083/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/uc-xlsx-host.log + exit 1 + + - name: Browser E2E (UC Web OS XLSX Host) + env: + SHEETS_WEB_E2E_URL: http://127.0.0.1:5275/ + UC_WEBOS_XLSX_HOST_E2E_URL: http://127.0.0.1:8083/ + run: | + set -o pipefail + npx playwright test \ + e2e/uc-webos-xlsx-host.spec.ts \ + e2e/uc-webos-xlsx-host-shortcuts.spec.ts \ + --config e2e/playwright.config.ts \ + 2>&1 | tee /tmp/uc-webos-xlsx-host-e2e.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: uc-webos-xlsx-host-artifacts + path: | + e2e/test-results + e2e/playwright-report + test-results + playwright-report + /tmp/uc-xlsx-engine.log + /tmp/uc-sheets-web.log + /tmp/uc-xlsx-host.log + /tmp/uc-webos-xlsx-host-e2e.log + retention-days: 7 diff --git a/apps/sheets/package.json b/apps/sheets/package.json index 707634bd8..50598a5b7 100644 --- a/apps/sheets/package.json +++ b/apps/sheets/package.json @@ -9,7 +9,9 @@ "scripts": { "dev": "electron-vite dev", "dev:renderer": "vite --config vite.renderer.config.ts", + "dev:web": "vite --config vite.web.config.ts", "build": "npm run native:build && electron-vite build", + "build:web": "vite build --config vite.web.config.ts", "native:build": "cargo build --release --manifest-path native/xlsx-engine/Cargo.toml --config native/xlsx-engine/.cargo/config.toml", "native:test": "cargo test --manifest-path native/xlsx-engine/Cargo.toml --config native/xlsx-engine/.cargo/config.toml", "typecheck": "tsc --noEmit", @@ -17,6 +19,7 @@ "test:watch": "vitest", "fixtures": "tsx scripts/generate-fixtures.ts", "compat": "tsx scripts/verify-compatibility.ts", + "compat:web-engine": "tsx scripts/verify-web-engine-compatibility.ts", "benchmark": "tsx scripts/benchmark-xlsx.ts", "benchmark:large": "npm run native:build && tsx scripts/benchmark-large-xlsx.ts", "gate": "npm run typecheck && npm run fixtures && npm test && npm run compat" @@ -54,7 +57,6 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.0.4", "electron": "^43.3.0", "electron-vite": "^5.0.0", "tsx": "^4.21.0", diff --git a/apps/sheets/scripts/report-web-bundle.mjs b/apps/sheets/scripts/report-web-bundle.mjs new file mode 100644 index 000000000..23a4ac786 --- /dev/null +++ b/apps/sheets/scripts/report-web-bundle.mjs @@ -0,0 +1,89 @@ +import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { extname, relative, resolve } from 'node:path' +import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib' + +const root = resolve(process.argv[2] || 'dist-web') +const reportPath = resolve(process.argv[3] || 'reports/web-bundle.json') +const include = new Set(['.js', '.css']) + +async function filesUnder(directory) { + const result = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name) + if (entry.isDirectory()) result.push(...(await filesUnder(path))) + else if (entry.isFile() && include.has(extname(entry.name))) result.push(path) + } + return result +} + +function kib(value) { + return Math.round((value / 1024) * 10) / 10 +} + +const files = [] +for (const path of await filesUnder(root)) { + const bytes = await readFile(path) + const rawBytes = (await stat(path)).size + const gzipBytes = gzipSync(bytes, { level: 9 }).byteLength + const brotliBytes = brotliCompressSync(bytes, { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, + }).byteLength + files.push({ + path: relative(root, path).replaceAll('\\', '/'), + type: extname(path).slice(1), + rawBytes, + gzipBytes, + brotliBytes, + }) +} + +files.sort((left, right) => right.brotliBytes - left.brotliBytes) +const totals = files.reduce( + (sum, item) => ({ + rawBytes: sum.rawBytes + item.rawBytes, + gzipBytes: sum.gzipBytes + item.gzipBytes, + brotliBytes: sum.brotliBytes + item.brotliBytes, + }), + { rawBytes: 0, gzipBytes: 0, brotliBytes: 0 }, +) +const largestJavaScript = files.find((item) => item.type === 'js') ?? null +const largestCss = files.find((item) => item.type === 'css') ?? null +const summary = { + bundleRoot: relative(process.cwd(), root).replaceAll('\\', '/'), + assetCount: files.length, + totals, + totalsKiB: { + raw: kib(totals.rawBytes), + gzip: kib(totals.gzipBytes), + brotli: kib(totals.brotliBytes), + }, + largestJavaScript, + largestCss, + topAssets: files.slice(0, 15), + assets: files, +} + +await mkdir(resolve(reportPath, '..'), { recursive: true }) +await writeFile(reportPath, `${JSON.stringify(summary, null, 2)}\n`) + +console.log( + `Sheets Web bundle: ${summary.assetCount} JS/CSS assets, ` + + `${summary.totalsKiB.raw} KiB raw, ${summary.totalsKiB.gzip} KiB gzip, ` + + `${summary.totalsKiB.brotli} KiB brotli`, +) +if (largestJavaScript) { + console.log( + `Largest JS: ${largestJavaScript.path} ` + + `(${kib(largestJavaScript.rawBytes)} KiB raw / ` + + `${kib(largestJavaScript.gzipBytes)} KiB gzip / ` + + `${kib(largestJavaScript.brotliBytes)} KiB brotli)`, + ) +} +if (largestCss) { + console.log( + `Largest CSS: ${largestCss.path} ` + + `(${kib(largestCss.rawBytes)} KiB raw / ` + + `${kib(largestCss.gzipBytes)} KiB gzip / ` + + `${kib(largestCss.brotliBytes)} KiB brotli)`, + ) +} diff --git a/apps/sheets/scripts/verify-web-engine-compatibility.ts b/apps/sheets/scripts/verify-web-engine-compatibility.ts new file mode 100644 index 000000000..98803e369 --- /dev/null +++ b/apps/sheets/scripts/verify-web-engine-compatibility.ts @@ -0,0 +1,368 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { + inventoryXlsx, + planCellEditsToXlsx, + type CellEdit, + type EntrySource, + type MutationPlan, +} from '../src/gateway/xlsx-gateway' +import type { CellState } from '../src/domain/workbook.types' + +const ENGINE_URL = (process.env.XLSX_ENGINE_URL || 'http://127.0.0.1:7301').replace(/\/$/, '') +const MAX_PATCH_ENTRY_BYTES = 256 * 1024 * 1024 + +interface CorpusCase { + fixture: string + sheetName: string + row: number + column: number + after: CellState +} + +const CORPUS: readonly CorpusCase[] = [ + { + fixture: 'compatibility-basic.xlsx', + sheetName: 'Sheet1', + row: 0, + column: 0, + after: { value: 'Verified by Rust path' }, + }, + { + fixture: 'compatibility-edit.xlsx', + sheetName: 'Data', + row: 0, + column: 2, + after: { value: 6 }, + }, + { + fixture: 'compatibility-structure.xlsx', + sheetName: 'Data', + row: 0, + column: 0, + after: { value: 99 }, + }, + { + fixture: 'compatibility-sheets.xlsx', + sheetName: 'Data', + row: 0, + column: 0, + after: { value: 99 }, + }, + { + fixture: 'compatibility-kitchen-sink.xlsx', + sheetName: 'Data', + row: 0, + column: 0, + after: { value: 99 }, + }, +] + +interface ManifestEntry { + name: string + compressedSize: number + uncompressedSize: number +} + +interface OpenedWorkbook { + sessionId: string +} + +interface FixtureReport { + fixture: string + passed: boolean + error?: string + touchedEntries: string[] + changedEntries: string[] + removedEntries: string[] + addedEntries: string[] + unexpectedChanges: string[] + preservedEntryCount: number +} + +async function checkedResponse(path: string, init?: RequestInit): Promise { + const response = await fetch(`${ENGINE_URL}${path}`, init) + if (!response.ok) { + throw new Error( + `Engine ${init?.method || 'GET'} ${path} failed (${response.status}): ${await response.text()}`, + ) + } + return response +} + +function sessionHeaders(sessionId: string): HeadersInit { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Xlsx-Session': sessionId, + } +} + +function bufferToArrayBuffer(bytes: Buffer): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength) + copy.set(bytes) + return copy.buffer +} + +async function openWorkbook(name: string, bytes: Buffer): Promise { + const response = await checkedResponse(`/v1/workbooks?name=${encodeURIComponent(name)}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + body: bufferToArrayBuffer(bytes), + }) + return (await response.json()) as OpenedWorkbook +} + +async function manifest(sessionId: string): Promise { + const response = await checkedResponse( + `/v1/sessions/${encodeURIComponent(sessionId)}/archive/manifest`, + { + headers: { Accept: 'application/json', 'X-Xlsx-Session': sessionId }, + }, + ) + const body = (await response.json()) as { entries: ManifestEntry[] } + return body.entries +} + +async function readEntries( + sessionId: string, + entries: readonly string[], +): Promise> { + const response = await checkedResponse( + `/v1/sessions/${encodeURIComponent(sessionId)}/archive/read`, + { + method: 'POST', + headers: sessionHeaders(sessionId), + body: JSON.stringify({ entries }), + }, + ) + const body = (await response.json()) as { + entries: Array<{ name: string; contentBase64: string }> + } + return new Map( + body.entries.map((entry) => [ + entry.name, + Uint8Array.from(Buffer.from(entry.contentBase64, 'base64')), + ]), + ) +} + +async function scanEntries( + sessionId: string, + entries: readonly string[], + needle: string, +): Promise { + const response = await checkedResponse( + `/v1/sessions/${encodeURIComponent(sessionId)}/archive/scan`, + { + method: 'POST', + headers: sessionHeaders(sessionId), + body: JSON.stringify({ entries, needle }), + }, + ) + return ((await response.json()) as { matches: string[] }).matches +} + +function base64Content(items: ReadonlyMap) { + return [...items].map(([name, content]) => ({ + name, + contentBase64: + typeof content === 'string' + ? Buffer.from(content, 'utf8').toString('base64') + : Buffer.from(content).toString('base64'), + })) +} + +function mergeAdditions(plan: MutationPlan): Map { + const result = new Map() + for (const [name, content] of plan.added) result.set(name, content) + for (const [name, content] of plan.addedBinary) result.set(name, content) + return result +} + +async function saveMutation( + sessionId: string, + name: string, + plan: MutationPlan, +): Promise<{ bytes: Buffer; sessionId: string }> { + const response = await checkedResponse( + `/v1/sessions/${encodeURIComponent(sessionId)}/archive/save`, + { + method: 'POST', + headers: sessionHeaders(sessionId), + body: JSON.stringify({ + name, + replacements: base64Content(plan.replaced), + removals: plan.removedEntries, + additions: base64Content(mergeAdditions(plan)), + }), + }, + ) + const savedSessionId = response.headers.get('x-xlsx-session') + if (!savedSessionId) throw new Error('Engine save response did not include x-xlsx-session.') + return { bytes: Buffer.from(await response.arrayBuffer()), sessionId: savedSessionId } +} + +async function deleteSession(sessionId: string): Promise { + await fetch(`${ENGINE_URL}/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + headers: { 'X-Xlsx-Session': sessionId }, + }) +} + +function createEngineEntrySource( + sessionId: string, + entries: readonly ManifestEntry[], +): EntrySource { + const byName = new Map(entries.map((entry) => [entry.name, entry])) + const textCache = new Map() + const decoder = new TextDecoder() + + return { + paths: async () => entries.map((entry) => entry.name), + has: async (path) => byName.has(path), + canPatch: async (path) => + (byName.get(path)?.uncompressedSize ?? 0) <= MAX_PATCH_ENTRY_BYTES, + containsText: async (path, needle) => + (await scanEntries(sessionId, [path], needle)).includes(path), + readText: async (path) => { + const cached = textCache.get(path) + if (cached !== undefined) return cached + const entry = byName.get(path) + if (!entry) throw new Error(`Workbook is missing ${path}.`) + if (entry.uncompressedSize > MAX_PATCH_ENTRY_BYTES) { + throw new Error(`${path} is too large to patch in the compatibility runner.`) + } + const content = (await readEntries(sessionId, [path])).get(path) + if (!content) throw new Error(`Engine did not return ${path}.`) + const text = decoder.decode(content) + textCache.set(path, text) + return text + }, + } +} + +async function planSingleCellEdit(source: EntrySource, entry: CorpusCase): Promise { + const edit: CellEdit = { + sheetName: entry.sheetName, + row: entry.row, + column: entry.column, + writeValue: true, + cell: entry.after, + } + + return planCellEditsToXlsx( + source, + [edit], + [], + [], + undefined, + [], + [], + [], + [], + [], + null, + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ) +} + +async function verifyCase(entry: CorpusCase): Promise { + const sourceBytes = await readFile(resolve('fixtures/generated', entry.fixture)) + let sourceSessionId: string | null = null + let savedSessionId: string | null = null + + try { + const opened = await openWorkbook(entry.fixture, sourceBytes) + sourceSessionId = opened.sessionId + const entries = await manifest(sourceSessionId) + const source = createEngineEntrySource(sourceSessionId, entries) + const plan = await planSingleCellEdit(source, entry) + const saved = await saveMutation(sourceSessionId, entry.fixture, plan) + savedSessionId = saved.sessionId + + const beforeEntries = await inventoryXlsx(sourceBytes) + const afterEntries = await inventoryXlsx(saved.bytes) + const beforeByPath = new Map(beforeEntries.map((item) => [item.path, item.sha256])) + const afterPaths = new Set(afterEntries.map((item) => item.path)) + const changedEntries = afterEntries + .filter((item) => beforeByPath.get(item.path) !== item.sha256) + .map((item) => item.path) + const removedEntries = beforeEntries + .filter((item) => !afterPaths.has(item.path)) + .map((item) => item.path) + const beforePaths = new Set(beforeEntries.map((item) => item.path)) + const addedEntries = afterEntries + .filter((item) => !beforePaths.has(item.path)) + .map((item) => item.path) + + const allowed = new Set([...plan.touchedEntries, ...plan.removedEntries, ...plan.addedEntries]) + const unexpectedChanges = [...changedEntries, ...removedEntries, ...addedEntries].filter( + (path) => !allowed.has(path), + ) + + return { + fixture: entry.fixture, + passed: unexpectedChanges.length === 0 && changedEntries.length > 0, + touchedEntries: [...plan.touchedEntries], + changedEntries, + removedEntries, + addedEntries, + unexpectedChanges, + preservedEntryCount: afterEntries.filter( + (item) => beforeByPath.get(item.path) === item.sha256, + ).length, + } + } catch (error) { + return { + fixture: entry.fixture, + passed: false, + error: error instanceof Error ? error.message : String(error), + touchedEntries: [], + changedEntries: [], + removedEntries: [], + addedEntries: [], + unexpectedChanges: [], + preservedEntryCount: 0, + } + } finally { + if (savedSessionId) await deleteSession(savedSessionId) + if (sourceSessionId) await deleteSession(sourceSessionId) + } +} + +async function main(): Promise { + await checkedResponse('/health') + + const fixtures: FixtureReport[] = [] + for (const entry of CORPUS) fixtures.push(await verifyCase(entry)) + const report = { + engineUrl: ENGINE_URL, + passed: fixtures.every((fixture) => fixture.passed), + fixtureCount: fixtures.length, + fixtures, + } + + await mkdir(resolve('reports'), { recursive: true }) + await writeFile( + resolve('reports/web-engine-compatibility.json'), + `${JSON.stringify(report, null, 2)}\n`, + ) + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) + if (!report.passed) process.exitCode = 1 +} + +void main() diff --git a/apps/sheets/src/gateway/xlsx-notes.ts b/apps/sheets/src/gateway/xlsx-notes.ts index f50c42d90..795be628f 100644 --- a/apps/sheets/src/gateway/xlsx-notes.ts +++ b/apps/sheets/src/gateway/xlsx-notes.ts @@ -26,6 +26,7 @@ const COMMENTS_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments' const VML_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing' +const REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' const COMMENTS_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml' const CONTENT_TYPES_PATH = '[Content_Types].xml' @@ -178,16 +179,25 @@ const EMPTY_RELS = const AFTER_LEGACY_DRAWING = /]*>/.exec(worksheetXml) + if (!open) throw new NoteEditError('Worksheet has no opening element.') + if (/\bxmlns:r="[^"]+"/.test(open[0])) return worksheetXml + const replacement = open[0].replace(/>$/, ` xmlns:r="${REL_NS}">`) + return worksheetXml.slice(0, open.index) + replacement + worksheetXml.slice(open.index + open[0].length) +} + function ensureLegacyDrawingElement(worksheetXml: string, rid: string): string { - if (/` - const anchor = AFTER_LEGACY_DRAWING.exec(worksheetXml) + const anchor = AFTER_LEGACY_DRAWING.exec(xml) if (anchor) { - return worksheetXml.slice(0, anchor.index) + element + worksheetXml.slice(anchor.index) + return xml.slice(0, anchor.index) + element + xml.slice(anchor.index) } - const end = worksheetXml.lastIndexOf('') + const end = xml.lastIndexOf('') if (end === -1) throw new NoteEditError('Worksheet has no closing element.') - return worksheetXml.slice(0, end) + element + worksheetXml.slice(end) + return xml.slice(0, end) + element + xml.slice(end) } /// Replaces the worksheet's whole comment set (empty list removes it). diff --git a/apps/sheets/src/renderer/i18n/strings-file.ts b/apps/sheets/src/renderer/i18n/strings-file.ts new file mode 100644 index 000000000..c891d3f29 --- /dev/null +++ b/apps/sheets/src/renderer/i18n/strings-file.ts @@ -0,0 +1,232 @@ +const en = { + appReadyInitial: 'Ready', + appFileTab: 'File', + appFileOpen: 'Open', + appFileSave: 'Save', + appFileSaveAs: 'Save As', + appFileSaveHistory: 'Save as History Version', + appFileExportXlsx: 'Export as XLSX', + appFileExit: 'Exit', + appFileUnsavedTitle: 'Unsaved changes', + appFileUnsavedMessage: 'Save your changes before closing this workbook?', + appFileSaveAndExit: 'Save and Exit', + appFileDiscardAndExit: 'Discard and Exit', + appFileCancel: 'Cancel', + appSavingHistoryVersion: 'Saving history version…', + appHistoryVersionSaved: 'History version saved', + appExportingXlsx: 'Exporting XLSX…', + appXlsxExported: 'XLSX exported', + appFileActionUnavailable: 'This file action is not available in the current host.', +} + +type FileStrings = typeof en +type FileLanguage = + | 'zh' + | 'en' + | 'ja' + | 'ko' + | 'fr' + | 'de' + | 'es' + | 'th' + | 'id' + | 'ru' + | 'ar' + | 'pt' + | 'it' + | 'pl' + | 'nl' + | 'ms' + | 'he' + | 'hi' + | 'zh-TW' + +export const fileStrings = { + zh: { + ...en, + appReadyInitial: '就绪', + appFileTab: '文件', + appFileOpen: '打开', + appFileSave: '保存', + appFileSaveAs: '另存为', + appFileSaveHistory: '保存历史版本', + appFileExportXlsx: '导出为 XLSX', + appFileExit: '退出', + appFileUnsavedTitle: '有未保存的更改', + appFileUnsavedMessage: '关闭此工作簿前是否保存更改?', + appFileSaveAndExit: '保存并退出', + appFileDiscardAndExit: '放弃更改并退出', + appFileCancel: '取消', + appSavingHistoryVersion: '正在保存历史版本…', + appHistoryVersionSaved: '历史版本已保存', + appExportingXlsx: '正在导出 XLSX…', + appXlsxExported: 'XLSX 已导出', + appFileActionUnavailable: '当前 Host 不支持此文件操作。', + }, + en, + ja: { + ...en, + appReadyInitial: '準備完了', + appFileTab: 'ファイル', + appFileOpen: '開く', + appFileSave: '保存', + appFileSaveAs: '名前を付けて保存', + appFileExit: '終了', + }, + ko: { + ...en, + appReadyInitial: '준비됨', + appFileTab: '파일', + appFileOpen: '열기', + appFileSave: '저장', + appFileSaveAs: '다른 이름으로 저장', + appFileExit: '종료', + }, + fr: { + ...en, + appReadyInitial: 'Prêt', + appFileTab: 'Fichier', + appFileOpen: 'Ouvrir', + appFileSave: 'Enregistrer', + appFileSaveAs: 'Enregistrer sous', + appFileExit: 'Quitter', + }, + de: { + ...en, + appReadyInitial: 'Bereit', + appFileTab: 'Datei', + appFileOpen: 'Öffnen', + appFileSave: 'Speichern', + appFileSaveAs: 'Speichern unter', + appFileExit: 'Beenden', + }, + es: { + ...en, + appReadyInitial: 'Listo', + appFileTab: 'Archivo', + appFileOpen: 'Abrir', + appFileSave: 'Guardar', + appFileSaveAs: 'Guardar como', + appFileExit: 'Salir', + }, + th: { + ...en, + appReadyInitial: 'พร้อม', + appFileTab: 'ไฟล์', + appFileOpen: 'เปิด', + appFileSave: 'บันทึก', + appFileSaveAs: 'บันทึกเป็น', + appFileExit: 'ออก', + }, + id: { + ...en, + appReadyInitial: 'Siap', + appFileTab: 'File', + appFileOpen: 'Buka', + appFileSave: 'Simpan', + appFileSaveAs: 'Simpan Sebagai', + appFileExit: 'Keluar', + }, + ru: { + ...en, + appReadyInitial: 'Готово', + appFileTab: 'Файл', + appFileOpen: 'Открыть', + appFileSave: 'Сохранить', + appFileSaveAs: 'Сохранить как', + appFileExit: 'Выход', + }, + ar: { + ...en, + appReadyInitial: 'جاهز', + appFileTab: 'ملف', + appFileOpen: 'فتح', + appFileSave: 'حفظ', + appFileSaveAs: 'حفظ باسم', + appFileExit: 'خروج', + }, + pt: { + ...en, + appReadyInitial: 'Pronto', + appFileTab: 'Arquivo', + appFileOpen: 'Abrir', + appFileSave: 'Salvar', + appFileSaveAs: 'Salvar como', + appFileExit: 'Sair', + }, + it: { + ...en, + appReadyInitial: 'Pronto', + appFileTab: 'File', + appFileOpen: 'Apri', + appFileSave: 'Salva', + appFileSaveAs: 'Salva con nome', + appFileExit: 'Esci', + }, + pl: { + ...en, + appReadyInitial: 'Gotowe', + appFileTab: 'Plik', + appFileOpen: 'Otwórz', + appFileSave: 'Zapisz', + appFileSaveAs: 'Zapisz jako', + appFileExit: 'Zakończ', + }, + nl: { + ...en, + appReadyInitial: 'Gereed', + appFileTab: 'Bestand', + appFileOpen: 'Openen', + appFileSave: 'Opslaan', + appFileSaveAs: 'Opslaan als', + appFileExit: 'Afsluiten', + }, + ms: { + ...en, + appReadyInitial: 'Sedia', + appFileTab: 'Fail', + appFileOpen: 'Buka', + appFileSave: 'Simpan', + appFileSaveAs: 'Simpan Sebagai', + appFileExit: 'Keluar', + }, + he: { + ...en, + appReadyInitial: 'מוכן', + appFileTab: 'קובץ', + appFileOpen: 'פתח', + appFileSave: 'שמור', + appFileSaveAs: 'שמור בשם', + appFileExit: 'יציאה', + }, + hi: { + ...en, + appReadyInitial: 'तैयार', + appFileTab: 'फ़ाइल', + appFileOpen: 'खोलें', + appFileSave: 'सहेजें', + appFileSaveAs: 'इस रूप में सहेजें', + appFileExit: 'बाहर निकलें', + }, + 'zh-TW': { + ...en, + appReadyInitial: '就緒', + appFileTab: '檔案', + appFileOpen: '開啟', + appFileSave: '儲存', + appFileSaveAs: '另存新檔', + appFileSaveHistory: '儲存歷史版本', + appFileExportXlsx: '匯出為 XLSX', + appFileExit: '結束', + appFileUnsavedTitle: '有未儲存的變更', + appFileUnsavedMessage: '關閉此活頁簿前是否儲存變更?', + appFileSaveAndExit: '儲存並結束', + appFileDiscardAndExit: '放棄變更並結束', + appFileCancel: '取消', + appSavingHistoryVersion: '正在儲存歷史版本…', + appHistoryVersionSaved: '歷史版本已儲存', + appExportingXlsx: '正在匯出 XLSX…', + appXlsxExported: 'XLSX 已匯出', + appFileActionUnavailable: '目前 Host 不支援此檔案操作。', + }, +} satisfies Record diff --git a/apps/sheets/src/renderer/i18n/strings.ts b/apps/sheets/src/renderer/i18n/strings.ts index 933dc8cbc..901dcc6df 100644 --- a/apps/sheets/src/renderer/i18n/strings.ts +++ b/apps/sheets/src/renderer/i18n/strings.ts @@ -1,25 +1,31 @@ import { aiStrings } from './strings-ai' import { appStrings } from './strings-app' import { dialogStrings } from './strings-dialogs' +import { fileStrings } from './strings-file' export const strings = { - zh: { ...appStrings.zh, ...dialogStrings.zh, ...aiStrings.zh }, - en: { ...appStrings.en, ...dialogStrings.en, ...aiStrings.en }, - ja: { ...appStrings.ja, ...dialogStrings.ja, ...aiStrings.ja }, - ko: { ...appStrings.ko, ...dialogStrings.ko, ...aiStrings.ko }, - fr: { ...appStrings.fr, ...dialogStrings.fr, ...aiStrings.fr }, - de: { ...appStrings.de, ...dialogStrings.de, ...aiStrings.de }, - es: { ...appStrings.es, ...dialogStrings.es, ...aiStrings.es }, - th: { ...appStrings.th, ...dialogStrings.th, ...aiStrings.th }, - id: { ...appStrings.id, ...dialogStrings.id, ...aiStrings.id }, - ru: { ...appStrings.ru, ...dialogStrings.ru, ...aiStrings.ru }, - ar: { ...appStrings.ar, ...dialogStrings.ar, ...aiStrings.ar }, - pt: { ...appStrings.pt, ...dialogStrings.pt, ...aiStrings.pt }, - it: { ...appStrings.it, ...dialogStrings.it, ...aiStrings.it }, - pl: { ...appStrings.pl, ...dialogStrings.pl, ...aiStrings.pl }, - nl: { ...appStrings.nl, ...dialogStrings.nl, ...aiStrings.nl }, - ms: { ...appStrings.ms, ...dialogStrings.ms, ...aiStrings.ms }, - he: { ...appStrings.he, ...dialogStrings.he, ...aiStrings.he }, - hi: { ...appStrings.hi, ...dialogStrings.hi, ...aiStrings.hi }, - 'zh-TW': { ...appStrings['zh-TW'], ...dialogStrings['zh-TW'], ...aiStrings['zh-TW'] }, + zh: { ...appStrings.zh, ...dialogStrings.zh, ...aiStrings.zh, ...fileStrings.zh }, + en: { ...appStrings.en, ...dialogStrings.en, ...aiStrings.en, ...fileStrings.en }, + ja: { ...appStrings.ja, ...dialogStrings.ja, ...aiStrings.ja, ...fileStrings.ja }, + ko: { ...appStrings.ko, ...dialogStrings.ko, ...aiStrings.ko, ...fileStrings.ko }, + fr: { ...appStrings.fr, ...dialogStrings.fr, ...aiStrings.fr, ...fileStrings.fr }, + de: { ...appStrings.de, ...dialogStrings.de, ...aiStrings.de, ...fileStrings.de }, + es: { ...appStrings.es, ...dialogStrings.es, ...aiStrings.es, ...fileStrings.es }, + th: { ...appStrings.th, ...dialogStrings.th, ...aiStrings.th, ...fileStrings.th }, + id: { ...appStrings.id, ...dialogStrings.id, ...aiStrings.id, ...fileStrings.id }, + ru: { ...appStrings.ru, ...dialogStrings.ru, ...aiStrings.ru, ...fileStrings.ru }, + ar: { ...appStrings.ar, ...dialogStrings.ar, ...aiStrings.ar, ...fileStrings.ar }, + pt: { ...appStrings.pt, ...dialogStrings.pt, ...aiStrings.pt, ...fileStrings.pt }, + it: { ...appStrings.it, ...dialogStrings.it, ...aiStrings.it, ...fileStrings.it }, + pl: { ...appStrings.pl, ...dialogStrings.pl, ...aiStrings.pl, ...fileStrings.pl }, + nl: { ...appStrings.nl, ...dialogStrings.nl, ...aiStrings.nl, ...fileStrings.nl }, + ms: { ...appStrings.ms, ...dialogStrings.ms, ...aiStrings.ms, ...fileStrings.ms }, + he: { ...appStrings.he, ...dialogStrings.he, ...aiStrings.he, ...fileStrings.he }, + hi: { ...appStrings.hi, ...dialogStrings.hi, ...aiStrings.hi, ...fileStrings.hi }, + 'zh-TW': { + ...appStrings['zh-TW'], + ...dialogStrings['zh-TW'], + ...aiStrings['zh-TW'], + ...fileStrings['zh-TW'], + }, } diff --git a/apps/sheets/src/renderer/save-actions.ts b/apps/sheets/src/renderer/save-actions.ts index 02fb9ddd9..dddce7c9e 100644 --- a/apps/sheets/src/renderer/save-actions.ts +++ b/apps/sheets/src/renderer/save-actions.ts @@ -6,6 +6,7 @@ * per call so refs and state never go stale. */ import type { WorkbookFile, WorkbookFilterState } from '../shared/desktop-api' +import { getSheetsWebSnapshotHost } from '../web/file-actions' import { isSheetRemoved, toSaveChartEdits, @@ -39,14 +40,25 @@ export interface SaveContext { openLazyWorkbook: (opened: WorkbookFile) => void } +export type WorkbookFileActionMode = + | 'save' + | 'save-as' + | 'save-history' + | 'export-xlsx' + | 'recovery' + /** * mode 'recovery': assemble the very same payload but hand it to the * crash-recovery writer instead of the save pipeline — no dialogs, no status * messages, no session swap, the opened file untouched. + * + * 'save-history' and 'export-xlsx' also keep the opened workbook untouched: + * Sheets Web materializes a preservation snapshot in a temporary Engine + * session, then delegates persistence/export to the Office Host protocol. */ export async function handleSave( ctx: SaveContext, - mode: 'save' | 'save-as' | 'recovery', + mode: WorkbookFileActionMode, quiet = false, ): Promise { const state = ctx.lazyWorkbookRef.current @@ -61,6 +73,7 @@ export async function handleSave( const visualAdditions = toSaveVisualAdds(state.editJournal) const tableAdditions = toSaveTableAdds(state.editJournal) const pivotAdditions = toSavePivotAdds(state.editJournal) + const sparklineAdditions = toSaveSparklineAdds(state.editJournal) const sheetOps = toSaveSheetOps(state.editJournal) const hyperlinkEdits = toSaveHyperlinkEdits(state.editJournal) let filterStates: WorkbookFilterState[] @@ -80,8 +93,7 @@ export async function handleSave( const pageSetupStates = toSavePageSetupStates(state.editJournal) const noteStates = collectNoteStates(ctx.univerRef.current, state) const pivotCacheRefreshPaths = [...state.editJournal.pivotCacheRefresh] - // Output-area expansion after layout growth (location ref write-back); the - // count folds into cacheRefresh. + // Output-area expansion after layout growth (location ref write-back). const pivotRefreshUpdates = [...state.editJournal.pivotRefreshUpdates].map( ([cachePath, update]) => ({ cachePath, @@ -105,12 +117,10 @@ export async function handleSave( }), ) // The gateway fails closed when these additions ride with structural or - // sheet changes (their coordinates entangle). Instead of bouncing the - // user, hold them back and save in two sequential phases: structure - // first, then the additions against the reopened session. Pre-existing - // sheet ids are stable across saves (`sheet-`), so the - // held ops stay addressable — ops on sheets created this session are - // the exception and keep the explicit error. + // sheet changes (their coordinates entangle). Ordinary Save runs these as + // two renderer phases; non-destructive History/Export hands the complete + // request to the Web snapshot host, which performs the same split in a + // temporary Engine session before talking to the platform Host. const hasShifts = structuralOps.length > 0 || sheetOps.length > 0 const heldPivots = hasShifts ? pivotAdditions : [] const heldTables = structuralOps.length > 0 ? tableAdditions : [] @@ -142,13 +152,17 @@ export async function handleSave( pageSetupStates.length + noteStates.length + pivotCacheRefreshPaths.length + + pivotRefreshUpdates.length + sheetProtections.length + + sparklineAdditions.length + (definedNamesState === null ? 0 : 1) + visualAdditions.length + visualEdits.length + tableAdditions.length + pivotAdditions.length - if (total === 0) { + // Save As / History / Export are file operations: an unchanged workbook + // still needs to reach the Host so it can create a result/version/export. + if (total === 0 && mode !== 'save-as' && mode !== 'save-history' && mode !== 'export-xlsx') { if (mode !== 'recovery') ctx.setMessage(t('appNoEditsToSave')) return } @@ -170,7 +184,7 @@ export async function handleSave( } const payload = { sessionId: state.file.sessionId, - mode: mode === 'recovery' ? ('save' as const) : mode, + mode: mode === 'save-as' ? ('save-as' as const) : ('save' as const), edits, structuralOps, chartEdits, @@ -189,7 +203,7 @@ export async function handleSave( pivotCacheRefreshPaths, pivotRefreshUpdates, sheetProtections, - sparklineAdditions: toSaveSparklineAdds(state.editJournal), + sparklineAdditions, formulaValues, definedNamesState, } @@ -198,6 +212,40 @@ export async function handleSave( await window.desktopApi.writeWorkbookRecovery(payload).catch(() => ({ ok: false })) return } + + if (mode === 'save-history' || mode === 'export-xlsx') { + const snapshotHost = getSheetsWebSnapshotHost() + if (!snapshotHost) { + const unavailable = t('appFileActionUnavailable') + ctx.setMessage(unavailable) + if (!quiet) showToast(unavailable, 'error') + return + } + try { + ctx.setMessage( + mode === 'save-history' ? t('appSavingHistoryVersion') : t('appExportingXlsx'), + ) + const result = + mode === 'save-history' + ? await snapshotHost.saveHistoryVersion(payload) + : await snapshotHost.exportXlsx(payload) + if (result.canceled) { + ctx.setMessage(t('appSaveCanceled')) + return + } + const message = + mode === 'save-history' ? t('appHistoryVersionSaved') : t('appXlsxExported') + ctx.setMessage(message) + if (!quiet) showToast(message) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : '' + const failed = localizeSaveError(message) ?? (message || t('appSaveFailed')) + ctx.setMessage(failed) + if (!quiet) showToast(failed, 'error') + } + return + } + try { ctx.setMessage(t('appSavingEdits', { count: total })) const result = await window.desktopApi.saveWorkbookEdits({ @@ -221,7 +269,7 @@ export async function handleSave( pivotCacheRefreshPaths, pivotRefreshUpdates, sheetProtections, - sparklineAdditions: toSaveSparklineAdds(state.editJournal), + sparklineAdditions, formulaValues, definedNamesState: splitSave ? null : definedNamesState, }) @@ -307,6 +355,7 @@ const SAVE_ERROR_PATTERNS = [ ['A new pivot cannot be saved together with row/column', 'appSaveErrPivotWithRowCol'], ['A new table cannot be saved together with row/column', 'appSaveErrTableWithRowCol'], ['Defined-name edits cannot be saved together', 'appSaveErrNamesWithStructural'], + ['VERSION_CONFLICT', 'appSaveErrChangedOnDisk'], ['The workbook changed on disk', 'appSaveErrChangedOnDisk'], ['style edits cannot be saved', 'appSaveErrStylesheetLimited'], ['Saving would change the workbook package structure', 'appSaveErrPackageGuard'], diff --git a/apps/sheets/src/web/bootstrap.ts b/apps/sheets/src/web/bootstrap.ts new file mode 100644 index 000000000..4c71d6eb3 --- /dev/null +++ b/apps/sheets/src/web/bootstrap.ts @@ -0,0 +1,95 @@ +import { + StandaloneOfficeHost, + createEmbeddedOfficeRuntime, + detectWebRuntimeMode, +} from '@genoffice/web-runtime' +import { createSheetsWebCloseLifecycle } from './close-lifecycle' +import { installSheetsWebDesktopAdapters } from './desktop-adapters' +import { createSheetsWebDesktopController } from './desktop-api' +import { getXlsxEngineHealth } from './engine-client' +import { installSheetsWebSnapshotHost } from './file-actions' +import { installSheetsWebHostPolicy } from './host-policy' +import './product-policy.css' + +function renderBootstrapError(error: unknown): void { + const root = document.getElementById('root') + if (!root) return + const message = error instanceof Error ? error.message : String(error) + root.innerHTML = `

GenOffice Sheets Web failed to start

${message}
` +} + +function resolveHostOrigin(): string | null { + const queryOrigin = new URL(window.location.href).searchParams.get('hostOrigin') + if (queryOrigin) return queryOrigin + const configured = import.meta.env.VITE_OFFICE_HOST_ORIGIN + return typeof configured === 'string' && configured ? configured : null +} + +async function bootstrapWeb(): Promise { + // Keep the Web product policy consistent with Docs/Slides: the platform owns + // persistence and AI is not part of the embedded office surface unless the + // Host explicitly enables it. + localStorage.setItem('ai-sheets-auto-save', '0') + + const health = await getXlsxEngineHealth() + if (!health.ok) throw new Error('XLSX Engine Service reported an unhealthy state.') + + const mode = detectWebRuntimeMode() + const embeddedRuntime = + mode === 'embedded' + ? createEmbeddedOfficeRuntime({ + hostOrigin: + resolveHostOrigin() ?? + (() => { + throw new Error( + 'Embedded Sheets requires ?hostOrigin=https://host.example.com or VITE_OFFICE_HOST_ORIGIN.', + ) + })(), + }) + : null + const standaloneHost = mode === 'standalone' ? new StandaloneOfficeHost() : null + const closeLifecycle = embeddedRuntime + ? createSheetsWebCloseLifecycle(embeddedRuntime.host, embeddedRuntime.bridge) + : null + const host = closeLifecycle?.host ?? standaloneHost + if (!host) throw new Error('Unable to initialize the Sheets web host runtime.') + + const hostPolicy = installSheetsWebHostPolicy(mode, embeddedRuntime?.bridge) + const controller = createSheetsWebDesktopController(host, embeddedRuntime?.bridge) + installSheetsWebDesktopAdapters(controller.desktopApi, host) + const uninstallSnapshotHost = installSheetsWebSnapshotHost(controller.snapshotHost) + + // Electron exposes this property as readonly through preload typings. Web + // installs the same contract before importing the shared renderer. + Object.defineProperty(window, 'desktopApi', { + configurable: true, + value: controller.desktopApi, + }) + document.documentElement.dataset.xlsxSessionStore = health.sessionStore + + let uninstallFileMenu = (): void => undefined + const cleanup = (): void => { + uninstallFileMenu() + uninstallSnapshotHost() + controller.destroy() + hostPolicy.destroy() + closeLifecycle?.destroy() + embeddedRuntime?.destroy() + standaloneHost?.destroy() + } + window.addEventListener('pagehide', cleanup, { once: true }) + + await import('../renderer/main') + const { installSheetsWebFileMenu } = await import('./file-menu') + uninstallFileMenu = installSheetsWebFileMenu() + + // Announce readiness only after the shared renderer has mounted and subscribed + // to DesktopApi and the File exit surface is ready. This keeps office:init and + // Host window-close requests from racing the initial workbook UI. + controller.notifyReady() +} + +void bootstrapWeb().catch((error) => { + console.error(error) + renderBootstrapError(error) +}) diff --git a/apps/sheets/src/web/close-lifecycle.ts b/apps/sheets/src/web/close-lifecycle.ts new file mode 100644 index 000000000..467bd6b7e --- /dev/null +++ b/apps/sheets/src/web/close-lifecycle.ts @@ -0,0 +1,105 @@ +import type { OfficeHostApi } from '@genoffice/office-host-api' +import { OFFICE_PROTOCOL_VERSION, type HostToEditorMessage } from '@genoffice/office-protocol' +import type { EditorIframeBridge } from '@genoffice/web-runtime' + +import { + SHEETS_WEB_FILE_ACTION_EVENT, + SHEETS_WEB_HOST_CLOSE_REQUEST_EVENT, + type SheetsWebFileAction, +} from './file-actions' + +export interface SheetsWebCloseLifecycle { + host: OfficeHostApi + destroy(): void +} + +function createCloseAwareHost( + baseHost: OfficeHostApi, + bridge: EditorIframeBridge, + getPendingRequestId: () => string | null, + clearPendingRequest: () => void, +): OfficeHostApi { + return { + getLocale: () => baseHost.getLocale(), + saveDocument: (input) => baseHost.saveDocument(input), + ...(baseHost.saveHistoryVersion + ? { saveHistoryVersion: (input) => baseHost.saveHistoryVersion!(input) } + : {}), + ...(baseHost.exportDocument + ? { exportDocument: (input) => baseHost.exportDocument!(input) } + : {}), + requestClose: async () => { + const requestId = getPendingRequestId() + if (!requestId) { + await baseHost.requestClose?.() + return + } + + clearPendingRequest() + bridge.send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:close-request', + requestId, + payload: { reason: 'window-close' }, + }) + }, + pickFile: (options) => baseHost.pickFile(options), + readFile: (fileId) => baseHost.readFile(fileId), + setDirty: (dirty) => baseHost.setDirty(dirty), + setTitle: (title) => baseHost.setTitle(title), + } +} + +export function createSheetsWebCloseLifecycle( + baseHost: OfficeHostApi, + bridge: EditorIframeBridge, +): SheetsWebCloseLifecycle { + let pendingHostCloseRequestId: string | null = null + + const clearPendingRequest = (): void => { + pendingHostCloseRequestId = null + } + + const host = createCloseAwareHost( + baseHost, + bridge, + () => pendingHostCloseRequestId, + clearPendingRequest, + ) + + const unsubscribeBridge = bridge.subscribe((message: HostToEditorMessage) => { + if (message.type !== 'office:request-close') return + + // Only one Host window-close transaction may be active at a time. Repeated + // clicks on the UC window close button are intentionally ignored until the + // current Excel exit flow either grants or cancels the transaction. + if (pendingHostCloseRequestId !== null) return + + pendingHostCloseRequestId = message.requestId + window.dispatchEvent(new Event(SHEETS_WEB_HOST_CLOSE_REQUEST_EVENT)) + }) + + const handleFileAction = (event: Event): void => { + const action = (event as CustomEvent).detail + if (action !== 'cancel-exit' || pendingHostCloseRequestId === null) return + + const requestId = pendingHostCloseRequestId + clearPendingRequest() + bridge.send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:close-cancelled', + requestId, + payload: { reason: 'user-cancelled' }, + }) + } + window.addEventListener(SHEETS_WEB_FILE_ACTION_EVENT, handleFileAction) + + return { + host, + destroy: () => { + unsubscribeBridge() + window.removeEventListener(SHEETS_WEB_FILE_ACTION_EVENT, handleFileAction) + clearPendingRequest() + }, + } +} diff --git a/apps/sheets/src/web/desktop-adapters.ts b/apps/sheets/src/web/desktop-adapters.ts new file mode 100644 index 000000000..9b473cb11 --- /dev/null +++ b/apps/sheets/src/web/desktop-adapters.ts @@ -0,0 +1,14 @@ +import type { OfficeHostApi } from '@genoffice/office-host-api' +import type { DesktopApi } from '../shared/desktop-api' +import { readLocalImageViaHost } from './local-image' +import { readPivotDefinitionViaEngine } from './pivot-reader' + +/** + * Installs browser-only DesktopApi capabilities that sit outside the generic + * Sheets Web controller. Keeping them in one explicit adapter layer makes the + * Electron/Web boundary visible and gives platform hosts one integration point. + */ +export function installSheetsWebDesktopAdapters(api: DesktopApi, host: OfficeHostApi): void { + api.readPivotDefinition = readPivotDefinitionViaEngine + api.readLocalImage = (request) => readLocalImageViaHost(host, request) +} diff --git a/apps/sheets/src/web/desktop-api.ts b/apps/sheets/src/web/desktop-api.ts new file mode 100644 index 000000000..638aa58a1 --- /dev/null +++ b/apps/sheets/src/web/desktop-api.ts @@ -0,0 +1,691 @@ +import type { AiChatResponse, AiSettings, AiStreamChunk, GenSparkAccountStatus } from '@genoffice/ai-provider' +import type { + OfficeEditorMode, + OfficeFile, + OfficeFileDescriptor, + OfficeHostApi, + SelectedOfficeFile, +} from '@genoffice/office-host-api' +import { OFFICE_PROTOCOL_VERSION, type HostToEditorMessage } from '@genoffice/office-protocol' +import type { EditorIframeBridge } from '@genoffice/web-runtime' +import type { + AttachmentAddResult, + AttachmentImageResult, + AttachmentReadResult, + DesktopApi, + ScreenCaptureResult, + ScreenSourcesResult, + WorkbookExportPdfResult, + WorkbookFile, + WorkbookFormulaCellsRequest, + WorkbookFormulaCellsResult, + WorkbookMediaRequest, + WorkbookMediaResult, + WorkbookPivotDefinition, + WorkbookPivotRequest, + WorkbookRangeRequest, + WorkbookRangeResult, + WorkbookRecalcRequest, + WorkbookRecalcResult, + WorkbookSaveRequest, + WorkbookSaveResult, +} from '../shared/desktop-api' +import { + createBlankXlsxWorkbook, + deleteXlsxSession, + openXlsxWorkbookBytes, + readXlsxWorkbookFormulaCells, + readXlsxWorkbookMedia, + readXlsxWorkbookRange, + recalcXlsxWorkbook, + saveXlsxArchiveMutation, +} from './engine-client' +import { + SHEETS_WEB_FILE_ACTION_EVENT, + type SheetsWebFileAction, + type SheetsWebSnapshotHost, +} from './file-actions' +import { saveWorkbookRequestViaEngine } from './xlsx-save' + +type SheetsLanguage = Awaited> +type LanguageHandler = Parameters[0] +type MenuHandler = Parameters[0] +type MenuAction = Parameters[0] +type RendererFileAction = MenuAction | 'save-history' | 'export-xlsx' + +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + +interface MaterializedWorkbook { + file: WorkbookFile + bytes: ArrayBuffer + touchedEntries: readonly string[] +} + +export interface SheetsWebDesktopController { + desktopApi: DesktopApi + snapshotHost: SheetsWebSnapshotHost + notifyReady(): void + destroy(): void +} + +function unavailable(name: string): never { + throw new Error(`${name} is not available in Sheets Web yet.`) +} + +function noopUnsubscribe(): () => void { + return () => undefined +} + +function hasWorkbookMutations(request: WorkbookSaveRequest): boolean { + return ( + request.edits.length > 0 || + request.structuralOps.length > 0 || + request.chartEdits.length > 0 || + request.visualEdits.length > 0 || + request.visualAdditions.length > 0 || + request.tableAdditions.length > 0 || + request.pivotAdditions.length > 0 || + request.sheetOps.length > 0 || + request.filterStates.length > 0 || + request.hyperlinkEdits.length > 0 || + request.cfStates.length > 0 || + request.dvStates.length > 0 || + request.pageSetupStates.length > 0 || + request.noteStates.length > 0 || + request.formulaValues.length > 0 || + request.pivotCacheRefreshPaths.length > 0 || + request.pivotRefreshUpdates.length > 0 || + request.sheetProtections.length > 0 || + request.sparklineAdditions.length > 0 || + request.definedNamesState !== null + ) +} + +function normalizeLanguage(locale: string): SheetsLanguage { + const value = locale.toLowerCase() + if (value.startsWith('zh')) return 'zh' + if (value.startsWith('ja')) return 'ja' + if (value.startsWith('ko')) return 'ko' + if (value.startsWith('fr')) return 'fr' + if (value.startsWith('de')) return 'de' + if (value.startsWith('es')) return 'es' + if (value.startsWith('th')) return 'th' + if (value.startsWith('id')) return 'id' + if (value.startsWith('ru')) return 'ru' + if (value.startsWith('ar')) return 'ar' + return 'en' +} + +async function selectedToOfficeFile( + host: OfficeHostApi, + selected: SelectedOfficeFile, +): Promise { + if (selected.transport === 'buffer' && selected.bytes) { + return { + id: selected.id, + name: selected.name, + mimeType: selected.mimeType || XLSX_MIME, + size: selected.size ?? selected.bytes.byteLength, + version: selected.version ?? null, + bytes: selected.bytes, + } + } + return host.readFile(selected.id) +} + +function officeDescriptor(file: OfficeFile | null, workbook: WorkbookFile): OfficeFileDescriptor { + if (file) { + return { + id: file.id, + name: file.name, + mimeType: file.mimeType || XLSX_MIME, + ...(file.size === undefined ? {} : { size: file.size }), + ...(file.version === undefined ? {} : { version: file.version }), + } + } + return { + id: `new:${crypto.randomUUID()}`, + name: workbook.name, + mimeType: XLSX_MIME, + version: null, + } +} + +function emptySecondPhaseRequest( + sessionId: string, + request: WorkbookSaveRequest, + tableAdditions: WorkbookSaveRequest['tableAdditions'], + pivotAdditions: WorkbookSaveRequest['pivotAdditions'], + definedNamesState: WorkbookSaveRequest['definedNamesState'], +): WorkbookSaveRequest { + return { + sessionId, + mode: 'save', + edits: [], + structuralOps: [], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions, + pivotAdditions, + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + pivotCacheRefreshPaths: [], + pivotRefreshUpdates: [], + sheetProtections: [], + sparklineAdditions: [], + definedNamesState, + } +} + +export function createSheetsWebDesktopController( + host: OfficeHostApi, + bridge?: EditorIframeBridge, +): SheetsWebDesktopController { + let currentLanguage = normalizeLanguage(document.documentElement.lang || navigator.language || 'en') + let currentMode: OfficeEditorMode = 'edit' + let currentTitle = 'Untitled.xlsx' + let currentOfficeFile: OfficeFile | null = null + let activeWorkbook: WorkbookFile | null = null + let pendingWorkbook: WorkbookFile | null = null + let pendingOpenSignal = false + let currentIsNewDocument = false + let readyNotified = false + let dirty = false + let saving = false + let closeAfterSave = false + let aiSettings = { provider: '', providers: {} } as unknown as AiSettings + + const languageHandlers = new Set() + const menuHandlers = new Set() + + const dispatchRendererFileAction = (action: RendererFileAction): void => { + // save-history/export-xlsx are Web-only extensions. The shared renderer's + // menu callback ultimately forwards unknown file modes to handleSave(); + // keep the Electron MenuAction contract untouched. + for (const handler of menuHandlers) handler(action as MenuAction) + } + + const handleWebFileShortcut = (event: KeyboardEvent): void => { + if (event.repeat || event.altKey || !(event.metaKey || event.ctrlKey)) return + const key = event.key.toLowerCase() + let action: MenuAction | null = null + if (key === 's') action = event.shiftKey ? 'save-as' : 'save' + else if (key === 'o' && !event.shiftKey) action = 'open' + if (!action) return + + // Electron owns these accelerators in the desktop build. Sheets Web has no + // native application menu, so keep the browser from interpreting Ctrl/Cmd+S + // as "save this HTML page" and route all file commands through the same + // renderer menu-action path instead. + event.preventDefault() + if (action === 'open' && menuHandlers.size === 0) { + pendingOpenSignal = true + return + } + dispatchRendererFileAction(action) + } + window.addEventListener('keydown', handleWebFileShortcut) + + const requestHostClose = async (): Promise => { + if (!host.requestClose) return + await host.requestClose() + } + + const handleWebFileAction = (event: Event): void => { + const action = (event as CustomEvent).detail + if (!action) return + if (action === 'open' || action === 'save' || action === 'save-as') { + dispatchRendererFileAction(action) + return + } + if (action === 'save-history' || action === 'export-xlsx') { + dispatchRendererFileAction(action) + return + } + if (action === 'save-and-exit') { + closeAfterSave = true + dispatchRendererFileAction('save') + return + } + if (action === 'discard-and-exit') { + closeAfterSave = false + void requestHostClose() + } + } + window.addEventListener(SHEETS_WEB_FILE_ACTION_EVENT, handleWebFileAction) + + const setLanguage = (locale: string): void => { + const next = normalizeLanguage(locale) + if (next === currentLanguage) return + currentLanguage = next + for (const handler of languageHandlers) handler(next) + } + + const emitOpen = (): void => { + if (menuHandlers.size === 0) { + pendingOpenSignal = true + return + } + pendingOpenSignal = false + dispatchRendererFileAction('open') + } + + const setActiveWorkbook = (workbook: WorkbookFile): void => { + activeWorkbook = workbook + pendingWorkbook = workbook + currentTitle = workbook.name + host.setTitle(workbook.name) + } + + const setWorkbookFromOfficeFile = async (file: OfficeFile): Promise => { + if (activeWorkbook) { + await deleteXlsxSession(activeWorkbook.sessionId).catch(() => undefined) + } + currentOfficeFile = file + currentIsNewDocument = false + setActiveWorkbook(await openXlsxWorkbookBytes(file.name, file.bytes.slice(0))) + } + + const pickWorkbook = async (): Promise => { + if (pendingWorkbook) { + const workbook = pendingWorkbook + pendingWorkbook = null + return workbook + } + + const selected = await host.pickFile({ + multiple: false, + accept: [XLSX_MIME, '.xlsx'], + mode: 'file', + }) + if (!selected?.[0]) return null + const file = await selectedToOfficeFile(host, selected[0]) + if (activeWorkbook) { + await deleteXlsxSession(activeWorkbook.sessionId).catch(() => undefined) + } + currentOfficeFile = file + currentIsNewDocument = false + const workbook = await openXlsxWorkbookBytes(file.name, file.bytes.slice(0)) + activeWorkbook = workbook + currentTitle = file.name + host.setTitle(file.name) + return workbook + } + + const createNewWorkbook = async (): Promise => { + if (activeWorkbook) { + await deleteXlsxSession(activeWorkbook.sessionId).catch(() => undefined) + } + const workbook = await createBlankXlsxWorkbook('Untitled.xlsx') + currentOfficeFile = null + currentIsNewDocument = true + setActiveWorkbook(workbook) + emitOpen() + } + + const materializeWorkbook = async (request: WorkbookSaveRequest): Promise => { + if (currentMode !== 'edit') throw new Error('Workbook is read-only.') + if (!activeWorkbook) throw new Error('No active workbook session.') + + if (!hasWorkbookMutations(request)) { + return { + ...(await saveXlsxArchiveMutation(request.sessionId, activeWorkbook.name, { + replacements: new Map(), + removals: [], + additions: new Map(), + })), + touchedEntries: [], + } + } + + const hasShifts = request.structuralOps.length > 0 || request.sheetOps.length > 0 + const heldPivots = hasShifts ? request.pivotAdditions : [] + const heldTables = request.structuralOps.length > 0 ? request.tableAdditions : [] + const heldNames = hasShifts ? request.definedNamesState : null + const split = heldPivots.length > 0 || heldTables.length > 0 || heldNames !== null + + if (!split) return saveWorkbookRequestViaEngine(request, activeWorkbook, activeWorkbook.name) + + const firstRequest: WorkbookSaveRequest = { + ...request, + mode: 'save', + tableAdditions: heldTables.length > 0 ? [] : request.tableAdditions, + pivotAdditions: heldPivots.length > 0 ? [] : request.pivotAdditions, + definedNamesState: null, + } + const first = await saveWorkbookRequestViaEngine(firstRequest, activeWorkbook, activeWorkbook.name) + try { + const second = await saveWorkbookRequestViaEngine( + emptySecondPhaseRequest( + first.file.sessionId, + request, + heldTables, + heldPivots, + heldNames, + ), + first.file, + activeWorkbook.name, + ) + if (first.file.sessionId !== second.file.sessionId) { + await deleteXlsxSession(first.file.sessionId).catch(() => undefined) + } + return { + ...second, + touchedEntries: [...new Set([...first.touchedEntries, ...second.touchedEntries])], + } + } catch (error) { + await deleteXlsxSession(first.file.sessionId).catch(() => undefined) + throw error + } + } + + const snapshotHost: SheetsWebSnapshotHost = { + saveHistoryVersion: async (request) => { + if (!currentOfficeFile) { + throw new Error('SAVE_FAILED: Save the workbook before creating a history version.') + } + if (!host.saveHistoryVersion) { + throw new Error('SAVE_FAILED: The current Host does not support history versions.') + } + saving = true + const materialized = await materializeWorkbook(request) + try { + const descriptor = officeDescriptor(currentOfficeFile, activeWorkbook ?? materialized.file) + const result = await host.saveHistoryVersion({ + file: descriptor, + bytes: materialized.bytes.slice(0), + baseVersion: descriptor.version ?? null, + }) + if (result.ok) return { canceled: false } + if (result.code === 'CANCELLED') return { canceled: true } + throw new Error(`${result.code ?? 'SAVE_FAILED'}: ${result.error || 'The Host could not save a history version.'}`) + } finally { + saving = false + await deleteXlsxSession(materialized.file.sessionId).catch(() => undefined) + } + }, + exportXlsx: async (request) => { + if (!host.exportDocument) { + throw new Error('SAVE_FAILED: The current Host does not support XLSX export.') + } + saving = true + const materialized = await materializeWorkbook(request) + try { + const workbook = activeWorkbook ?? materialized.file + const descriptor = officeDescriptor(currentOfficeFile, workbook) + const result = await host.exportDocument({ + format: 'xlsx', + file: { ...descriptor, name: descriptor.name || 'Untitled.xlsx' }, + bytes: materialized.bytes.slice(0), + }) + if (result.ok) return { canceled: false } + if (result.code === 'CANCELLED') return { canceled: true } + throw new Error(`${result.code ?? 'SAVE_FAILED'}: ${result.error || 'The Host could not export this workbook.'}`) + } finally { + saving = false + await deleteXlsxSession(materialized.file.sessionId).catch(() => undefined) + } + }, + } + + const handleBridgeMessage = async (message: HostToEditorMessage): Promise => { + if (message.type === 'office:init') { + if (message.payload.kind !== 'xlsx') return + currentMode = message.payload.mode + document.documentElement.dataset.officeMode = currentMode + if (message.payload.locale) setLanguage(message.payload.locale) + await setWorkbookFromOfficeFile(message.payload.file) + emitOpen() + return + } + + if (message.type === 'office:new') { + if (message.payload.kind !== 'xlsx') return + currentMode = message.payload.mode + document.documentElement.dataset.officeMode = currentMode + if (message.payload.locale) setLanguage(message.payload.locale) + await createNewWorkbook() + return + } + + if (message.type === 'office:set-locale') { + setLanguage(message.payload.locale) + return + } + + if (message.type === 'office:set-mode') { + currentMode = message.payload.mode + document.documentElement.dataset.officeMode = currentMode + return + } + + if (message.type === 'office:query-state') { + bridge?.send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:state-result', + requestId: message.requestId, + payload: { + ready: readyNotified, + dirty, + saving, + mode: currentMode, + title: currentTitle, + }, + }) + return + } + + if (message.type === 'office:save') { + dispatchRendererFileAction('save') + } + } + + const unsubscribeBridge = bridge?.subscribe((message) => { + void handleBridgeMessage(message).catch((error) => { + const requestId = 'requestId' in message ? message.requestId : undefined + bridge.send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:error', + ...(requestId === undefined ? {} : { requestId }), + payload: { + code: 'SHEETS_WEB_HOST_ERROR', + message: error instanceof Error ? error.message : String(error), + }, + }) + }) + }) + + const desktopApi: DesktopApi = { + getLanguage: async () => currentLanguage, + onLanguageChanged: (handler) => { + languageHandlers.add(handler) + return () => languageHandlers.delete(handler) + }, + + selectWorkbook: pickWorkbook, + readWorkbookRange: async (request: WorkbookRangeRequest): Promise => + readXlsxWorkbookRange(request), + readWorkbookFormulas: async ( + request: WorkbookFormulaCellsRequest, + ): Promise => readXlsxWorkbookFormulaCells(request), + recalcWorkbook: async (request: WorkbookRecalcRequest): Promise => { + if (!activeWorkbook) throw new Error('No active workbook session.') + return recalcXlsxWorkbook(request, activeWorkbook) + }, + readWorkbookMedia: async (request: WorkbookMediaRequest): Promise => { + if (!activeWorkbook) throw new Error('No active workbook session.') + return readXlsxWorkbookMedia(request, activeWorkbook) + }, + readPivotDefinition: async (_request: WorkbookPivotRequest): Promise => + unavailable('readPivotDefinition'), + readLocalImage: async () => unavailable('readLocalImage'), + captureScreenSources: async (): Promise => ({ + status: 'denied', + sources: [], + }), + captureScreenSource: async (): Promise => null, + saveWorkbookEdits: async (request: WorkbookSaveRequest): Promise => { + if (currentMode !== 'edit') throw new Error('Workbook is read-only.') + if (!activeWorkbook) throw new Error('No active workbook session.') + + saving = true + const previousSessionId = activeWorkbook.sessionId + try { + const saved = + request.mode === 'save-as' && !hasWorkbookMutations(request) + ? { + ...(await saveXlsxArchiveMutation(request.sessionId, activeWorkbook.name, { + replacements: new Map(), + removals: [], + additions: new Map(), + })), + touchedEntries: [] as readonly string[], + } + : await saveWorkbookRequestViaEngine(request, activeWorkbook, activeWorkbook.name) + const descriptor = officeDescriptor(currentOfficeFile, activeWorkbook) + const result = await host.saveDocument({ + file: descriptor, + bytes: saved.bytes.slice(0), + baseVersion: descriptor.version ?? null, + mode: request.mode === 'save-as' ? 'saveAs' : 'save', + newDocument: currentIsNewDocument, + }) + + if (!result.ok) { + closeAfterSave = false + await deleteXlsxSession(saved.file.sessionId).catch(() => undefined) + if (result.code === 'CANCELLED') return { canceled: true } + const code = result.code ?? 'SAVE_FAILED' + throw new Error(`${code}: ${result.error || 'The host could not save this workbook.'}`) + } + if (!result.file) { + closeAfterSave = false + await deleteXlsxSession(saved.file.sessionId).catch(() => undefined) + throw new Error('SAVE_FAILED: The host reported success without a saved file descriptor.') + } + + const nextWorkbook: WorkbookFile = { + ...saved.file, + name: result.file.name, + } + activeWorkbook = nextWorkbook + currentOfficeFile = { + ...result.file, + bytes: saved.bytes.slice(0), + } + currentTitle = result.file.name + currentIsNewDocument = false + dirty = false + host.setTitle(currentTitle) + host.setDirty(false) + if (previousSessionId !== nextWorkbook.sessionId) { + await deleteXlsxSession(previousSessionId).catch(() => undefined) + } + + const shouldClose = closeAfterSave + closeAfterSave = false + if (shouldClose) await requestHostClose() + + return { + canceled: false, + file: nextWorkbook, + touchedEntries: [...saved.touchedEntries], + } + } catch (error) { + closeAfterSave = false + throw error + } finally { + saving = false + } + }, + writeWorkbookRecovery: async () => ({ ok: true }), + autoRenameWorkbook: async () => ({ renamed: false }), + exportPdf: async (): Promise => ({ canceled: true }), + closeWorkbook: async (sessionId: string) => { + if (activeWorkbook?.sessionId === sessionId) activeWorkbook = null + await deleteXlsxSession(sessionId) + }, + openExternal: async (url: string) => { + if (/^https?:\/\//.test(url)) window.open(url, '_blank', 'noopener,noreferrer') + }, + + onMenuAction: (handler) => { + menuHandlers.add(handler) + if (pendingOpenSignal) queueMicrotask(() => emitOpen()) + return () => menuHandlers.delete(handler) + }, + onWorkbookRenamed: () => noopUnsubscribe(), + notifyPendingEdits: (count: number) => { + const next = count > 0 + if (dirty === next) return + dirty = next + host.setDirty(next) + }, + onCloseSaveRequest: () => noopUnsubscribe(), + reportCloseSaveResult: () => undefined, + consumeNewBlankWorkbook: async () => false, + hasQueuedWorkbook: async () => pendingWorkbook !== null, + + getAiSettings: async () => aiSettings, + setAiSettings: async (settings) => { + aiSettings = settings + }, + aiChat: async (): Promise => unavailable('aiChat'), + aiStream: async () => unavailable('aiStream'), + aiStreamCancel: async () => undefined, + aiGskStatus: async (): Promise => + ({ loggedIn: false }) as GenSparkAccountStatus, + aiGskLogin: async () => undefined, + webSearch: async () => ({ results: [], method: 'disabled' }), + onAiStream: (_handler: (chunk: AiStreamChunk) => void) => noopUnsubscribe(), + + pickAttachments: async (): Promise => null, + addAttachmentPaths: async (): Promise => ({ accepted: [], rejected: [] }), + addPastedImage: async (): Promise => ({ accepted: [], rejected: [] }), + readAttachment: async (): Promise => ({ + ok: false, + error: 'Attachments are disabled in Sheets Web.', + }), + readAttachmentImage: async (): Promise => ({ + ok: false, + error: 'Attachments are disabled in Sheets Web.', + }), + getPathForFile: (file: File) => `browser-file://${encodeURIComponent(file.name)}`, + } + + return { + desktopApi, + snapshotHost, + notifyReady: () => { + if (readyNotified) return + readyNotified = true + bridge?.send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:ready', + payload: { kind: 'xlsx' }, + }) + }, + destroy: () => { + unsubscribeBridge?.() + window.removeEventListener('keydown', handleWebFileShortcut) + window.removeEventListener(SHEETS_WEB_FILE_ACTION_EVENT, handleWebFileAction) + languageHandlers.clear() + menuHandlers.clear() + if (activeWorkbook) void deleteXlsxSession(activeWorkbook.sessionId) + activeWorkbook = null + pendingWorkbook = null + }, + } +} + +export function createSheetsWebDesktopApi(host: OfficeHostApi): DesktopApi { + return createSheetsWebDesktopController(host).desktopApi +} diff --git a/apps/sheets/src/web/engine-client.ts b/apps/sheets/src/web/engine-client.ts new file mode 100644 index 000000000..f15d244fd --- /dev/null +++ b/apps/sheets/src/web/engine-client.ts @@ -0,0 +1,380 @@ +import { + workbookFileSchema, + workbookFormulaCellsResultSchema, + workbookMediaResultSchema, + workbookRangeResultSchema, + workbookRecalcResultSchema, + type WorkbookFile, + type WorkbookFormulaCellsRequest, + type WorkbookFormulaCellsResult, + type WorkbookMediaRequest, + type WorkbookMediaResult, + type WorkbookRangeRequest, + type WorkbookRangeResult, + type WorkbookRecalcRequest, + type WorkbookRecalcResult, +} from '../shared/desktop-api' + +const ENGINE_BASE = '/xlsx-engine' +const MAX_MEDIA_BYTES = 20 * 1024 * 1024 + +export interface XlsxEngineHealth { + ok: boolean + service: string + sessionStore: string +} + +export interface XlsxEngineSession { + sessionId: string + source: 'blank' | 'uploaded' +} + +export interface XlsxArchiveEntry { + name: string + crc32: number + compressedSize: number + uncompressedSize: number +} + +export interface XlsxArchiveMutation { + replacements: ReadonlyMap + removals: readonly string[] + additions: ReadonlyMap +} + +export interface SavedXlsxWorkbook { + file: WorkbookFile + bytes: ArrayBuffer +} + +async function readJson(response: Response): Promise { + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error( + `XLSX engine request failed (${response.status})${detail ? `: ${detail}` : ''}`, + ) + } + return (await response.json()) as T +} + +async function assertOk(response: Response): Promise { + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error( + `XLSX engine request failed (${response.status})${detail ? `: ${detail}` : ''}`, + ) + } + return response +} + +function sessionHeaders(sessionId: string): HeadersInit { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Xlsx-Session': sessionId, + } +} + +function bytesToBase64(value: string | Uint8Array): string { + const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} + +function archiveContent(items: ReadonlyMap) { + return [...items].map(([name, content]) => ({ + name, + contentBase64: bytesToBase64(content), + })) +} + +function workbookSheetNames(workbook: WorkbookFile): { + namesById: Map + idsByName: Map +} { + return { + namesById: new Map(workbook.sheets.map((sheet) => [sheet.id, sheet.name])), + idsByName: new Map(workbook.sheets.map((sheet) => [sheet.name, sheet.id])), + } +} + +function mediaTypeForPath(path: string): string | null { + const extension = path.split('.').at(-1)?.toLowerCase() + if (extension === 'png') return 'image/png' + if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg' + if (extension === 'gif') return 'image/gif' + if (extension === 'webp') return 'image/webp' + if (extension === 'bmp') return 'image/bmp' + if (extension === 'svg') return 'image/svg+xml' + return null +} + +export async function getXlsxEngineHealth(): Promise { + const response = await fetch(`${ENGINE_BASE}/health`, { + headers: { Accept: 'application/json' }, + }) + return readJson(response) +} + +export async function createBlankXlsxSession(): Promise { + const response = await fetch(`${ENGINE_BASE}/v1/sessions`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ source: 'blank' }), + }) + return readJson(response) +} + +export async function createBlankXlsxWorkbook(name = 'Untitled.xlsx'): Promise { + const response = await fetch( + `${ENGINE_BASE}/v1/workbooks/blank?name=${encodeURIComponent(name)}`, + { + method: 'POST', + headers: { Accept: 'application/json' }, + }, + ) + return workbookFileSchema.parse(await readJson(response)) +} + +export async function openXlsxWorkbookBytes(name: string, bytes: ArrayBuffer): Promise { + const response = await fetch( + `${ENGINE_BASE}/v1/workbooks?name=${encodeURIComponent(name || 'workbook.xlsx')}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + body: bytes, + }, + ) + return workbookFileSchema.parse(await readJson(response)) +} + +export async function openXlsxWorkbook(file: File): Promise { + return openXlsxWorkbookBytes(file.name, await file.arrayBuffer()) +} + +export async function getXlsxWorkbookMetadata(sessionId: string): Promise { + const response = await fetch(`${ENGINE_BASE}/v1/sessions/${encodeURIComponent(sessionId)}`, { + headers: { + Accept: 'application/json', + 'X-Xlsx-Session': sessionId, + }, + }) + return workbookFileSchema.parse(await readJson(response)) +} + +export async function readXlsxWorkbookRange( + request: WorkbookRangeRequest, +): Promise { + const response = await fetch( + `${ENGINE_BASE}/v1/sessions/${encodeURIComponent(request.sessionId)}/ranges`, + { + method: 'POST', + headers: sessionHeaders(request.sessionId), + body: JSON.stringify({ + sheetId: request.sheetId, + range: request.range, + }), + }, + ) + return workbookRangeResultSchema.parse(await readJson(response)) +} + +export async function readXlsxWorkbookFormulaCells( + request: WorkbookFormulaCellsRequest, +): Promise { + const response = await fetch( + `${ENGINE_BASE}/v1/sessions/${encodeURIComponent(request.sessionId)}/formulas`, + { + method: 'POST', + headers: sessionHeaders(request.sessionId), + body: JSON.stringify({ sheetId: request.sheetId }), + }, + ) + return workbookFormulaCellsResultSchema.parse(await readJson(response)) +} + +export async function recalcXlsxWorkbook( + request: WorkbookRecalcRequest, + workbook: WorkbookFile, +): Promise { + const { namesById, idsByName } = workbookSheetNames(workbook) + const sheetName = (sheetId: string): string => { + const name = namesById.get(sheetId) + if (!name) throw new Error(`Unknown worksheet ${sheetId}.`) + return name + } + + const response = await fetch( + `${ENGINE_BASE}/v1/sessions/${encodeURIComponent(request.sessionId)}/recalc`, + { + method: 'POST', + headers: sessionHeaders(request.sessionId), + body: JSON.stringify({ + edits: request.edits.map((edit) => ({ + sheet: sheetName(edit.sheetId), + row: edit.row, + column: edit.column, + input: edit.input, + })), + reads: request.reads.map((read) => ({ + sheet: sheetName(read.sheetId), + range: read.range, + })), + }), + }, + ) + const result = await readJson<{ + cells: Array<{ + sheet: string + row: number + column: number + formatted: string + number?: number + isFormula: boolean + }> + }>(response) + + return workbookRecalcResultSchema.parse({ + cells: result.cells.map(({ sheet, ...cell }) => { + const sheetId = idsByName.get(sheet) + if (!sheetId) throw new Error(`XLSX engine returned unknown worksheet ${sheet}.`) + return { sheetId, ...cell } + }), + }) +} + +export async function readXlsxWorkbookMedia( + request: WorkbookMediaRequest, + workbook: WorkbookFile, +): Promise { + const visual = workbook.visuals.find((candidate) => candidate.id === request.visualId) + if (!visual?.mediaPath) throw new Error(`Unknown workbook image ${request.visualId}.`) + + const manifest = await getXlsxArchiveManifest(request.sessionId) + const entry = manifest.find((candidate) => candidate.name === visual.mediaPath) + if (!entry) throw new Error(`Workbook is missing ${visual.mediaPath}.`) + if (entry.uncompressedSize > MAX_MEDIA_BYTES) { + throw new Error(`Workbook image exceeds the ${MAX_MEDIA_BYTES / 1024 / 1024}MB preview limit.`) + } + + const entries = await readXlsxArchiveEntries(request.sessionId, [visual.mediaPath]) + const bytes = entries.get(visual.mediaPath) + if (!bytes) throw new Error(`XLSX Engine did not return ${visual.mediaPath}.`) + + const mediaType = visual.mediaType ?? mediaTypeForPath(visual.mediaPath) + if (!mediaType?.startsWith('image/')) { + throw new Error(`Unsupported workbook image type for ${visual.mediaPath}.`) + } + + return workbookMediaResultSchema.parse({ + mediaType, + base64: bytesToBase64(bytes), + }) +} + +export async function getXlsxArchiveManifest(sessionId: string): Promise { + const response = await fetch( + `${ENGINE_BASE}/v1/sessions/${encodeURIComponent(sessionId)}/archive/manifest`, + { + headers: { + Accept: 'application/json', + 'X-Xlsx-Session': sessionId, + }, + }, + ) + const body = await readJson<{ entries: XlsxArchiveEntry[] }>(response) + return body.entries +} + +export async function readXlsxArchiveEntries( + sessionId: string, + entries: readonly string[], +): Promise> { + const response = await fetch( + `${ENGINE_BASE}/v1/sessions/${encodeURIComponent(sessionId)}/archive/read`, + { + method: 'POST', + headers: sessionHeaders(sessionId), + body: JSON.stringify({ entries }), + }, + ) + const body = await readJson<{ + entries: { name: string; contentBase64: string }[] + }>(response) + return new Map(body.entries.map((entry) => [entry.name, base64ToBytes(entry.contentBase64)])) +} + +export async function scanXlsxArchiveEntries( + sessionId: string, + entries: readonly string[], + needle: string, +): Promise { + const response = await fetch( + `${ENGINE_BASE}/v1/sessions/${encodeURIComponent(sessionId)}/archive/scan`, + { + method: 'POST', + headers: sessionHeaders(sessionId), + body: JSON.stringify({ entries, needle }), + }, + ) + const body = await readJson<{ matches: string[] }>(response) + return body.matches +} + +export async function saveXlsxArchiveMutation( + sessionId: string, + name: string, + mutation: XlsxArchiveMutation, +): Promise { + const response = await assertOk( + await fetch( + `${ENGINE_BASE}/v1/sessions/${encodeURIComponent(sessionId)}/archive/save`, + { + method: 'POST', + headers: sessionHeaders(sessionId), + body: JSON.stringify({ + name, + replacements: archiveContent(mutation.replacements), + removals: mutation.removals, + additions: archiveContent(mutation.additions), + }), + }, + ), + ) + const savedSessionId = response.headers.get('x-xlsx-session') + if (!savedSessionId) throw new Error('XLSX engine save response did not include a session id.') + const bytes = await response.arrayBuffer() + const file = await getXlsxWorkbookMetadata(savedSessionId) + return { file, bytes } +} + +export async function deleteXlsxSession(sessionId: string): Promise { + const response = await fetch(`${ENGINE_BASE}/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + headers: { + 'X-Xlsx-Session': sessionId, + }, + }) + if (response.status !== 204 && response.status !== 404) { + throw new Error(`Unable to release XLSX session (${response.status}).`) + } +} diff --git a/apps/sheets/src/web/file-actions.ts b/apps/sheets/src/web/file-actions.ts new file mode 100644 index 000000000..89f2894f0 --- /dev/null +++ b/apps/sheets/src/web/file-actions.ts @@ -0,0 +1,48 @@ +import type { WorkbookSaveRequest } from '../shared/desktop-api' + +export type SheetsWebFileAction = + | 'open' + | 'save' + | 'save-as' + | 'save-history' + | 'export-xlsx' + | 'save-and-exit' + | 'discard-and-exit' + | 'cancel-exit' + +export const SHEETS_WEB_FILE_ACTION_EVENT = 'genoffice:sheets-web-file-action' +export const SHEETS_WEB_HOST_CLOSE_REQUEST_EVENT = 'genoffice:sheets-web-host-close-request' + +export interface SheetsWebSnapshotActionResult { + canceled: boolean +} + +export interface SheetsWebSnapshotHost { + saveHistoryVersion(request: WorkbookSaveRequest): Promise + exportXlsx(request: WorkbookSaveRequest): Promise +} + +const SNAPSHOT_HOST_KEY = '__genofficeSheetsWebSnapshotHost' + +type SnapshotWindow = Window & { + [SNAPSHOT_HOST_KEY]?: SheetsWebSnapshotHost +} + +export function dispatchSheetsWebFileAction(action: SheetsWebFileAction): void { + window.dispatchEvent(new CustomEvent(SHEETS_WEB_FILE_ACTION_EVENT, { detail: action })) +} + +export function installSheetsWebSnapshotHost(host: SheetsWebSnapshotHost): () => void { + const target = window as SnapshotWindow + Object.defineProperty(target, SNAPSHOT_HOST_KEY, { + configurable: true, + value: host, + }) + return () => { + if (target[SNAPSHOT_HOST_KEY] === host) delete target[SNAPSHOT_HOST_KEY] + } +} + +export function getSheetsWebSnapshotHost(): SheetsWebSnapshotHost | null { + return (window as SnapshotWindow)[SNAPSHOT_HOST_KEY] ?? null +} diff --git a/apps/sheets/src/web/file-menu.ts b/apps/sheets/src/web/file-menu.ts new file mode 100644 index 000000000..1cba70968 --- /dev/null +++ b/apps/sheets/src/web/file-menu.ts @@ -0,0 +1,243 @@ +import { t } from '../renderer/i18n/locale' +import { + dispatchSheetsWebFileAction, + SHEETS_WEB_HOST_CLOSE_REQUEST_EVENT, + type SheetsWebFileAction, +} from './file-actions' + +interface FileMenuElements { + root: HTMLDivElement + trigger: HTMLButtonElement + menu: HTMLDivElement + open: HTMLButtonElement + save: HTMLButtonElement + saveAs: HTMLButtonElement + saveHistory: HTMLButtonElement + exportXlsx: HTMLButtonElement + exit: HTMLButtonElement +} + +function button(className: string): HTMLButtonElement { + const element = document.createElement('button') + element.type = 'button' + element.className = className + return element +} + +function setMenuLabel( + element: HTMLButtonElement, + label: string, + shortcut?: string, +): void { + element.replaceChildren() + const text = document.createElement('span') + text.textContent = label + element.append(text) + if (shortcut) { + const key = document.createElement('span') + key.className = 'file-menu-key' + key.textContent = shortcut + element.append(key) + } +} + +function currentWorkbookDirty(): boolean { + const saveButton = document.querySelector('.ribbon-tabs .qa-btn') + return saveButton ? !saveButton.disabled : false +} + +function createExitDialog( + onCancel: () => void, + onDiscardAndExit: () => void, + onSaveAndExit: () => void, +): HTMLDivElement { + const backdrop = document.createElement('div') + backdrop.className = 'file-exit-backdrop' + + const dialog = document.createElement('div') + dialog.className = 'file-exit-dialog' + dialog.setAttribute('role', 'dialog') + dialog.setAttribute('aria-modal', 'true') + + const heading = document.createElement('h2') + heading.textContent = t('appFileUnsavedTitle') + const message = document.createElement('p') + message.textContent = t('appFileUnsavedMessage') + const actions = document.createElement('div') + actions.className = 'file-exit-actions' + + const cancel = button('') + cancel.textContent = t('appFileCancel') + cancel.addEventListener('click', onCancel) + + const discard = button('') + discard.textContent = t('appFileDiscardAndExit') + discard.addEventListener('click', onDiscardAndExit) + + const save = button('primary') + save.textContent = t('appFileSaveAndExit') + save.addEventListener('click', onSaveAndExit) + + actions.append(cancel, discard, save) + dialog.append(heading, message, actions) + backdrop.append(dialog) + backdrop.addEventListener('mousedown', (event) => { + if (event.target === backdrop) onCancel() + }) + return backdrop +} + +function createFileMenu(): FileMenuElements { + const root = document.createElement('div') + root.className = 'sheets-web-file-menu-root file-tab-wrap' + + const trigger = button('ribbon-tab ribbon-tab-file') + trigger.setAttribute('aria-haspopup', 'menu') + trigger.setAttribute('aria-expanded', 'false') + + const menu = document.createElement('div') + menu.className = 'file-menu' + menu.setAttribute('role', 'menu') + menu.hidden = true + + const open = button('file-menu-open') + const save = button('file-menu-save') + const saveAs = button('file-menu-save-as') + const saveHistory = button('file-menu-save-history') + const exportXlsx = button('file-menu-export-xlsx') + const exit = button('file-menu-exit') + + menu.append(open, save, saveAs, saveHistory, exportXlsx, exit) + root.append(trigger, menu) + return { root, trigger, menu, open, save, saveAs, saveHistory, exportXlsx, exit } +} + +export function installSheetsWebFileMenu(): () => void { + const elements = createFileMenu() + document.body.append(elements.root) + let exitDialog: HTMLDivElement | null = null + let saveExitTimer: ReturnType | null = null + + const updateLabels = (): void => { + elements.trigger.textContent = t('appFileTab') + setMenuLabel(elements.open, t('appFileOpen'), 'Ctrl+O') + setMenuLabel(elements.save, t('appFileSave'), 'Ctrl+S') + setMenuLabel(elements.saveAs, t('appFileSaveAs'), 'Ctrl+Shift+S') + setMenuLabel(elements.saveHistory, t('appFileSaveHistory')) + setMenuLabel(elements.exportXlsx, t('appFileExportXlsx')) + setMenuLabel(elements.exit, t('appFileExit')) + } + + const closeMenu = (): void => { + elements.menu.hidden = true + elements.trigger.classList.remove('open') + elements.trigger.setAttribute('aria-expanded', 'false') + } + + const openMenu = (): void => { + const readOnly = document.documentElement.dataset.officeMode === 'view' + elements.save.disabled = readOnly || !currentWorkbookDirty() + elements.saveAs.disabled = readOnly + elements.saveHistory.disabled = readOnly + elements.menu.hidden = false + elements.trigger.classList.add('open') + elements.trigger.setAttribute('aria-expanded', 'true') + } + + const toggleMenu = (): void => { + if (elements.menu.hidden) openMenu() + else closeMenu() + } + + const run = (action: SheetsWebFileAction): void => { + closeMenu() + dispatchSheetsWebFileAction(action) + } + + const closeExitDialog = (): void => { + exitDialog?.remove() + exitDialog = null + } + + const cancelExit = (): void => { + closeExitDialog() + dispatchSheetsWebFileAction('cancel-exit') + } + + const discardAndExit = (): void => { + closeExitDialog() + dispatchSheetsWebFileAction('discard-and-exit') + } + + const saveAndExit = (): void => { + closeExitDialog() + if (saveExitTimer !== null) clearTimeout(saveExitTimer) + dispatchSheetsWebFileAction('save') + const deadline = Date.now() + 120_000 + const waitForClean = (): void => { + if (!currentWorkbookDirty()) { + saveExitTimer = null + dispatchSheetsWebFileAction('discard-and-exit') + return + } + // Save failures and cancellations leave the journal dirty. End this + // one-shot close transaction explicitly so an embedded Host cannot stay + // in waiting-editor forever and a later ordinary Save cannot inherit it. + if (Date.now() >= deadline) { + saveExitTimer = null + dispatchSheetsWebFileAction('cancel-exit') + return + } + saveExitTimer = setTimeout(waitForClean, 100) + } + saveExitTimer = setTimeout(waitForClean, 100) + } + + const requestExit = (): void => { + closeMenu() + if (!currentWorkbookDirty()) { + dispatchSheetsWebFileAction('discard-and-exit') + return + } + if (exitDialog) return + exitDialog = createExitDialog(cancelExit, discardAndExit, saveAndExit) + document.body.append(exitDialog) + } + + elements.trigger.addEventListener('click', toggleMenu) + elements.open.addEventListener('click', () => run('open')) + elements.save.addEventListener('click', () => run('save')) + elements.saveAs.addEventListener('click', () => run('save-as')) + elements.saveHistory.addEventListener('click', () => run('save-history')) + elements.exportXlsx.addEventListener('click', () => run('export-xlsx')) + elements.exit.addEventListener('click', requestExit) + + const handleHostCloseRequest = (): void => { + requestExit() + } + window.addEventListener(SHEETS_WEB_HOST_CLOSE_REQUEST_EVENT, handleHostCloseRequest) + + const onDocumentPointerDown = (event: MouseEvent): void => { + if (!elements.root.contains(event.target as Node)) closeMenu() + } + const onDocumentKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + closeMenu() + if (exitDialog) cancelExit() + } + document.addEventListener('mousedown', onDocumentPointerDown) + document.addEventListener('keydown', onDocumentKeyDown) + + updateLabels() + const unsubscribeLanguage = window.desktopApi.onLanguageChanged(() => updateLabels()) + + return () => { + unsubscribeLanguage() + window.removeEventListener(SHEETS_WEB_HOST_CLOSE_REQUEST_EVENT, handleHostCloseRequest) + document.removeEventListener('mousedown', onDocumentPointerDown) + document.removeEventListener('keydown', onDocumentKeyDown) + if (saveExitTimer !== null) clearTimeout(saveExitTimer) + closeExitDialog() + elements.root.remove() + } +} diff --git a/apps/sheets/src/web/host-policy.ts b/apps/sheets/src/web/host-policy.ts new file mode 100644 index 000000000..da4681cf8 --- /dev/null +++ b/apps/sheets/src/web/host-policy.ts @@ -0,0 +1,70 @@ +import { + DEFAULT_EMBEDDED_OFFICE_CAPABILITIES, + DEFAULT_STANDALONE_OFFICE_CAPABILITIES, + type OfficeHostCapabilities, +} from '@genoffice/office-host-api' +import type { EditorIframeBridge, WebRuntimeMode } from '@genoffice/web-runtime' + +const CAPABILITY_CLASSES = [ + 'office-web', + 'office-ai-enabled', + 'office-autosave-editor', + 'office-can-open', + 'office-can-save', + 'office-can-save-as', + 'office-can-save-history', + 'office-can-export-xlsx', + 'office-can-close', +] as const + +function applyCapabilityClasses(capabilities: OfficeHostCapabilities): void { + const root = document.documentElement + root.classList.add('office-web') + root.classList.toggle('office-ai-enabled', capabilities.ai) + root.classList.toggle('office-autosave-editor', capabilities.autoSave === 'editor') + root.classList.toggle('office-can-open', capabilities.open) + root.classList.toggle('office-can-save', capabilities.save) + root.classList.toggle('office-can-save-as', capabilities.saveAs) + root.classList.toggle('office-can-save-history', capabilities.saveHistoryVersion) + root.classList.toggle('office-can-export-xlsx', capabilities.exportXlsx) + root.classList.toggle('office-can-close', capabilities.close) +} + +export interface SheetsWebHostPolicyController { + getCapabilities(): OfficeHostCapabilities + destroy(): void +} + +export function installSheetsWebHostPolicy( + mode: WebRuntimeMode, + bridge?: EditorIframeBridge, +): SheetsWebHostPolicyController { + let capabilities: OfficeHostCapabilities = { + ...(mode === 'embedded' + ? DEFAULT_EMBEDDED_OFFICE_CAPABILITIES + : DEFAULT_STANDALONE_OFFICE_CAPABILITIES), + } + + const apply = (patch?: Partial): void => { + if (patch) capabilities = { ...capabilities, ...patch } + applyCapabilityClasses(capabilities) + } + apply() + + const unsubscribe = bridge?.subscribe((message) => { + if ( + (message.type === 'office:init' || message.type === 'office:new') && + message.payload.capabilities + ) { + apply(message.payload.capabilities) + } + }) + + return { + getCapabilities: () => ({ ...capabilities }), + destroy: () => { + unsubscribe?.() + document.documentElement.classList.remove(...CAPABILITY_CLASSES) + }, + } +} diff --git a/apps/sheets/src/web/index.html b/apps/sheets/src/web/index.html new file mode 100644 index 000000000..b27e2c726 --- /dev/null +++ b/apps/sheets/src/web/index.html @@ -0,0 +1,13 @@ + + + + + + + GenOffice Sheets Web + + +
+ + + diff --git a/apps/sheets/src/web/local-image.ts b/apps/sheets/src/web/local-image.ts new file mode 100644 index 000000000..6c57e8f8c --- /dev/null +++ b/apps/sheets/src/web/local-image.ts @@ -0,0 +1,93 @@ +import type { OfficeHostApi, SelectedOfficeFile } from '@genoffice/office-host-api' +import { + localImageResultSchema, + type LocalImageRequest, + type LocalImageResult, +} from '../shared/desktop-api' + +const MAX_LOCAL_IMAGE_BYTES = 20 * 1024 * 1024 +const IMAGE_ACCEPT = [ + 'image/png', + 'image/jpeg', + 'image/gif', + '.png', + '.jpg', + '.jpeg', + '.gif', +] + +function sniffImageType(bytes: Uint8Array): LocalImageResult['mediaType'] | null { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return 'image/png' + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + if (bytes.length >= 6) { + const signature = String.fromCharCode(...bytes.subarray(0, 6)) + if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif' + } + return null +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} + +async function selectedBytes( + host: OfficeHostApi, + selected: SelectedOfficeFile, +): Promise { + if ((selected.size ?? 0) > MAX_LOCAL_IMAGE_BYTES) { + throw new Error('Image exceeds 20MB and cannot be inserted.') + } + if (selected.transport === 'buffer' && selected.bytes) return selected.bytes + return (await host.readFile(selected.id)).bytes +} + +/** + * Web cannot dereference an Electron absolute path. It delegates image choice + * to the Office Host instead: standalone uses the browser picker, while an + * embedded UC/Web OS host can return a platform file or token through the same + * office:pick-file / office:read-file contract. + */ +export async function readLocalImageViaHost( + host: OfficeHostApi, + _request: LocalImageRequest, +): Promise { + const selected = await host.pickFile({ + multiple: false, + accept: IMAGE_ACCEPT, + mode: 'file', + }) + if (!selected?.[0]) throw new Error('Image selection was cancelled.') + + const buffer = await selectedBytes(host, selected[0]) + if (buffer.byteLength > MAX_LOCAL_IMAGE_BYTES) { + throw new Error('Image exceeds 20MB and cannot be inserted.') + } + + const bytes = new Uint8Array(buffer) + const mediaType = sniffImageType(bytes) + if (!mediaType) throw new Error('The selected file is not a PNG/JPEG/GIF image.') + + return localImageResultSchema.parse({ + mediaType, + base64: bytesToBase64(bytes), + }) +} diff --git a/apps/sheets/src/web/pivot-reader.ts b/apps/sheets/src/web/pivot-reader.ts new file mode 100644 index 000000000..1de672346 --- /dev/null +++ b/apps/sheets/src/web/pivot-reader.ts @@ -0,0 +1,26 @@ +import type { WorkbookPivotDefinition, WorkbookPivotRequest } from '../shared/desktop-api' +import { parsePivotDefinition } from '../gateway/xlsx-pivot' +import { readXlsxArchiveEntries } from './engine-client' + +const decoder = new TextDecoder() + +/** + * Browser Pivot reader. The request already carries the exact pivotTable and + * pivotCacheDefinition part paths discovered from workbook metadata; the Web + * adapter only reads those session-scoped archive entries and reuses the same + * fail-closed parser as Electron. + */ +export async function readPivotDefinitionViaEngine( + request: WorkbookPivotRequest, +): Promise { + const entries = await readXlsxArchiveEntries(request.sessionId, [request.path, request.cachePath]) + const pivotBytes = entries.get(request.path) + const cacheBytes = entries.get(request.cachePath) + if (!pivotBytes) throw new Error(`XLSX Engine did not return ${request.path}.`) + if (!cacheBytes) throw new Error(`XLSX Engine did not return ${request.cachePath}.`) + + return parsePivotDefinition( + decoder.decode(pivotBytes), + decoder.decode(cacheBytes), + ) as WorkbookPivotDefinition +} diff --git a/apps/sheets/src/web/product-policy.css b/apps/sheets/src/web/product-policy.css new file mode 100644 index 000000000..fb5549ffa --- /dev/null +++ b/apps/sheets/src/web/product-policy.css @@ -0,0 +1,172 @@ +/* Web-only product policy. Electron never imports this stylesheet. */ + +html.office-web:not(.office-ai-enabled) .ribbon-group:has(.ai-entry), +html.office-web:not(.office-ai-enabled) .ribbon-group:has(.ai-entry) + .ribbon-sep, +html.office-web:not(.office-ai-enabled) .copilot, +html.office-web:not(.office-ai-enabled) .ai-entry { + display: none !important; +} + +html.office-web:not(.office-autosave-editor) .autosave-toggle { + display: none !important; +} + +html.office-web:not(.office-ai-enabled) .sheet-body, +html.office-web:not(.office-ai-enabled) .app-shell.copilot-collapsed .sheet-body { + grid-template-columns: minmax(0, 1fr) !important; +} + +html.office-web:not(.office-can-open) .file-menu-open, +html.office-web:not(.office-can-save) .file-menu-save, +html.office-web:not(.office-can-save-as) .file-menu-save-as, +html.office-web:not(.office-can-save-history) .file-menu-save-history, +html.office-web:not(.office-can-export-xlsx) .file-menu-export-xlsx, +html.office-web:not(.office-can-close) .file-menu-exit { + display: none !important; +} + +/* Match the Word/PPT Web File tab position and menu geometry; only the + product accent differs (Excel green instead of Word blue / PPT orange). */ +html.office-web .file-tab-wrap { + position: relative; +} + +/* The Web mount carries both classes. Keep this selector after file-tab-wrap + so the generic Word/PPT-compatible wrapper rule cannot move the File tab + into normal document flow. */ +html.office-web .sheets-web-file-menu-root.file-tab-wrap { + position: fixed; + top: 5px; + left: 10px; + z-index: 120; + font-family: inherit; +} + +html.office-web .ribbon-tab-file { + min-height: 28px; + border: 0; + color: #fff; + background: var(--excel-green); + border-radius: 4px; + padding: 6px 16px; + margin-right: 6px; + font-size: 13px; + font-weight: 600; + line-height: 16px; + white-space: nowrap; +} + +html.office-web .ribbon-tab-file:hover, +html.office-web .ribbon-tab-file.open { + color: #fff; + background: var(--excel-green-dark); +} + +html.office-web .file-menu { + position: absolute; + top: calc(100% + 2px); + left: 0; + z-index: 60; + min-width: 210px; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: 6px; + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); + padding: 5px; +} + +html.office-web .file-menu[hidden] { + display: none !important; +} + +html.office-web .file-menu button { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + border: none; + background: none; + padding: 8px 12px; + font-size: 13px; + border-radius: 4px; + cursor: pointer; + color: var(--text); + white-space: nowrap; +} + +html.office-web .file-menu button:hover:not(:disabled) { + background: var(--ribbon-hover); +} + +html.office-web .file-menu button:disabled { + opacity: 0.4; + cursor: default; +} + +html.office-web .file-menu-key { + color: var(--text-dim); + font-size: 12px; + margin-left: 24px; +} + +html.office-web .file-exit-backdrop { + position: fixed; + inset: 0; + z-index: 200; + display: grid; + place-items: center; + background: rgb(0 0 0 / 28%); +} + +html.office-web .file-exit-dialog { + width: min(440px, calc(100vw - 32px)); + padding: 20px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + box-shadow: 0 16px 48px rgb(0 0 0 / 24%); +} + +html.office-web .file-exit-dialog h2 { + margin: 0 0 8px; + font-size: 17px; + font-weight: 600; +} + +html.office-web .file-exit-dialog p { + margin: 0 0 20px; + color: var(--text-muted); + font-size: 13px; + line-height: 1.5; +} + +html.office-web .file-exit-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +html.office-web .file-exit-actions button { + min-height: 32px; + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--surface); + color: var(--text); +} + +html.office-web .file-exit-actions .primary { + border-color: var(--excel-green); + background: var(--excel-green); + color: #fff; +} + +/* Browser Web Office has no native titlebar controls to reserve space for. + The Web-owned File tab occupies the same left slot as Word/PPT, so shift + the shared Sheets QAT/tab row to start immediately after it. */ +html.office-web .ribbon-tabs-win, +html.office-web .ribbon-tabs-mac, +html.office-web .ribbon-tabs:not(.ribbon-tabs-win):not(.ribbon-tabs-mac) { + padding-right: 10px; + padding-left: 82px; +} diff --git a/apps/sheets/src/web/xlsx-save.ts b/apps/sheets/src/web/xlsx-save.ts new file mode 100644 index 000000000..6aaf97765 --- /dev/null +++ b/apps/sheets/src/web/xlsx-save.ts @@ -0,0 +1,490 @@ +import type { WorkbookFile, WorkbookSaveRequest } from '../shared/desktop-api' +import { + planCellEditsToXlsx, + type CellEdit, + type EntrySource, + type MutationPlan, + type SheetFormulaValues, +} from '../gateway/xlsx-gateway' +import { + getXlsxArchiveManifest, + readXlsxArchiveEntries, + saveXlsxArchiveMutation, + scanXlsxArchiveEntries, + type SavedXlsxWorkbook, + type XlsxArchiveEntry, +} from './engine-client' + +const MAX_PATCH_ENTRY_BYTES = 256 * 1024 * 1024 + +type PlannerStructuralOps = NonNullable[2]> +type PlannerStructuralOp = PlannerStructuralOps[number]['ops'][number] +type PlannerSheetPlan = NonNullable[4]> +type PlannerFilterStates = Parameters[5] +type PlannerHyperlinkEdits = Parameters[6] +type PlannerCfStates = Parameters[7] +type PlannerDvStates = Parameters[8] +type PlannerSheetProtections = Parameters[9] +type PlannerVisualAdditions = Parameters[11] +type PlannerPageSetupStates = Parameters[12] +type PlannerNoteStates = Parameters[13] +type PlannerTableAdditions = NonNullable[14]> +type PlannerPivotAdditions = NonNullable[15]> +type PlannerPivotRefreshUpdates = NonNullable[17]> +type PlannerSparklineAdditions = NonNullable[19]> + +interface SheetPlanContext { + readonly plan: PlannerSheetPlan | undefined + /** Planner-stage name: original file name for existing sheets, final name for additions. */ + readonly plannerNames: ReadonlyMap +} + +function createEngineEntrySource( + sessionId: string, + manifest: readonly XlsxArchiveEntry[], +): EntrySource { + const entryByName = new Map(manifest.map((entry) => [entry.name, entry])) + const textCache = new Map() + const decoder = new TextDecoder() + + return { + paths: async () => manifest.map((entry) => entry.name), + has: async (path) => entryByName.has(path), + canPatch: async (path) => + (entryByName.get(path)?.uncompressedSize ?? 0) <= MAX_PATCH_ENTRY_BYTES, + containsText: async (path, needle) => { + const matches = await scanXlsxArchiveEntries(sessionId, [path], needle) + return matches.includes(path) + }, + readText: async (path) => { + const cached = textCache.get(path) + if (cached !== undefined) return cached + const entry = entryByName.get(path) + if (!entry) throw new Error(`Workbook is missing ${path}.`) + if (entry.uncompressedSize > MAX_PATCH_ENTRY_BYTES) { + throw new Error( + `${path} is ${entry.uncompressedSize} bytes uncompressed — too large to edit. ` + + 'Entries above 256MB can be preserved but not patched.', + ) + } + const entries = await readXlsxArchiveEntries(sessionId, [path]) + const bytes = entries.get(path) + if (!bytes) throw new Error(`XLSX Engine did not return ${path}.`) + const content = decoder.decode(bytes) + textCache.set(path, content) + return content + }, + } +} + +function mergeAdditions(plan: MutationPlan): Map { + const additions = new Map() + for (const [path, content] of plan.added) additions.set(path, content) + for (const [path, content] of plan.addedBinary) additions.set(path, content) + return additions +} + +function originalSheetNames(workbook: WorkbookFile): Map { + return new Map(workbook.sheets.map((sheet) => [sheet.id, sheet.name])) +} + +function requiredSheetName(names: ReadonlyMap, sheetId: string): string { + const name = names.get(sheetId) + if (!name) throw new Error(`Unknown worksheet ${sheetId}.`) + return name +} + +/** + * Collapses the renderer's ordered sheet journal into the gateway's declarative + * SheetEditPlan. Existing sheets keep their original file name until the final + * sheet-surgery phase; newly added sheets use their final name from the start + * because their worksheet part is allocated under that name. + */ +function buildSheetPlanContext( + request: WorkbookSaveRequest, + workbook: WorkbookFile, +): SheetPlanContext { + const originalNames = originalSheetNames(workbook) + if (request.sheetOps.length === 0) { + return { plan: undefined, plannerNames: originalNames } + } + + const finalNames = new Map(originalNames) + const added = new Map< + string, + { name: string; sourceSheetId?: string | undefined; sequence: number } + >() + const removed = new Set() + const hidden = new Map() + let additionSequence = 0 + let orderChanged = false + + for (const op of request.sheetOps) { + switch (op.kind) { + case 'rename-sheet': { + if (!finalNames.has(op.sheetId)) throw new Error(`Unknown worksheet ${op.sheetId}.`) + finalNames.set(op.sheetId, op.newName) + const addition = added.get(op.sheetId) + if (addition) addition.name = op.newName + break + } + case 'add-sheet': { + if (finalNames.has(op.sheetId)) { + throw new Error(`Worksheet id ${op.sheetId} already exists.`) + } + finalNames.set(op.sheetId, op.name) + added.set(op.sheetId, { name: op.name, sequence: additionSequence++ }) + break + } + case 'duplicate-sheet': { + if (finalNames.has(op.sheetId)) { + throw new Error(`Worksheet id ${op.sheetId} already exists.`) + } + if (!originalNames.has(op.sourceSheetId)) { + throw new Error( + 'Duplicating a sheet that was itself added in the current unsaved session is not supported yet.', + ) + } + finalNames.set(op.sheetId, op.name) + added.set(op.sheetId, { + name: op.name, + sourceSheetId: op.sourceSheetId, + sequence: additionSequence++, + }) + break + } + case 'remove-sheet': { + if (!finalNames.has(op.sheetId)) throw new Error(`Unknown worksheet ${op.sheetId}.`) + removed.add(op.sheetId) + break + } + case 'set-sheet-hidden': { + if (!finalNames.has(op.sheetId)) throw new Error(`Unknown worksheet ${op.sheetId}.`) + hidden.set(op.sheetId, op.hidden) + break + } + case 'reorder-sheets': + orderChanged = true + break + } + } + + // An added-then-removed sheet never needs to enter the package. + const canceledAdditionIds = [...removed].filter((sheetId) => added.has(sheetId)) + for (const sheetId of canceledAdditionIds) { + added.delete(sheetId) + finalNames.delete(sheetId) + hidden.delete(sheetId) + removed.delete(sheetId) + } + + const plannerNames = new Map(originalNames) + for (const [sheetId, addition] of added) plannerNames.set(sheetId, addition.name) + + const renames = workbook.sheets.flatMap((sheet) => { + if (removed.has(sheet.id)) return [] + const finalName = requiredSheetName(finalNames, sheet.id) + return finalName === sheet.name ? [] : [{ sheetName: sheet.name, newName: finalName }] + }) + + const additions = [...added.entries()] + .sort((left, right) => left[1].sequence - right[1].sequence) + .map(([, addition]) => ({ + name: addition.name, + ...(addition.sourceSheetId === undefined + ? {} + : { sourceSheetName: requiredSheetName(originalNames, addition.sourceSheetId) }), + })) + + const removals = workbook.sheets + .filter((sheet) => removed.has(sheet.id)) + .map((sheet) => sheet.name) + + const hiddenChanges = [...hidden.entries()] + .filter(([sheetId]) => !removed.has(sheetId)) + .map(([sheetId, isHidden]) => ({ + sheetName: originalNames.get(sheetId) ?? requiredSheetName(finalNames, sheetId), + hidden: isHidden, + })) + + const order = request.sheetOrder.map((sheetId) => { + if (removed.has(sheetId)) { + throw new Error(`Removed worksheet ${sheetId} is still present in the final sheet order.`) + } + return requiredSheetName(finalNames, sheetId) + }) + + const expectedIds = new Set([ + ...workbook.sheets.filter((sheet) => !removed.has(sheet.id)).map((sheet) => sheet.id), + ...added.keys(), + ]) + if ( + order.length !== expectedIds.size || + request.sheetOrder.some((sheetId) => !expectedIds.has(sheetId)) + ) { + throw new Error('Final sheet order does not match the saved worksheet set.') + } + if (new Set(request.sheetOrder).size !== request.sheetOrder.length) { + throw new Error('Final sheet order contains a duplicate worksheet id.') + } + + return { + plannerNames, + plan: { + renames, + additions, + removals, + order, + hiddenChanges, + orderChanged, + }, + } +} + +function toPlannerStructuralOps( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerStructuralOps { + const bySheet = new Map() + for (const structuralOp of request.structuralOps) { + const { sheetId, ...op } = structuralOp + const sheetName = requiredSheetName(names, sheetId) + const ops = bySheet.get(sheetName) ?? [] + ops.push(op as PlannerStructuralOp) + bySheet.set(sheetName, ops) + } + return [...bySheet].map(([sheetName, ops]) => ({ sheetName, ops })) +} + +function toPlannerVisualAdditions( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerVisualAdditions { + return request.visualAdditions.map(({ sheetId, ...addition }) => ({ + sheetName: requiredSheetName(names, sheetId), + ...addition, + })) +} + +function toPlannerTableAdditions( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerTableAdditions { + return request.tableAdditions.map(({ sheetId, ...addition }) => ({ + sheetName: requiredSheetName(names, sheetId), + ...addition, + })) +} + +function toPlannerPivotAdditions( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerPivotAdditions { + return request.pivotAdditions.map(({ sheetId, sourceSheetId, ...addition }) => ({ + sheetName: requiredSheetName(names, sheetId), + sourceSheetName: requiredSheetName(names, sourceSheetId), + ...addition, + })) +} + +function toPlannerPivotRefreshUpdates( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerPivotRefreshUpdates { + return request.pivotRefreshUpdates.map(({ sheetId, relayout, ...update }) => { + const sheetName = requiredSheetName(names, sheetId) + if (!relayout) return { ...update, sheetName } + + const { sheetId: relayoutSheetId, sourceSheetId, ...layout } = relayout + if (relayoutSheetId !== sheetId) { + throw new Error('Pivot relayout target worksheet does not match the refresh target worksheet.') + } + return { + ...update, + sheetName, + relayout: { + ...layout, + sourceSheetName: requiredSheetName(names, sourceSheetId), + }, + } + }) +} + +function toPlannerSparklineAdditions( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerSparklineAdditions { + return request.sparklineAdditions.map(({ sheetId, ...addition }) => ({ + sheetName: requiredSheetName(names, sheetId), + ...addition, + })) +} + +function toPlannerCellEdits( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): CellEdit[] { + return request.edits.map((edit) => ({ + sheetName: requiredSheetName(names, edit.sheetId), + row: edit.row, + column: edit.column, + writeValue: edit.writeValue, + cell: { + value: edit.value, + ...(edit.formula === undefined ? {} : { formula: edit.formula }), + }, + ...(edit.style === undefined ? {} : { style: edit.style }), + ...(edit.rich === undefined ? {} : { rich: edit.rich }), + ...(edit.styleReset === undefined ? {} : { styleReset: edit.styleReset }), + })) +} + +function toPlannerFilterStates( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerFilterStates { + return request.filterStates.map((state) => ({ + sheetName: requiredSheetName(names, state.sheetId), + filter: state.filter, + hiddenRows: state.hiddenRows, + visibilityRange: state.visibilityRange, + })) +} + +function toPlannerHyperlinkEdits( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerHyperlinkEdits { + const linksBySheet = new Map< + string, + Array<{ row: number; column: number; target: string | null }> + >() + + for (const link of request.hyperlinkEdits) { + const sheetName = requiredSheetName(names, link.sheetId) + const links = linksBySheet.get(sheetName) ?? [] + links.push({ row: link.row, column: link.column, target: link.target }) + linksBySheet.set(sheetName, links) + } + + return [...linksBySheet].map(([sheetName, links]) => ({ sheetName, edits: links })) +} + +function toPlannerCfStates( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerCfStates { + return request.cfStates.map((state) => ({ + sheetName: requiredSheetName(names, state.sheetId), + rules: state.rules, + })) +} + +function toPlannerDvStates( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerDvStates { + return request.dvStates.map((state) => ({ + sheetName: requiredSheetName(names, state.sheetId), + rules: state.rules, + })) +} + +function toPlannerSheetProtections( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerSheetProtections { + return request.sheetProtections.map((state) => ({ + sheetName: requiredSheetName(names, state.sheetId), + protected: state.protected, + })) +} + +function toPlannerPageSetupStates( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerPageSetupStates { + return request.pageSetupStates.map(({ sheetId, ...state }) => ({ + sheetName: requiredSheetName(names, sheetId), + ...state, + })) +} + +function toPlannerNoteStates( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): PlannerNoteStates { + return request.noteStates.map(({ sheetId, notes }) => ({ + sheetName: requiredSheetName(names, sheetId), + notes, + })) +} + +function toPlannerFormulaValues( + request: WorkbookSaveRequest, + names: ReadonlyMap, +): SheetFormulaValues[] { + const valuesBySheet = new Map() + + for (const value of request.formulaValues) { + const sheetName = requiredSheetName(names, value.sheetId) + const cells = valuesBySheet.get(sheetName) ?? [] + cells.push({ + row: value.row, + column: value.column, + value: value.value, + }) + valuesBySheet.set(sheetName, cells) + } + + return [...valuesBySheet].map(([sheetName, cells]) => ({ sheetName, cells })) +} + +/** + * Browser preservation-save path. The same mutation planner used by Electron + * reads package parts through the Rust session API; Rust only reassembles the + * planned replacement/add/remove sets and returns standard XLSX bytes. + */ +export async function saveWorkbookRequestViaEngine( + request: WorkbookSaveRequest, + workbook: WorkbookFile, + name: string, +): Promise { + const sheetContext = buildSheetPlanContext(request, workbook) + const manifest = await getXlsxArchiveManifest(request.sessionId) + const source = createEngineEntrySource(request.sessionId, manifest) + const plan = await planCellEditsToXlsx( + source, + toPlannerCellEdits(request, sheetContext.plannerNames), + toPlannerStructuralOps(request, sheetContext.plannerNames), + request.chartEdits, + sheetContext.plan, + toPlannerFilterStates(request, sheetContext.plannerNames), + toPlannerHyperlinkEdits(request, sheetContext.plannerNames), + toPlannerCfStates(request, sheetContext.plannerNames), + toPlannerDvStates(request, sheetContext.plannerNames), + toPlannerSheetProtections(request, sheetContext.plannerNames), + request.definedNamesState, + toPlannerVisualAdditions(request, sheetContext.plannerNames), + toPlannerPageSetupStates(request, sheetContext.plannerNames), + toPlannerNoteStates(request, sheetContext.plannerNames), + toPlannerTableAdditions(request, sheetContext.plannerNames), + toPlannerPivotAdditions(request, sheetContext.plannerNames), + request.pivotCacheRefreshPaths, + toPlannerPivotRefreshUpdates(request, sheetContext.plannerNames), + request.visualEdits, + toPlannerSparklineAdditions(request, sheetContext.plannerNames), + toPlannerFormulaValues(request, sheetContext.plannerNames), + ) + + const saved = await saveXlsxArchiveMutation(request.sessionId, name, { + replacements: plan.replaced, + removals: plan.removedEntries, + additions: mergeAdditions(plan), + }) + + return { + ...saved, + touchedEntries: plan.touchedEntries, + } +} diff --git a/apps/sheets/tests/web-engine-client.test.ts b/apps/sheets/tests/web-engine-client.test.ts new file mode 100644 index 000000000..021e2b14b --- /dev/null +++ b/apps/sheets/tests/web-engine-client.test.ts @@ -0,0 +1,232 @@ +import type { OfficeHostApi } from '@genoffice/office-host-api' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { WorkbookFile } from '../src/shared/desktop-api' +import { readXlsxWorkbookMedia } from '../src/web/engine-client' +import { readLocalImageViaHost } from '../src/web/local-image' + +const sessionId = '00000000-0000-4000-8000-000000000001' +const mediaPath = 'xl/media/image1.png' + +function workbook(): WorkbookFile { + return { + sessionId, + name: 'media.xlsx', + sha256: '0'.repeat(64), + entryCount: 1, + sheets: [ + { + id: 'sheet-1', + name: 'Sheet1', + rowCount: 1, + columnCount: 1, + columnWidths: [], + defaultRowHeight: null, + defaultColumnWidth: null, + freeze: null, + hidden: false, + tabColor: null, + showGridLines: true, + showFormulas: false, + tables: [], + comments: [], + pivotRanges: [], + pivotTables: [], + sparklines: [], + }, + ], + styles: [], + dxfStyles: [], + visuals: [ + { + id: 'image-1', + sheetId: 'sheet-1', + kind: 'image', + anchor: { + fromRow: 0, + fromColumn: 0, + fromRowOffset: 0, + fromColumnOffset: 0, + toRow: 1, + toColumn: 1, + toRowOffset: 0, + toColumnOffset: 0, + }, + mediaPath, + mediaType: 'image/png', + }, + ], + definedNames: [], + readOnly: false, + } +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('Sheets Web XLSX media client', () => { + it('reads image bytes from the workbook session archive', async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + entries: [ + { + name: mediaPath, + crc32: 0, + compressedSize: bytes.length, + uncompressedSize: bytes.length, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + entries: [ + { + name: mediaPath, + contentBase64: Buffer.from(bytes).toString('base64'), + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await readXlsxWorkbookMedia( + { sessionId, visualId: 'image-1' }, + workbook(), + ) + + expect(result).toEqual({ + mediaType: 'image/png', + base64: Buffer.from(bytes).toString('base64'), + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/archive/manifest') + expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/archive/read') + }) + + it('rejects oversized images before downloading their bytes', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + entries: [ + { + name: mediaPath, + crc32: 0, + compressedSize: 1, + uncompressedSize: 20 * 1024 * 1024 + 1, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + await expect( + readXlsxWorkbookMedia({ sessionId, visualId: 'image-1' }, workbook()), + ).rejects.toThrow('20MB preview limit') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('Sheets Web Host image adapter', () => { + it('resolves a token-backed platform file through OfficeHostApi.readFile', async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const pickFile = vi.fn().mockResolvedValue([ + { + id: 'fs:image-1', + name: 'platform-image.png', + mimeType: 'application/octet-stream', + size: bytes.byteLength, + version: 'v1', + transport: 'token', + token: 'opaque-platform-token', + }, + ]) + const readFile = vi.fn().mockResolvedValue({ + id: 'fs:image-1', + name: 'platform-image.png', + mimeType: 'application/octet-stream', + size: bytes.byteLength, + version: 'v1', + bytes: bytes.buffer as ArrayBuffer, + }) + const host = { pickFile, readFile } as unknown as OfficeHostApi + + const result = await readLocalImageViaHost(host, { path: 'host-picker://insert-image' }) + + expect(result).toEqual({ + mediaType: 'image/png', + base64: Buffer.from(bytes).toString('base64'), + }) + expect(pickFile).toHaveBeenCalledWith({ + multiple: false, + accept: ['image/png', 'image/jpeg', 'image/gif', '.png', '.jpg', '.jpeg', '.gif'], + mode: 'file', + }) + expect(readFile).toHaveBeenCalledWith('fs:image-1') + }) + + it('rejects spoofed image metadata after reading the real bytes', async () => { + const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]) + const host = { + pickFile: vi.fn().mockResolvedValue([ + { + id: 'fs:not-image', + name: 'fake.png', + mimeType: 'image/png', + size: bytes.byteLength, + version: 'v1', + transport: 'token', + token: 'opaque-platform-token', + }, + ]), + readFile: vi.fn().mockResolvedValue({ + id: 'fs:not-image', + name: 'fake.png', + mimeType: 'image/png', + size: bytes.byteLength, + version: 'v1', + bytes: bytes.buffer as ArrayBuffer, + }), + } as unknown as OfficeHostApi + + await expect( + readLocalImageViaHost(host, { path: 'host-picker://insert-image' }), + ).rejects.toThrow('not a PNG/JPEG/GIF image') + }) + + it('rejects oversized platform images before requesting their content', async () => { + const readFile = vi.fn() + const host = { + pickFile: vi.fn().mockResolvedValue([ + { + id: 'fs:huge-image', + name: 'huge.png', + mimeType: 'image/png', + size: 20 * 1024 * 1024 + 1, + version: 'v1', + transport: 'token', + token: 'opaque-platform-token', + }, + ]), + readFile, + } as unknown as OfficeHostApi + + await expect( + readLocalImageViaHost(host, { path: 'host-picker://insert-image' }), + ).rejects.toThrow('exceeds 20MB') + expect(readFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sheets/tests/xlsx-notes.test.ts b/apps/sheets/tests/xlsx-notes.test.ts index c014934c0..267272e4a 100644 --- a/apps/sheets/tests/xlsx-notes.test.ts +++ b/apps/sheets/tests/xlsx-notes.test.ts @@ -41,7 +41,7 @@ describe('note snapshots', () => { expect(vml![1]).toContain('40') }) - it('registers rels, content types, and the legacyDrawing element', async () => { + it('registers rels, content types, namespace, and the legacyDrawing element', async () => { const plan = await planNotes(NOTES) const rels = plan.replaced.get('xl/worksheets/_rels/sheet1.xml.rels') ?? plan.added.get('xl/worksheets/_rels/sheet1.xml.rels') @@ -51,6 +51,9 @@ describe('note snapshots', () => { expect(contentTypes).toContain('spreadsheetml.comments+xml') expect(contentTypes).toContain('Extension="vml"') const worksheet = plan.replaced.get('xl/worksheets/sheet1.xml') + expect(worksheet).toContain( + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"', + ) expect(worksheet).toContain(' + `export function ${name}(..._args) { throw new Error(${message}) }`, + ) + .join('\n') + }, + transform(code, id) { + if (!id.replaceAll('\\', '/').endsWith('/src/gateway/xlsx-drawing-add.ts')) return null + if (!code.includes(imageBase64Needle)) { + throw new Error( + 'Sheets Web image base64 boundary no longer matches xlsx-drawing-add.ts; review the browser adaptation.', + ) + } + return { + code: `${code.replace(imageBase64Needle, imageBase64Replacement)}\n${imageBase64Helper}`, + map: null, + } + }, + } +} + +export default defineConfig({ + root: 'src/web', + plugins: [xlsxGatewayBrowserBoundary(), react()], + server: { + host: '0.0.0.0', + port: Number(process.env.SHEETS_WEB_PORT) || 5275, + strictPort: true, + proxy: xlsxEngineProxy, + }, + preview: { + host: '0.0.0.0', + port: Number(process.env.SHEETS_WEB_PORT) || 5275, + strictPort: true, + proxy: xlsxEngineProxy, + }, + build: { + outDir: '../../dist-web', + emptyOutDir: true, + }, +}) \ No newline at end of file diff --git a/deploy/xlsx-engine/README.md b/deploy/xlsx-engine/README.md new file mode 100644 index 000000000..36070f2bf --- /dev/null +++ b/deploy/xlsx-engine/README.md @@ -0,0 +1,189 @@ +# Sheets Web + XLSX Engine single-node deployment + +This directory is the production-oriented baseline for the first GenOffice Sheets Web milestone. + +The intended topology is: + +```text +Browser / UC Web OS + | + HTTPS + | + Nginx + | \ + | +-- /xlsx-engine/* -> 127.0.0.1:7301 + | + +------- Sheets Web static files + | + +-- office:* iframe protocol when embedded by UC +``` + +The Rust process is deliberately private and UC-agnostic. It must not receive UC JWTs, tenant IDs, FsNode IDs, plugin permissions, or storage credentials. + +## 1. Build + +From a clean checkout: + +```bash +npm ci +npm run build:web:sheets +npm run build:xlsx-engine +``` + +Outputs used by this runbook: + +```text +apps/sheets/dist-web/ +services/xlsx-engine-service/target/release/xlsx-engine-service +``` + +The UC reference Host can be built separately with: + +```bash +npm run build:uc-webos-xlsx-host +``` + +It belongs inside the UC plugin/static-host deployment, not inside the Rust service. + +## 2. Install files + +Create a dedicated unprivileged account and directories using the operating system's normal account-management tools, then install approximately as follows: + +```text +/opt/genoffice/sheets-web/ <- contents of apps/sheets/dist-web/ +/opt/genoffice/bin/xlsx-engine-service <- Rust release binary +/etc/genoffice/xlsx-engine.env <- copy of xlsx-engine.env.example +/var/lib/genoffice-xlsx-engine/ <- owned by genoffice:genoffice +/etc/systemd/system/genoffice-xlsx-engine.service +/etc/nginx/conf.d/genoffice-sheets.conf +``` + +Recommended ownership: + +```text +/opt/genoffice root:root, read-only to the service +/etc/genoffice root:root +/etc/genoffice/xlsx-engine.env root:genoffice, mode 0640 +/var/lib/genoffice-xlsx-engine genoffice:genoffice +``` + +The systemd unit uses `ProtectSystem=strict` and permits writes only under `/var/lib/genoffice-xlsx-engine`. + +## 3. Configure the Engine + +Copy `xlsx-engine.env.example` to `/etc/genoffice/xlsx-engine.env` and tune it for the server. + +Important defaults: + +```text +raw workbook max 100 MiB +total request max 384 MiB +idle session TTL 3600 s +cleanup interval 60 s +heavy request slots 4 +heavy queue timeout 15 s +``` + +Start conservatively. The current native workbook-session layer still serializes some work internally, so increasing the admission-slot count far above CPU capacity does not automatically improve throughput. + +## 4. Configure Nginx + +Copy `nginx-sheets.conf.example`, then change at least: + +- `server_name`; +- TLS configuration; +- Sheets Web static root if different; +- `frame-ancestors` to the exact UC Web OS origin(s); +- `/metrics` allow-list if Prometheus is not local. + +The example forwards Nginx `$request_id` as `X-Request-Id`. The Engine echoes the ID back and writes it into its JSON request log, which makes a browser/proxy request traceable without logging workbook data. + +The public `/xlsx-engine/` prefix is stripped by Nginx. Sheets Web therefore continues calling same-origin URLs such as: + +```text +/xlsx-engine/v1/workbooks +/xlsx-engine/v1/sessions//ranges +``` + +while Rust itself continues exposing `/v1/*`. + +## 5. Start and verify + +After installing the unit/configuration: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now genoffice-xlsx-engine +sudo systemctl reload nginx +``` + +Local Engine smoke checks: + +```bash +curl -fsS http://127.0.0.1:7301/health +curl -fsS http://127.0.0.1:7301/metrics +``` + +Expected health fields include: + +```text +ok +service +sessionStore +maxHeavyRequests +availableHeavySlots +heavyQueueTimeoutSecs +``` + +Check request correlation: + +```bash +curl -i -H 'X-Request-Id: deployment-smoke-1' \ + http://127.0.0.1:7301/health +``` + +The response should contain: + +```text +X-Request-Id: deployment-smoke-1 +``` + +and stdout/journald should contain one JSON `http_request` event with the same ID. + +## 6. Production checks before traffic + +Verify all of these before enabling UC users: + +1. Nginx can serve the Sheets Web `index.html` and hashed assets. +2. `/xlsx-engine/health` works through the same public origin as Sheets Web. +3. `/xlsx-engine/metrics` is not publicly reachable unless intentionally allowed. +4. An ordinary XLSX opens, edits, saves and reopens. +5. An unchanged workbook can Save As to a new file. +6. In UC, a stale normal Save produces `VERSION_CONFLICT` and does not call the final file-write API. +7. `Ctrl/Cmd+S`, `Ctrl/Cmd+Shift+S` and `Ctrl/Cmd+O` are intercepted by Sheets Web rather than by the browser. +8. Stopping the service removes its endpoint workspace; a forced crash is cleaned on the next successful bind of that same endpoint. +9. Request bodies beyond the configured raw workbook limit return HTTP 413. +10. Saturated heavy-work admission returns HTTP 503 before XLSX work starts. + +## 7. Logs and metrics + +Use journald or the platform log collector for the service stdout. Request logs contain operational metadata only: + +```json +{"event":"http_request","requestId":"...","method":"POST","path":"/v1/workbooks","status":201,"durationMs":42} +``` + +Do not add workbook names, query strings, UC identifiers, or request bodies to these log lines. + +The Prometheus-text metrics are intentionally low-cardinality and contain no user/file labels. At minimum alert on sustained: + +- growth in `genoffice_xlsx_server_errors_total`; +- growth in `genoffice_xlsx_heavy_admission_rejects_total`; +- `genoffice_xlsx_heavy_slots_available` staying at zero; +- unexpectedly high `genoffice_xlsx_workbook_sessions` relative to expected active editors. + +## 8. Upgrade / rollback rule + +Treat the Sheets Web static bundle and Rust Engine as one release even though they deploy independently. Upgrade both from the same tested commit. Keep the previous static bundle and Engine binary available so rollback can restore the pair together. + +The UC Host/iframe protocol and Rust `/v1/*` boundary are intentionally stable so future multi-node routing can be added behind these interfaces without changing the editor. diff --git a/deploy/xlsx-engine/genoffice-xlsx-engine.service b/deploy/xlsx-engine/genoffice-xlsx-engine.service new file mode 100644 index 000000000..4f5a45b3f --- /dev/null +++ b/deploy/xlsx-engine/genoffice-xlsx-engine.service @@ -0,0 +1,33 @@ +[Unit] +Description=GenOffice XLSX Engine Service +After=network.target + +[Service] +Type=simple +User=genoffice +Group=genoffice +WorkingDirectory=/opt/genoffice +EnvironmentFile=/etc/genoffice/xlsx-engine.env +ExecStart=/opt/genoffice/bin/xlsx-engine-service +Restart=on-failure +RestartSec=2s +TimeoutStopSec=30s +KillSignal=SIGINT + +# The engine only needs its private work root plus the installed binary and +# shared libraries. UC storage/auth are never mounted into this process. +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/genoffice-xlsx-engine +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/xlsx-engine/nginx-sheets.conf.example b/deploy/xlsx-engine/nginx-sheets.conf.example new file mode 100644 index 000000000..8a7649387 --- /dev/null +++ b/deploy/xlsx-engine/nginx-sheets.conf.example @@ -0,0 +1,54 @@ +# Dedicated Sheets Web origin example. +# Replace server_name, TLS includes and the static root for the deployment. +server { + listen 443 ssl http2; + server_name sheets.example.com; + + # include /etc/nginx/snippets/tls.conf; + + root /opt/genoffice/sheets-web; + index index.html; + + # The Engine accepts up to 384 MiB total request bodies by default. Leave + # modest proxy headroom so Nginx does not reject a valid archive mutation + # before the Engine can apply its own configured limit. + client_max_body_size 400m; + + # Prometheus endpoint: private by default. Add the monitoring subnet here + # rather than exposing workbook/session operational metrics publicly. + location = /xlsx-engine/metrics { + allow 127.0.0.1; + allow ::1; + deny all; + + proxy_pass http://127.0.0.1:7301/metrics; + proxy_http_version 1.1; + proxy_set_header X-Request-Id $request_id; + } + + location /xlsx-engine/ { + # Trailing slash intentionally strips the public /xlsx-engine prefix; + # the Rust service itself exposes /health and /v1/*. + proxy_pass http://127.0.0.1:7301/; + proxy_http_version 1.1; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # Once the Engine admits a synchronous XLSX operation it is allowed to + # finish atomically. Keep the proxy timeout comfortably above expected + # workbook operations; tune this using production duration metrics. + proxy_connect_timeout 5s; + proxy_send_timeout 900s; + proxy_read_timeout 900s; + } + + location / { + try_files $uri $uri/ /index.html; + } + + # Sheets is designed to be embedded by UC Web OS. Configure frame-ancestors + # in the real deployment to the exact UC origin(s); do not use a wildcard. + # add_header Content-Security-Policy "frame-ancestors 'self' https://webos.example.com" always; +} diff --git a/deploy/xlsx-engine/verify-deployment.sh b/deploy/xlsx-engine/verify-deployment.sh new file mode 100644 index 000000000..41bd8d27c --- /dev/null +++ b/deploy/xlsx-engine/verify-deployment.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-http://127.0.0.1:7301}" +BASE_URL="${BASE_URL%/}" +REQUEST_ID="deploy-smoke-$(date +%s)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +printf 'Checking XLSX Engine at %s\n' "$BASE_URL" + +curl -fsS \ + -D "$TMP_DIR/health.headers" \ + -H "X-Request-Id: $REQUEST_ID" \ + "$BASE_URL/health" \ + > "$TMP_DIR/health.json" + +grep -qi "^x-request-id: ${REQUEST_ID}" "$TMP_DIR/health.headers" \ + || fail 'health response did not echo X-Request-Id' + +node - "$TMP_DIR/health.json" <<'NODE' +const fs = require('fs') +const path = process.argv[2] +const value = JSON.parse(fs.readFileSync(path, 'utf8')) +if (value.ok !== true || value.service !== 'xlsx-engine-service') process.exit(1) +for (const field of ['maxHeavyRequests', 'availableHeavySlots', 'heavyQueueTimeoutSecs']) { + if (!Number.isFinite(value[field])) process.exit(1) +} +NODE +printf ' health/request-id: ok\n' + +curl -fsS "$BASE_URL/metrics" > "$TMP_DIR/metrics.txt" +for metric in \ + genoffice_xlsx_requests_total \ + genoffice_xlsx_server_errors_total \ + genoffice_xlsx_heavy_admission_rejects_total \ + genoffice_xlsx_heavy_slots \ + genoffice_xlsx_heavy_slots_available \ + genoffice_xlsx_workbook_sessions +do + grep -q "^${metric} " "$TMP_DIR/metrics.txt" || fail "missing metric ${metric}" +done +printf ' metrics: ok\n' + +curl -fsS -X POST \ + "$BASE_URL/v1/workbooks/blank?name=deployment-smoke.xlsx" \ + > "$TMP_DIR/blank.json" + +SESSION_ID="$(node - "$TMP_DIR/blank.json" <<'NODE' +const fs = require('fs') +const value = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) +if (!value.sessionId || !Array.isArray(value.sheets) || value.sheets.length === 0) process.exit(1) +process.stdout.write(value.sessionId) +NODE +)" + +curl -fsS \ + -H "X-Xlsx-Session: $SESSION_ID" \ + "$BASE_URL/v1/sessions/$SESSION_ID" \ + > "$TMP_DIR/session.json" +printf ' blank workbook/session: ok\n' + +STATUS="$(curl -sS \ + -o /dev/null \ + -w '%{http_code}' \ + -X DELETE \ + -H "X-Xlsx-Session: $SESSION_ID" \ + "$BASE_URL/v1/sessions/$SESSION_ID")" +[[ "$STATUS" == "204" ]] || fail "session cleanup returned HTTP ${STATUS}" +printf ' explicit session cleanup: ok\n' + +STATUS="$(curl -sS \ + -o "$TMP_DIR/deleted.txt" \ + -w '%{http_code}' \ + -H "X-Xlsx-Session: $SESSION_ID" \ + "$BASE_URL/v1/sessions/$SESSION_ID")" +[[ "$STATUS" == "404" ]] || fail "deleted session remained reachable (HTTP ${STATUS})" +printf ' deleted-session isolation: ok\n' + +printf 'PASS: XLSX Engine deployment smoke checks succeeded.\n' diff --git a/deploy/xlsx-engine/xlsx-engine.env.example b/deploy/xlsx-engine/xlsx-engine.env.example new file mode 100644 index 000000000..8fe512f81 --- /dev/null +++ b/deploy/xlsx-engine/xlsx-engine.env.example @@ -0,0 +1,20 @@ +# Listener stays private; Nginx is the public boundary. +XLSX_ENGINE_LISTEN=127.0.0.1:7301 + +# Raw workbook upload and total HTTP body limits. +XLSX_ENGINE_MAX_WORKBOOK_MB=100 +XLSX_ENGINE_MAX_REQUEST_MB=384 + +# Idle workbook session lifecycle. +XLSX_ENGINE_SESSION_TTL_SECS=3600 +XLSX_ENGINE_CLEANUP_INTERVAL_SECS=60 + +# Expensive XLSX work is admitted before execution. Requests that cannot obtain +# a slot within the queue timeout receive HTTP 503 before work starts. +XLSX_ENGINE_MAX_HEAVY_REQUESTS=4 +XLSX_ENGINE_HEAVY_QUEUE_TIMEOUT_SECS=15 + +# Persistent parent directory for endpoint-isolated temporary workspaces. +# The service removes its own endpoint workspace on graceful shutdown and +# removes crash leftovers after successfully rebinding that endpoint. +XLSX_ENGINE_WORK_ROOT=/var/lib/genoffice-xlsx-engine diff --git a/docs/WEB_OFFICE_FOUNDATION.md b/docs/WEB_OFFICE_FOUNDATION.md index 9c22df797..d932c75ff 100644 --- a/docs/WEB_OFFICE_FOUNDATION.md +++ b/docs/WEB_OFFICE_FOUNDATION.md @@ -1,15 +1,107 @@ # Web Office Foundation -This branch introduces the Web Office foundation for running GenOffice editors in browsers and embedding them into a host system through iframe-based integration. +This branch family introduces the Web Office foundation for running GenOffice editors in browsers and embedding them into a host system through iframe-based integration. -Initial scope: +Current architecture: -- Keep the existing React editor UIs. -- Preserve the current Electron applications while adding Web entry points. -- Add a host API abstraction for file open/save, file picking, locale, dirty state, and lifecycle events. -- Add a versioned iframe message protocol and bridge. -- Prioritize Docs and Slides Web support first. -- Keep Sheets Web UI separate from the XLSX processing service design. -- Avoid changes to `docx-engine`, `pptx-engine`, and `pptx-render` unless browser compatibility requires them. +- Keep the existing React editor UIs and preserve the Electron applications. +- Add Web entry points behind the shared `OfficeHostApi` / versioned `office:*` iframe protocol. +- Docs and Slides Web reuse their browser-compatible document engines. +- Sheets Web reuses the existing React + Univer renderer while XLSX parsing, workbook sessions, formula evaluation and archive assembly stay behind the independent Rust XLSX engine service. +- Browser Sheets calls only same-origin `/xlsx-engine/*`; Vite/Nginx owns the reverse proxy. +- UC Web OS owns authentication, FsNode/storage permissions, file open/save and platform pickers. The Rust engine never receives UC JWTs, tenants, users, FsNode IDs or plugin permissions. +- Standalone hosts may implement the same Office Host API with ordinary browser file pickers. -Development branch: `agent/web-office-foundation`. +## Sheets Web status + +The Excel Web foundation now has permanent TypeScript, Rust, compatibility and Chromium gates covering: + +- blank workbook creation; +- real `.xlsx` open and lazy range reads; +- iframe `office:new` / `office:init` lifecycle; +- formula discovery and IronCalc recalculation; +- preservation save with formula cached-value persistence while retaining `` formulas; +- filters and row visibility; +- hyperlinks; +- conditional formatting and data validation; +- sheet protection; +- page setup; +- legacy notes/comments and VML relationship creation; +- sheet add, duplicate, rename, remove, hide/unhide and reorder; +- structural row/column insert, remove and move; +- row/column size, hidden and outline state; +- merge/unmerge and reference movement; +- defined names; +- linked chart edits; +- workbook image/media reads plus image/visual insertion and anchor edits; +- native tables; +- x14 sparklines; +- Pivot definition reads; +- Pivot cache `refreshOnLoad`; +- native Pivot creation; +- existing Pivot output-layout refresh/expansion, including fail-closed checks when newly occupied cells already contain ordinary worksheet data; +- host save/download and reopen verification. + +All major renderer XLSX save journals now have a Web preservation path and browser coverage. Unsupported document constructs discovered by the shared planner continue to fail closed rather than producing a partially corrupted package. + +## Host file integration + +Sheets Web supports both buffer and token-backed Host files. + +Permanent Chromium coverage includes: + +- Host-selected workbook open; +- `office:pick-file -> token -> office:read-file` for image insertion; +- normal save through the Host; +- clean-workbook Save As without a synthetic edit; +- Web `Ctrl/Cmd+S`, `Ctrl/Cmd+Shift+S` and `Ctrl/Cmd+O` routing through the same renderer menu-action path instead of browser page commands; +- UC-style optimistic version checks where a stale normal Save returns `VERSION_CONFLICT` before the final Host write; Save As remains independent of the source version. + +The reference UC bridge lives in `examples/uc-webos-xlsx-host`. Opening the file that launched the plugin, normal Save and Save As use the established UC file RPCs. The only remaining platform API gap is a confirmed UC interactive picker for choosing an arbitrary second system file from inside Office; the generic `office:pick-file` boundary is already isolated so the editor does not need to change when that UC RPC is finalized. + +## XLSX Engine production baseline + +The Rust service now includes: + +- configurable raw-workbook and total-request size limits; +- idle session TTL and cleanup; +- endpoint-isolated workspaces with crash/startup and graceful-shutdown cleanup; +- bounded heavy-work admission with a pre-execution queue timeout; +- `X-Request-Id` correlation; +- JSON request logs containing operational fields only; +- low-cardinality Prometheus-text `/metrics`; +- no UC platform coupling. + +A single-node deployment baseline is under `deploy/xlsx-engine/` with environment, systemd, Nginx and rollout/smoke-check examples. + +## Compatibility gates + +In addition to feature-specific Chromium tests, `compat:web-engine` runs the repository's five generated XLSX compatibility fixtures through the real Web production save architecture: + +```text +shared planner + -> Rust archive manifest/read/scan + -> Rust archive save + -> new workbook session + -> decompressed OOXML entry SHA256 comparison +``` + +Only entries explicitly touched/added/removed by the mutation plan may differ. This is a generated regression corpus, not a claim that the files were produced by Excel, WPS or LibreOffice; real-application files should be added as a separate corpus when available. + +## Remaining milestone work + +The Excel Web foundation itself is substantially complete. Remaining work is primarily release/platform work: + +- integrate the UC Host bridge into the actual UC Web OS plugin source; +- replace the temporary browser fallback for arbitrary second-file selection once the final UC system-picker RPC is confirmed; +- audit and remediate production dependency vulnerabilities; +- measure/tune Web bundle loading rather than adding speculative chunking; +- add a real Excel/WPS/LibreOffice compatibility corpus; +- tune Engine admission/session limits with production telemetry; +- add a hard in-flight Engine execution deadline only if XLSX operations gain cooperative cancellation or move behind an isolated worker-process boundary. + +Development branches: + +- `agent/web-office-foundation` +- `agent/ppt-web-foundation` +- `agent/xlsx-web-foundation` diff --git a/e2e/sheets-web-chart.spec.ts b/e2e/sheets-web-chart.spec.ts new file mode 100644 index 000000000..69fed6398 --- /dev/null +++ b/e2e/sheets-web-chart.spec.ts @@ -0,0 +1,192 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const sheetsWebUrl = process.env.SHEETS_WEB_E2E_URL +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL + +async function createChartWorkbook(path: string): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + + CategoryValue + A10 + B20 + + +`, + 'xl/worksheets/_rels/sheet1.xml.rels': ` + + +`, + 'xl/drawings/drawing1.xml': ` + + + 3010 + 100150 + + + + + + + +`, + 'xl/drawings/_rels/drawing1.xml.rels': ` + + +`, + 'xl/charts/chart1.xml': ` + + + Original Chart + + + + + + + Value + Data!$A$2:$A$3AB + Data!$B$2:$B$3General1020 + + + + + + + + + +`, + } + + for (const [name, content] of Object.entries(parts)) zip.file(name, content) + await writeFile(path, await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })) +} + +test.describe('Sheets Web chart edits', () => { + test.skip(!sheetsWebUrl, 'SHEETS_WEB_E2E_URL is only set by the Sheets Web browser CI step') + + test('edits a linked chart part and preserves its drawing relationship', async ({ page }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-chart-')) + const workbookPath = join(directory, 'web-excel-chart.xlsx') + await createChartWorkbook(workbookPath) + + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=web-excel-chart.xlsx'), + ) + await page.locator('#xlsx-picker').setInputFiles(workbookPath) + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + sheets: Array<{ id: string; name: string }> + visuals?: Array<{ kind: string; chartPath?: string }> + } + expect(opened.sheets[0]?.name).toBe('Data') + expect(opened.visuals?.some((visual) => visual.kind === 'chart')).toBe(true) + + const saved = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + sessionId: payload.sessionId, + mode: 'save', + edits: [], + structuralOps: [], + chartEdits: [{ chartPath: 'xl/charts/chart1.xml', title: 'Web Chart Saved' }], + visualEdits: [], + visualAdditions: [], + tableAdditions: [], + pivotAdditions: [], + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + pivotCacheRefreshPaths: [], + pivotRefreshUpdates: [], + sheetProtections: [], + sparklineAdditions: [], + definedNamesState: null, + }) + }, + { sessionId: opened.sessionId }, + ) + + expect(saved.canceled).toBe(false) + expect(saved.touchedEntries).toContain('xl/charts/chart1.xml') + await expect(page.locator('#host-state')).toHaveText('saved', { timeout: 30_000 }) + await expect(page.locator('#download-button')).toBeEnabled() + + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('web-excel-chart.xlsx') + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const downloadedZip = await JSZip.loadAsync(await readFile(downloadPath!)) + const chartXml = await downloadedZip.file('xl/charts/chart1.xml')?.async('text') + expect(chartXml).toContain('Web Chart Saved') + expect(chartXml).not.toContain('Original Chart') + expect(chartXml).toContain('Data!$A$2:$A$3') + expect(chartXml).toContain('Data!$B$2:$B$3') + + const worksheetXml = await downloadedZip.file('xl/worksheets/sheet1.xml')?.async('text') + expect(worksheetXml).toContain('') + const drawingRels = await downloadedZip.file('xl/drawings/_rels/drawing1.xml.rels')?.async('text') + expect(drawingRels).toContain('Target="../charts/chart1.xml"') + const drawingXml = await downloadedZip.file('xl/drawings/drawing1.xml')?.async('text') + expect(drawingXml).toContain('') + }) +}) diff --git a/e2e/sheets-web-defined-names.spec.ts b/e2e/sheets-web-defined-names.spec.ts new file mode 100644 index 000000000..1207ac869 --- /dev/null +++ b/e2e/sheets-web-defined-names.spec.ts @@ -0,0 +1,152 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const sheetsWebUrl = process.env.SHEETS_WEB_E2E_URL +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL + +async function createDefinedNamesWorkbook(path: string): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + + + Data!$A$1:$B$2 + Data!$A$1 + +`, + 'xl/_rels/workbook.xml.rels': ` + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + + 1020 + 3040 + +`, + } + + for (const [name, content] of Object.entries(parts)) zip.file(name, content) + await writeFile( + path, + await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }), + ) +} + +test.describe('Sheets Web defined names', () => { + test.skip(!sheetsWebUrl, 'SHEETS_WEB_E2E_URL is only set by the Sheets Web browser CI step') + + test('rewrites modeled names while preserving Excel built-ins', async ({ page }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-names-')) + const workbookPath = join(directory, 'web-excel-defined-names.xlsx') + await createDefinedNamesWorkbook(workbookPath) + + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=web-excel-defined-names.xlsx'), + ) + await page.locator('#xlsx-picker').setInputFiles(workbookPath) + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + sheets: Array<{ id: string; name: string }> + } + expect(opened.sheets[0]?.name).toBe('Data') + + const saved = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + sessionId: payload.sessionId, + mode: 'save', + edits: [], + structuralOps: [], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions: [], + pivotAdditions: [], + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + pivotCacheRefreshPaths: [], + pivotRefreshUpdates: [], + sheetProtections: [], + sparklineAdditions: [], + definedNamesState: { + names: [ + { name: 'Revenue', formula: '=Data!$B$1' }, + { name: 'LocalTotal', formula: '=Data!$A$1', sheetIndex: 0 }, + ], + preserveNames: [], + }, + }) + }, + { sessionId: opened.sessionId }, + ) + + expect(saved.canceled).toBe(false) + expect(saved.touchedEntries).toContain('xl/workbook.xml') + await expect(page.locator('#host-state')).toHaveText('saved', { timeout: 30_000 }) + await expect(page.locator('#download-button')).toBeEnabled() + + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('web-excel-defined-names.xlsx') + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const downloadedZip = await JSZip.loadAsync(await readFile(downloadPath!)) + const workbookXml = await downloadedZip.file('xl/workbook.xml')?.async('text') + expect(workbookXml).toContain( + 'Data!$A$1:$B$2', + ) + expect(workbookXml).not.toContain('name="OldName"') + expect(workbookXml).toContain('Data!$B$1') + expect(workbookXml).toContain( + 'Data!$A$1', + ) + }) +}) diff --git a/e2e/sheets-web-file-menu.spec.ts b/e2e/sheets-web-file-menu.spec.ts new file mode 100644 index 000000000..f2e6cdf65 --- /dev/null +++ b/e2e/sheets-web-file-menu.spec.ts @@ -0,0 +1,314 @@ +import { resolve } from 'node:path' + +import { expect, test, type FrameLocator, type Page } from '@playwright/test' + +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL +const fixture = resolve(__dirname, '../apps/sheets/fixtures/generated/compatibility-basic.xlsx') + +async function openHost(page: Page): Promise { + if (!hostUrl) throw new Error('SHEETS_WEB_HOST_E2E_URL is required.') + await page.goto(hostUrl) + const editor = page.frameLocator('#office-frame') + await expect(editor.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + await expect(editor.locator('text=GenOffice Sheets Web failed to start')).toHaveCount(0) + await expect(editor.locator('.sheets-web-file-menu-root .ribbon-tab-file')).toHaveText('文件', { + timeout: 15_000, + }) + return editor +} + +async function openFixture(page: Page): Promise { + await page.locator('#xlsx-picker').setInputFiles(fixture) + await expect(page.locator('#file-name')).toContainText('compatibility-basic.xlsx', { + timeout: 15_000, + }) +} + +async function openFileMenu(editor: FrameLocator): Promise { + const trigger = editor.locator('.sheets-web-file-menu-root .ribbon-tab-file') + await trigger.click() + await expect(editor.locator('.sheets-web-file-menu-root .file-menu')).toBeVisible() +} + +async function largestWorksheetCanvas(editor: FrameLocator): Promise<{ x: number; y: number; width: number; height: number }> { + let best: { x: number; y: number; width: number; height: number } | null = null + await expect + .poll( + async () => { + const canvases = editor.locator('canvas') + const count = await canvases.count() + best = null + let bestArea = 0 + for (let index = 0; index < count; index += 1) { + const box = await canvases.nth(index).boundingBox() + if (!box || box.width < 200 || box.height < 80) continue + const area = box.width * box.height + if (area <= bestArea) continue + bestArea = area + best = box + } + return bestArea + }, + { timeout: 15_000 }, + ) + .toBeGreaterThan(20_000) + if (!best) throw new Error('Worksheet canvas was not found.') + return best +} + +async function editFirstCell(page: Page, editor: FrameLocator): Promise { + const box = await largestWorksheetCanvas(editor) + // The worksheet canvas includes a narrow row/column header. Keep the click + // well inside A1 while remaining valid in smaller CI viewports. + await page.mouse.click(box.x + Math.min(90, box.width - 20), box.y + Math.min(38, box.height - 20)) + await page.keyboard.type('File Menu Dirty', { delay: 20 }) + await page.keyboard.press('Enter') + await expect(editor.locator('.ribbon-tabs .qa-btn').first()).toBeEnabled({ timeout: 10_000 }) +} + +async function installCloseMessageRecorder(page: Page): Promise { + await page.evaluate(() => { + const target = window as Window & { + __sheetsCloseMessages?: Array<{ + type: string + requestId?: string + reason?: string + }> + } + target.__sheetsCloseMessages = [] + window.addEventListener('message', (event) => { + const message = event.data as { + type?: string + requestId?: string + payload?: { reason?: string } + } + if ( + message?.type !== 'office:close-request' && + message?.type !== 'office:close-cancelled' + ) { + return + } + target.__sheetsCloseMessages?.push({ + type: message.type, + ...(message.requestId ? { requestId: message.requestId } : {}), + ...(message.payload?.reason ? { reason: message.payload.reason } : {}), + }) + }) + }) +} + +async function sendWindowCloseRequest(page: Page, requestId: string): Promise { + await page.evaluate((id) => { + const frame = document.querySelector('#office-frame') + if (!frame?.contentWindow) throw new Error('Sheets iframe is not available.') + const targetOrigin = new URL(frame.src, window.location.href).origin + frame.contentWindow.postMessage( + { + protocol: 1, + type: 'office:request-close', + requestId: id, + payload: { reason: 'window-close' }, + }, + targetOrigin, + ) + }, requestId) +} + +async function expectCloseMessage( + page: Page, + type: 'office:close-request' | 'office:close-cancelled', + requestId: string, + reason: string, +): Promise { + await expect + .poll( + () => + page.evaluate( + ({ expectedType, expectedRequestId, expectedReason }) => { + const messages = ( + window as Window & { + __sheetsCloseMessages?: Array<{ + type: string + requestId?: string + reason?: string + }> + } + ).__sheetsCloseMessages + return ( + messages?.some( + (message) => + message.type === expectedType && + message.requestId === expectedRequestId && + message.reason === expectedReason, + ) ?? false + ) + }, + { + expectedType: type, + expectedRequestId: requestId, + expectedReason: reason, + }, + ), + { timeout: 20_000 }, + ) + .toBe(true) +} + +test.describe('Sheets Web deployable File menu', () => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is only set by the Sheets Web File UI gate') + + test('matches the Word/PPT File position and hides Web AI product chrome', async ({ page }) => { + const editor = await openHost(page) + const fileButton = editor.locator('.sheets-web-file-menu-root .ribbon-tab-file') + const saveButton = editor.locator('.ribbon-tabs .qa-btn').first() + + const fileBox = await fileButton.boundingBox() + const saveBox = await saveButton.boundingBox() + expect(fileBox).not.toBeNull() + expect(saveBox).not.toBeNull() + expect(fileBox!.x).toBeLessThan(saveBox!.x) + expect(Math.abs(fileBox!.y - saveBox!.y)).toBeLessThan(8) + + await expect(editor.locator('.ai-entry:visible')).toHaveCount(0) + await expect(editor.locator('.copilot:visible')).toHaveCount(0) + await expect(editor.locator('.autosave-toggle:visible')).toHaveCount(0) + await expect(editor.locator('.workbook-status')).not.toContainText(/AI|Genspark/i) + + await openFileMenu(editor) + const visibleItems = editor.locator('.sheets-web-file-menu-root .file-menu button:visible') + await expect(visibleItems).toHaveCount(6) + await expect(visibleItems.nth(0).locator('span').first()).toHaveText('打开') + await expect(visibleItems.nth(0).locator('.file-menu-key')).toHaveText('Ctrl+O') + await expect(visibleItems.nth(1).locator('span').first()).toHaveText('保存') + await expect(visibleItems.nth(1).locator('.file-menu-key')).toHaveText('Ctrl+S') + await expect(visibleItems.nth(2).locator('span').first()).toHaveText('另存为') + await expect(visibleItems.nth(2).locator('.file-menu-key')).toHaveText('Ctrl+Shift+S') + await expect(visibleItems.nth(3)).toHaveText('保存历史版本') + await expect(visibleItems.nth(4)).toHaveText('导出为 XLSX') + await expect(visibleItems.nth(5)).toHaveText('退出') + }) + + test('runs history, XLSX export, and unchanged Save As through the Host', async ({ page }) => { + const editor = await openHost(page) + await openFixture(page) + + await openFileMenu(editor) + await editor.locator('.file-menu-save-history').click() + await expect(page.locator('#host-state')).toContainText('history saved (1)', { timeout: 20_000 }) + await expect(page.locator('#file-name')).toContainText('compatibility-basic.xlsx') + + const downloadPromise = page.waitForEvent('download') + await openFileMenu(editor) + await editor.locator('.file-menu-export-xlsx').click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('compatibility-basic.xlsx') + await expect(page.locator('#file-name')).toContainText('compatibility-basic.xlsx') + + page.once('dialog', async (dialog) => { + expect(dialog.type()).toBe('prompt') + await dialog.accept('file-menu-copy.xlsx') + }) + await openFileMenu(editor) + await editor.locator('.file-menu-save-as').click() + await expect(page.locator('#file-name')).toHaveText('file-menu-copy.xlsx', { timeout: 20_000 }) + await expect(page.locator('#host-state')).toContainText('saved') + }) + + test('uses Save / Discard / Cancel for dirty File Exit', async ({ page }) => { + const editor = await openHost(page) + await openFixture(page) + await editFirstCell(page, editor) + + await openFileMenu(editor) + await editor.locator('.file-menu-exit').click() + const dialog = editor.locator('.file-exit-dialog') + await expect(dialog).toBeVisible() + await expect(dialog.locator('button')).toHaveText([ + '取消', + '放弃更改并退出', + '保存并退出', + ]) + + await dialog.getByRole('button', { name: '取消' }).click() + await expect(dialog).toHaveCount(0) + + await openFileMenu(editor) + await editor.locator('.file-menu-exit').click() + await editor.locator('.file-exit-dialog').getByRole('button', { name: '放弃更改并退出' }).click() + await expect(page.locator('#host-state')).toContainText('close requested', { timeout: 10_000 }) + }) + + test('saves dirty workbook before granting File Exit', async ({ page }) => { + const editor = await openHost(page) + await openFixture(page) + await editFirstCell(page, editor) + + await openFileMenu(editor) + await editor.locator('.file-menu-exit').click() + const dialog = editor.locator('.file-exit-dialog') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: '保存并退出' }).click() + + await expect(editor.locator('.ribbon-tabs .qa-btn').first()).toBeDisabled({ timeout: 20_000 }) + await expect(page.locator('#dirty-state')).toHaveText('clean', { timeout: 20_000 }) + await expect(page.locator('#host-state')).toContainText('close requested', { timeout: 20_000 }) + }) + + test('returns the same requestId for a clean Host window close', async ({ page }) => { + await openHost(page) + await installCloseMessageRecorder(page) + + await sendWindowCloseRequest(page, 'close-clean-1') + await expectCloseMessage(page, 'office:close-request', 'close-clean-1', 'window-close') + await expect(page.locator('#host-state')).toContainText('close requested', { timeout: 10_000 }) + }) + + test('cancels one dirty Host close transaction and ignores duplicate requests', async ({ page }) => { + const editor = await openHost(page) + await openFixture(page) + await editFirstCell(page, editor) + await installCloseMessageRecorder(page) + + await sendWindowCloseRequest(page, 'close-dirty-1') + await sendWindowCloseRequest(page, 'close-dirty-duplicate') + + const dialog = editor.locator('.file-exit-dialog') + await expect(dialog).toBeVisible() + await expect(editor.locator('.file-exit-dialog')).toHaveCount(1) + await dialog.getByRole('button', { name: '取消' }).click() + + await expectCloseMessage(page, 'office:close-cancelled', 'close-dirty-1', 'user-cancelled') + await page.waitForTimeout(250) + expect( + await page.evaluate(() => + ( + window as Window & { + __sheetsCloseMessages?: Array<{ requestId?: string }> + } + ).__sheetsCloseMessages?.some( + (message) => message.requestId === 'close-dirty-duplicate', + ) ?? false, + ), + ).toBe(false) + + await sendWindowCloseRequest(page, 'close-dirty-2') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: '放弃更改并退出' }).click() + await expectCloseMessage(page, 'office:close-request', 'close-dirty-2', 'window-close') + }) + + test('saves dirty workbook before granting a correlated Host window close', async ({ page }) => { + const editor = await openHost(page) + await openFixture(page) + await editFirstCell(page, editor) + await installCloseMessageRecorder(page) + + await sendWindowCloseRequest(page, 'close-save-1') + const dialog = editor.locator('.file-exit-dialog') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: '保存并退出' }).click() + + await expect(page.locator('#dirty-state')).toHaveText('clean', { timeout: 20_000 }) + await expectCloseMessage(page, 'office:close-request', 'close-save-1', 'window-close') + }) +}) diff --git a/e2e/sheets-web-image.spec.ts b/e2e/sheets-web-image.spec.ts new file mode 100644 index 000000000..0ea965bbf --- /dev/null +++ b/e2e/sheets-web-image.spec.ts @@ -0,0 +1,276 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const sheetsWebUrl = process.env.SHEETS_WEB_E2E_URL +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL + +// Valid 1x1 PNG; the Host picker and XLSX save path must preserve the exact bytes. +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlZ4xkAAAAASUVORK5CYII=' + +async function createImageWorkbook(path: string): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + Image host +`, + } + + for (const [name, content] of Object.entries(parts)) zip.file(name, content) + await writeFile(path, await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })) +} + +const emptySaveRequest = { + edits: [], + structuralOps: [], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions: [], + pivotAdditions: [], + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + pivotCacheRefreshPaths: [], + pivotRefreshUpdates: [], + sheetProtections: [], + sparklineAdditions: [], + definedNamesState: null, +} + +test.describe('Sheets Web Host file integration', () => { + test.skip(!sheetsWebUrl, 'SHEETS_WEB_E2E_URL is only set by the Sheets Web browser CI step') + + test('opens a workbook selected through the Host token/read-file flow', async ({ page }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-host-open-')) + const workbookPath = join(directory, 'uc-webos-open.xlsx') + await createImageWorkbook(workbookPath) + + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + + const fileChooserPromise = page.waitForEvent('filechooser') + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=uc-webos-open.xlsx'), + ) + const selectedWorkbookPromise = editorFrame.locator('body').evaluate(async () => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.selectWorkbook() + }) + const fileChooser = await fileChooserPromise + await fileChooser.setFiles(workbookPath) + + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + name: string + sheets: Array<{ id: string; name: string }> + } + const selectedWorkbook = await selectedWorkbookPromise + + expect(opened.name).toBe('uc-webos-open.xlsx') + expect(opened.sheets[0]?.name).toBe('Data') + expect(selectedWorkbook.sessionId).toBe(opened.sessionId) + expect(selectedWorkbook.name).toBe('uc-webos-open.xlsx') + expect(selectedWorkbook.sheets[0]?.name).toBe('Data') + }) + + test('picks an image through the Host, inserts it, then moves the saved drawing anchor', async ({ + page, + }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-image-')) + const workbookPath = join(directory, 'web-excel-image.xlsx') + await createImageWorkbook(workbookPath) + + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=web-excel-image.xlsx'), + ) + await page.locator('#xlsx-picker').setInputFiles(workbookPath) + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + sheets: Array<{ id: string; name: string }> + } + const sheetId = opened.sheets[0]!.id + + // Exercise the same office:pick-file bridge that UC/Web OS will implement. + const fileChooserPromise = page.waitForEvent('filechooser') + const imageResultPromise = editorFrame.locator('body').evaluate(async () => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.readLocalImage({ path: 'host-picker://insert-image' }) + }) + const fileChooser = await fileChooserPromise + await fileChooser.setFiles({ + name: 'uc-webos-image.png', + mimeType: 'image/png', + buffer: Buffer.from(PNG_BASE64, 'base64'), + }) + const pickedImage = await imageResultPromise + expect(pickedImage).toEqual({ mediaType: 'image/png', base64: PNG_BASE64 }) + + const inserted = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + ...payload.empty, + sessionId: payload.sessionId, + mode: 'save', + visualAdditions: [ + { + sheetId: payload.sheetId, + anchor: { + fromRow: 1, + fromColumn: 2, + fromRowOffset: 0, + fromColumnOffset: 0, + toRow: 8, + toColumn: 6, + toRowOffset: 0, + toColumnOffset: 0, + }, + image: payload.image, + }, + ], + }) + }, + { + sessionId: opened.sessionId, + sheetId, + image: pickedImage, + empty: emptySaveRequest, + }, + ) + + expect(inserted.canceled).toBe(false) + expect(inserted.touchedEntries).toContain('xl/media/image1.png') + expect(inserted.touchedEntries).toContain('xl/drawings/drawing1.xml') + expect(inserted.file.visuals?.some((visual: any) => visual.kind === 'image')).toBe(true) + + const moved = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + ...payload.empty, + sessionId: payload.sessionId, + mode: 'save', + visualEdits: [ + { + drawingPath: 'xl/drawings/drawing1.xml', + drawingIndex: 0, + anchor: { + fromRow: 3, + fromColumn: 4, + fromRowOffset: 0, + fromColumnOffset: 0, + toRow: 10, + toColumn: 8, + toRowOffset: 0, + toColumnOffset: 0, + }, + }, + ], + }) + }, + { sessionId: inserted.file.sessionId, empty: emptySaveRequest }, + ) + + expect(moved.canceled).toBe(false) + expect(moved.touchedEntries).toContain('xl/drawings/drawing1.xml') + await expect(page.locator('#host-state')).toHaveText('saved', { timeout: 30_000 }) + await expect(page.locator('#download-button')).toBeEnabled() + + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('web-excel-image.xlsx') + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const downloadedZip = await JSZip.loadAsync(await readFile(downloadPath!)) + const media = await downloadedZip.file('xl/media/image1.png')?.async('uint8array') + expect(media).toBeDefined() + expect(Array.from(media!.slice(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10]) + + const worksheetXml = await downloadedZip.file('xl/worksheets/sheet1.xml')?.async('text') + expect(worksheetXml).toContain( + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"', + ) + expect(worksheetXml).toContain('') + + const sheetRels = await downloadedZip + .file('xl/worksheets/_rels/sheet1.xml.rels') + ?.async('text') + expect(sheetRels).toContain('relationships/drawing') + expect(sheetRels).toContain('Target="../drawings/drawing1.xml"') + + const drawingXml = await downloadedZip.file('xl/drawings/drawing1.xml')?.async('text') + expect(drawingXml).toContain('') + expect(drawingXml).toContain('4') + expect(drawingXml).toContain('3') + expect(drawingXml).toContain('8') + expect(drawingXml).toContain('10') + expect(drawingXml).toContain('r:embed="rId1"') + + const drawingRels = await downloadedZip.file('xl/drawings/_rels/drawing1.xml.rels')?.async('text') + expect(drawingRels).toContain('relationships/image') + expect(drawingRels).toContain('Target="../media/image1.png"') + + const contentTypes = await downloadedZip.file('[Content_Types].xml')?.async('text') + expect(contentTypes).toContain('Extension="png"') + expect(contentTypes).toContain('ContentType="image/png"') + expect(contentTypes).toContain('PartName="/xl/drawings/drawing1.xml"') + }) +}) diff --git a/e2e/sheets-web-pivot-add.spec.ts b/e2e/sheets-web-pivot-add.spec.ts new file mode 100644 index 000000000..97dcb534b --- /dev/null +++ b/e2e/sheets-web-pivot-add.spec.ts @@ -0,0 +1,244 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const sheetsWebUrl = process.env.SHEETS_WEB_E2E_URL +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL + +async function createPivotSourceWorkbook(path: string): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + + CategoryValue + A10 + B20 + +`, + 'xl/worksheets/sheet2.xml': ` + + +`, + } + + for (const [name, content] of Object.entries(parts)) zip.file(name, content) + await writeFile(path, await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })) +} + +test.describe('Sheets Web pivot creation', () => { + test.skip(!sheetsWebUrl, 'SHEETS_WEB_E2E_URL is only set by the Sheets Web browser CI step') + + test('creates a populated native pivot package and reads it from the saved session', async ({ page }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-pivot-add-')) + const workbookPath = join(directory, 'web-excel-pivot-add.xlsx') + await createPivotSourceWorkbook(workbookPath) + + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=web-excel-pivot-add.xlsx'), + ) + await page.locator('#xlsx-picker').setInputFiles(workbookPath) + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + sheets: Array<{ id: string; name: string }> + } + expect(opened.sheets.map((sheet) => sheet.name)).toEqual(['Data', 'Pivot']) + const sourceSheetId = opened.sheets.find((sheet) => sheet.name === 'Data')!.id + const pivotSheetId = opened.sheets.find((sheet) => sheet.name === 'Pivot')!.id + + const saved = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + sessionId: payload.sessionId, + mode: 'save', + edits: [], + structuralOps: [], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions: [], + pivotAdditions: [ + { + sheetId: payload.pivotSheetId, + sourceSheetId: payload.sourceSheetId, + sourceArea: { startRow: 0, endRow: 2, startColumn: 0, endColumn: 1 }, + location: { startRow: 0, endRow: 3, startColumn: 5, endColumn: 6 }, + name: 'WebPivot', + fieldNames: ['Category', 'Value'], + rowFieldIndices: [0], + rowItems: ['A', 'B'], + rowLevelItems: [['A', 'B']], + rowLines: [ + { t: 'data', members: [0] }, + { t: 'data', members: [1] }, + ], + values: [{ fieldIndex: 1, agg: 'sum', name: 'Sum of Value' }], + }, + ], + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + pivotCacheRefreshPaths: [], + pivotRefreshUpdates: [], + sheetProtections: [], + sparklineAdditions: [], + definedNamesState: null, + }) + }, + { + sessionId: opened.sessionId, + sourceSheetId, + pivotSheetId, + }, + ) + + expect(saved.canceled).toBe(false) + for (const path of [ + 'xl/pivotTables/pivotTable1.xml', + 'xl/pivotCache/pivotCacheDefinition1.xml', + 'xl/pivotCache/pivotCacheRecords1.xml', + 'xl/workbook.xml', + 'xl/_rels/workbook.xml.rels', + '[Content_Types].xml', + ]) { + expect(saved.touchedEntries).toContain(path) + } + + const definition = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.readPivotDefinition({ + sessionId: payload.sessionId, + path: 'xl/pivotTables/pivotTable1.xml', + cachePath: 'xl/pivotCache/pivotCacheDefinition1.xml', + }) + }, + { sessionId: saved.file.sessionId }, + ) + expect(definition.outputRef).toBe('F1:G4') + expect(definition.sourceSheet).toBe('Data') + expect(definition.sourceRef).toBe('A1:B3') + expect(definition.fields).toEqual([ + { name: 'Category', sharedItems: ['A', 'B'] }, + { name: 'Value', sharedItems: [] }, + ]) + expect(definition.rowFields).toEqual([0]) + expect(definition.dataFields).toEqual([ + { name: 'Sum of Value', field: 1, subtotal: 'sum' }, + ]) + expect(definition.unsupported).toEqual([]) + + await expect(page.locator('#host-state')).toHaveText('saved', { timeout: 30_000 }) + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const downloadedZip = await JSZip.loadAsync(await readFile(downloadPath!)) + const recordsXml = await downloadedZip + .file('xl/pivotCache/pivotCacheRecords1.xml') + ?.async('text') + expect(recordsXml).toContain('count="2"') + expect(recordsXml).toContain('') + expect(recordsXml).toContain('') + + const cacheXml = await downloadedZip + .file('xl/pivotCache/pivotCacheDefinition1.xml') + ?.async('text') + expect(cacheXml).toContain('recordCount="2"') + expect(cacheXml).not.toContain('refreshOnLoad="1"') + expect(cacheXml).toContain('') + expect(cacheXml).toContain( + '', + ) + + const pivotXml = await downloadedZip.file('xl/pivotTables/pivotTable1.xml')?.async('text') + expect(pivotXml).toContain('name="WebPivot" cacheId="1"') + expect(pivotXml).toContain('') + expect(pivotXml).toContain('<\/pivotCaches>/, + ) + const workbookRels = await downloadedZip.file('xl/_rels/workbook.xml.rels')?.async('text') + expect(workbookRels).toContain('pivotCacheDefinition') + expect(workbookRels).toContain('Target="pivotCache/pivotCacheDefinition1.xml"') + + const contentTypes = await downloadedZip.file('[Content_Types].xml')?.async('text') + expect(contentTypes).toContain('PartName="/xl/pivotTables/pivotTable1.xml"') + expect(contentTypes).toContain('pivotCacheDefinition+xml') + expect(contentTypes).toContain('pivotCacheRecords+xml') + }) +}) diff --git a/e2e/sheets-web-pivot-read.spec.ts b/e2e/sheets-web-pivot-read.spec.ts new file mode 100644 index 000000000..a6dd8cc54 --- /dev/null +++ b/e2e/sheets-web-pivot-read.spec.ts @@ -0,0 +1,188 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const sheetsWebUrl = process.env.SHEETS_WEB_E2E_URL +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL + +async function createPivotWorkbook(path: string): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + + CategoryValue + A10 + B20 + +`, + 'xl/pivotTables/pivotTable1.xml': ` + + + + + + + + + +`, + 'xl/pivotCache/pivotCacheDefinition1.xml': ` + + + + + + +`, + } + + for (const [name, content] of Object.entries(parts)) zip.file(name, content) + await writeFile(path, await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })) +} + +test.describe('Sheets Web pivot reads', () => { + test.skip(!sheetsWebUrl, 'SHEETS_WEB_E2E_URL is only set by the Sheets Web browser CI step') + + test('parses pivot definitions and marks the cache for Excel refresh', async ({ page }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-pivot-')) + const workbookPath = join(directory, 'web-excel-pivot-read.xlsx') + await createPivotWorkbook(workbookPath) + + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=web-excel-pivot-read.xlsx'), + ) + await page.locator('#xlsx-picker').setInputFiles(workbookPath) + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + sheets: Array<{ id: string; name: string }> + } + expect(opened.sheets[0]?.name).toBe('Data') + + const definition = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.readPivotDefinition({ + sessionId: payload.sessionId, + path: 'xl/pivotTables/pivotTable1.xml', + cachePath: 'xl/pivotCache/pivotCacheDefinition1.xml', + }) + }, + { sessionId: opened.sessionId }, + ) + + expect(definition.outputRef).toBe('D1:E3') + expect(definition.firstDataRow).toBe(1) + expect(definition.firstDataCol).toBe(1) + expect(definition.sourceSheet).toBe('Data') + expect(definition.sourceRef).toBe('A1:B3') + expect(definition.fields).toEqual([ + { name: 'Category', sharedItems: ['A', 'B'] }, + { name: 'Value', sharedItems: [10, 20] }, + ]) + expect(definition.rowFields).toEqual([0]) + expect(definition.colFields).toEqual([]) + expect(definition.dataFields).toEqual([ + { name: 'Sum of Value', field: 1, subtotal: 'sum' }, + ]) + expect(definition.fieldItems[0]).toEqual([ + { x: 0, hidden: false }, + { x: 1, hidden: false }, + ]) + expect(definition.unsupported).toEqual([]) + + const saved = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + sessionId: payload.sessionId, + mode: 'save', + edits: [], + structuralOps: [], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions: [], + pivotAdditions: [], + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + pivotCacheRefreshPaths: ['xl/pivotCache/pivotCacheDefinition1.xml'], + pivotRefreshUpdates: [], + sheetProtections: [], + sparklineAdditions: [], + definedNamesState: null, + }) + }, + { sessionId: opened.sessionId }, + ) + + expect(saved.canceled).toBe(false) + expect(saved.touchedEntries).toContain('xl/pivotCache/pivotCacheDefinition1.xml') + await expect(page.locator('#host-state')).toHaveText('saved', { timeout: 30_000 }) + await expect(page.locator('#download-button')).toBeEnabled() + + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const downloadedZip = await JSZip.loadAsync(await readFile(downloadPath!)) + const cacheXml = await downloadedZip + .file('xl/pivotCache/pivotCacheDefinition1.xml') + ?.async('text') + expect(cacheXml).toMatch(/]*\brefreshOnLoad="1"/) + expect(cacheXml).toContain('') + }) +}) diff --git a/e2e/sheets-web-pivot-refresh.spec.ts b/e2e/sheets-web-pivot-refresh.spec.ts new file mode 100644 index 000000000..cc5731fa0 --- /dev/null +++ b/e2e/sheets-web-pivot-refresh.spec.ts @@ -0,0 +1,258 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const sheetsWebUrl = process.env.SHEETS_WEB_E2E_URL +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL + +const PIVOT_CACHE_PATH = 'xl/pivotCache/pivotCacheDefinition1.xml' + +async function createPivotRefreshWorkbook(path: string, conflict: boolean): Promise { + const zip = new JSZip() + const conflictCell = conflict ? 'KEEP' : '' + const parts: Record = { + '[Content_Types].xml': ` + + + + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + + + CategoryValue + CategorySum of Value + + A10A10${conflictCell} + B20B20 + Grand Total30 + + +`, + 'xl/worksheets/_rels/sheet1.xml.rels': ` + + +`, + 'xl/pivotTables/pivotTable1.xml': ` + + + + + + +`, + 'xl/pivotTables/_rels/pivotTable1.xml.rels': ` + + +`, + [PIVOT_CACHE_PATH]: ` + + + + + + +`, + 'xl/pivotCache/_rels/pivotCacheDefinition1.xml.rels': ` + + +`, + 'xl/pivotCache/pivotCacheRecords1.xml': ` +`, + } + + for (const [name, content] of Object.entries(parts)) zip.file(name, content) + await writeFile(path, await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })) +} + +function emptySaveRequest(sessionId: string) { + return { + sessionId, + mode: 'save' as const, + edits: [], + structuralOps: [], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions: [], + pivotAdditions: [], + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + sheetProtections: [], + sparklineAdditions: [], + definedNamesState: null, + } +} + +async function openWorkbook(page: import('@playwright/test').Page, workbookPath: string, name: string) { + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes(`/xlsx-engine/v1/workbooks?name=${name}`), + ) + await page.locator('#xlsx-picker').setInputFiles(workbookPath) + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + sheets: Array<{ id: string; name: string }> + } + expect(opened.sheets[0]?.name).toBe('Data') + return { editorFrame, opened } +} + +test.describe('Sheets Web existing Pivot refresh', () => { + test.skip(!sheetsWebUrl, 'SHEETS_WEB_E2E_URL is only set by the Sheets Web browser CI step') + + test('expands an existing Pivot output area and marks its cache refresh-on-load', async ({ page }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-pivot-refresh-')) + const workbookPath = join(directory, 'web-excel-pivot-refresh.xlsx') + await createPivotRefreshWorkbook(workbookPath, false) + + const { editorFrame, opened } = await openWorkbook( + page, + workbookPath, + 'web-excel-pivot-refresh.xlsx', + ) + const sheetId = opened.sheets[0]!.id + const saved = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + ...payload.empty, + pivotCacheRefreshPaths: [payload.cachePath], + pivotRefreshUpdates: [ + { + cachePath: payload.cachePath, + sheetId: payload.sheetId, + newOutputRef: 'F1:H4', + }, + ], + }) + }, + { + empty: emptySaveRequest(opened.sessionId), + cachePath: PIVOT_CACHE_PATH, + sheetId, + }, + ) + + expect(saved.canceled).toBe(false) + expect(saved.touchedEntries).toEqual( + expect.arrayContaining(['xl/pivotTables/pivotTable1.xml', PIVOT_CACHE_PATH]), + ) + await expect(page.locator('#host-state')).toHaveText('saved', { timeout: 30_000 }) + + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const zip = await JSZip.loadAsync(await readFile(downloadPath!)) + const pivotXml = await zip.file('xl/pivotTables/pivotTable1.xml')?.async('text') + const cacheXml = await zip.file(PIVOT_CACHE_PATH)?.async('text') + expect(pivotXml).toMatch(/]*\bref="F1:H4"/) + expect(cacheXml).toMatch(/]*\brefreshOnLoad="1"/) + }) + + test('fails closed when the newly occupied Pivot area contains ordinary worksheet data', async ({ page }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-pivot-conflict-')) + const workbookPath = join(directory, 'web-excel-pivot-conflict.xlsx') + await createPivotRefreshWorkbook(workbookPath, true) + + const { editorFrame, opened } = await openWorkbook( + page, + workbookPath, + 'web-excel-pivot-conflict.xlsx', + ) + const sheetId = opened.sheets[0]!.id + const error = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + try { + await api.saveWorkbookEdits({ + ...payload.empty, + pivotCacheRefreshPaths: [payload.cachePath], + pivotRefreshUpdates: [ + { + cachePath: payload.cachePath, + sheetId: payload.sheetId, + newOutputRef: 'F1:H4', + }, + ], + }) + return null + } catch (cause) { + return cause instanceof Error ? cause.message : String(cause) + } + }, + { + empty: emptySaveRequest(opened.sessionId), + cachePath: PIVOT_CACHE_PATH, + sheetId, + }, + ) + + expect(error).toContain('conflicts with existing worksheet content') + + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const zip = await JSZip.loadAsync(await readFile(downloadPath!)) + const sheetXml = await zip.file('xl/worksheets/sheet1.xml')?.async('text') + const pivotXml = await zip.file('xl/pivotTables/pivotTable1.xml')?.async('text') + const cacheXml = await zip.file(PIVOT_CACHE_PATH)?.async('text') + expect(sheetXml).toContain('KEEP') + expect(pivotXml).toMatch(/]*\bref="F1:G4"/) + expect(cacheXml).not.toMatch(/]*\brefreshOnLoad="1"/) + }) +}) diff --git a/e2e/sheets-web-table-sparkline.spec.ts b/e2e/sheets-web-table-sparkline.spec.ts new file mode 100644 index 000000000..4a9bab829 --- /dev/null +++ b/e2e/sheets-web-table-sparkline.spec.ts @@ -0,0 +1,180 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const sheetsWebUrl = process.env.SHEETS_WEB_E2E_URL +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL + +async function createTableSparklineWorkbook(path: string): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + + NameValue + Alpha10 + Beta20 + +`, + } + + for (const [name, content] of Object.entries(parts)) zip.file(name, content) + await writeFile(path, await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })) +} + +test.describe('Sheets Web table and sparkline saves', () => { + test.skip(!sheetsWebUrl, 'SHEETS_WEB_E2E_URL is only set by the Sheets Web browser CI step') + + test('creates a native table part and x14 sparkline group', async ({ page }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-table-')) + const workbookPath = join(directory, 'web-excel-table-sparkline.xlsx') + await createTableSparklineWorkbook(workbookPath) + + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=web-excel-table-sparkline.xlsx'), + ) + await page.locator('#xlsx-picker').setInputFiles(workbookPath) + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + sheets: Array<{ id: string; name: string }> + } + const sheetId = opened.sheets[0]!.id + expect(opened.sheets[0]?.name).toBe('Data') + + const saved = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + sessionId: payload.sessionId, + mode: 'save', + edits: [], + structuralOps: [], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions: [ + { + sheetId: payload.sheetId, + area: { startRow: 0, endRow: 2, startColumn: 0, endColumn: 1 }, + name: 'WebTable', + columnNames: ['Name', 'Value'], + style: 'TableStyleMedium2', + bandedRows: true, + }, + ], + pivotAdditions: [], + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + pivotCacheRefreshPaths: [], + pivotRefreshUpdates: [], + sheetProtections: [], + sparklineAdditions: [ + { + sheetId: payload.sheetId, + type: 'column', + color: '#336699', + cells: [{ cell: 'D2', sourceRef: 'Data!B2:B3' }], + }, + ], + definedNamesState: null, + }) + }, + { sessionId: opened.sessionId, sheetId }, + ) + + expect(saved.canceled).toBe(false) + expect(saved.touchedEntries).toContain('xl/tables/table1.xml') + expect(saved.touchedEntries).toContain('xl/worksheets/sheet1.xml') + await expect(page.locator('#host-state')).toHaveText('saved', { timeout: 30_000 }) + await expect(page.locator('#download-button')).toBeEnabled() + + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('web-excel-table-sparkline.xlsx') + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const downloadedZip = await JSZip.loadAsync(await readFile(downloadPath!)) + const tableXml = await downloadedZip.file('xl/tables/table1.xml')?.async('text') + expect(tableXml).toContain('name="WebTable"') + expect(tableXml).toContain('displayName="WebTable"') + expect(tableXml).toContain('ref="A1:B3"') + expect(tableXml).toContain('') + expect(tableXml).toContain('') + expect(tableXml).toContain('name="TableStyleMedium2"') + expect(tableXml).toContain('showRowStripes="1"') + + const worksheetXml = await downloadedZip.file('xl/worksheets/sheet1.xml')?.async('text') + expect(worksheetXml).toContain( + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"', + ) + expect(worksheetXml).toContain('') + expect(worksheetXml).toContain( + '', + ) + expect(worksheetXml).toContain('') + expect(worksheetXml).toContain('') + expect(worksheetXml).toContain('Data!B2:B3') + expect(worksheetXml).toContain('D2') + + const sheetRels = await downloadedZip + .file('xl/worksheets/_rels/sheet1.xml.rels') + ?.async('text') + expect(sheetRels).toContain('/relationships/table') + expect(sheetRels).toContain('Target="../tables/table1.xml"') + + const contentTypes = await downloadedZip.file('[Content_Types].xml')?.async('text') + expect(contentTypes).toContain('PartName="/xl/tables/table1.xml"') + expect(contentTypes).toContain('spreadsheetml.table+xml') + }) +}) diff --git a/e2e/sheets-web.spec.ts b/e2e/sheets-web.spec.ts new file mode 100644 index 000000000..f8d9fb68c --- /dev/null +++ b/e2e/sheets-web.spec.ts @@ -0,0 +1,527 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const sheetsWebUrl = process.env.SHEETS_WEB_E2E_URL +const hostUrl = process.env.SHEETS_WEB_HOST_E2E_URL + +async function createMinimalWorkbook(path: string): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + Browser Original42B1*284 +`, + 'xl/worksheets/sheet2.xml': ` + + + Remove this sheet +`, + } + + for (const [name, content] of Object.entries(parts)) zip.file(name, content) + const bytes = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + await writeFile(path, bytes) +} + +async function worksheetContaining(zip: JSZip, needle: string): Promise { + for (const [name, file] of Object.entries(zip.files)) { + if (!/^xl\/worksheets\/[^/]+\.xml$/.test(name) || file.dir) continue + const xml = await file.async('text') + if (xml.includes(needle)) return xml + } + return undefined +} + +test.describe('Sheets Web', () => { + test.skip(!sheetsWebUrl, 'SHEETS_WEB_E2E_URL is only set by the Sheets Web browser CI step') + + test('preserves formulas, worksheet journals, sheet management, and structural edits', async ({ + page, + }) => { + test.skip(!hostUrl, 'SHEETS_WEB_HOST_E2E_URL is required for the iframe host flow') + + const directory = await mkdtemp(join(tmpdir(), 'genoffice-sheets-web-')) + const workbookPath = join(directory, 'web-excel-browser-e2e.xlsx') + await createMinimalWorkbook(workbookPath) + + await page.goto(hostUrl!) + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + await expect(editorFrame.locator('text=GenOffice Sheets Web failed to start')).toHaveCount(0) + + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=web-excel-browser-e2e.xlsx'), + ) + await page.locator('#xlsx-picker').setInputFiles(workbookPath) + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + sheets: Array<{ id: string; name: string; hidden?: boolean }> + } + expect(opened.sessionId).toBeTruthy() + expect(opened.sheets.map((sheet) => sheet.name)).toEqual(['Sheet1', 'RemoveMe']) + + const sheetId = opened.sheets[0]!.id + const removeSheetId = opened.sheets[1]!.id + const initialRange = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.readWorkbookRange({ + sessionId: payload.sessionId, + sheetId: payload.sheetId, + range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 }, + }) + }, + { sessionId: opened.sessionId, sheetId }, + ) + expect(initialRange.cells.find((cell: any) => cell.row === 0 && cell.column === 0)?.value).toBe( + 'Browser Original', + ) + expect(initialRange.cells.find((cell: any) => cell.row === 0 && cell.column === 1)?.value).toBe(42) + + const formulas = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.readWorkbookFormulas({ + sessionId: payload.sessionId, + sheetId: payload.sheetId, + }) + }, + { sessionId: opened.sessionId, sheetId }, + ) + expect(formulas.truncated).toBe(false) + expect(formulas.cells.find((cell: any) => cell.row === 0 && cell.column === 2)?.formula).toBe( + '=B1*2', + ) + + const recalculated = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.recalcWorkbook({ + sessionId: payload.sessionId, + edits: [ + { + sheetId: payload.sheetId, + row: 0, + column: 1, + input: '50', + }, + ], + reads: [ + { + sheetId: payload.sheetId, + range: { startRow: 0, endRow: 0, startColumn: 2, endColumn: 2 }, + }, + ], + }) + }, + { sessionId: opened.sessionId, sheetId }, + ) + const recalculatedFormula = recalculated.cells.find( + (cell: any) => cell.row === 0 && cell.column === 2, + ) + expect(recalculatedFormula?.number).toBe(100) + expect(recalculatedFormula?.isFormula).toBe(true) + + const addedSheetId = 'web-added-sheet' + const copySheetId = 'web-copy-sheet' + const saved = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + sessionId: payload.sessionId, + mode: 'save', + edits: [ + { + sheetId: payload.sheetId, + row: 0, + column: 0, + writeValue: true, + value: 'Browser Saved', + }, + { + sheetId: payload.sheetId, + row: 0, + column: 1, + writeValue: true, + value: 50, + }, + { + sheetId: payload.addedSheetId, + row: 0, + column: 0, + writeValue: true, + value: 'Added Web Sheet', + }, + { + sheetId: payload.copySheetId, + row: 0, + column: 0, + writeValue: true, + value: 'Edited Copy', + }, + ], + structuralOps: [ + { sheetId: payload.copySheetId, kind: 'insert-rows', index: 1, count: 1 }, + { sheetId: payload.copySheetId, kind: 'move-rows', index: 0, count: 1, before: 2 }, + { sheetId: payload.addedSheetId, kind: 'insert-rows', index: 0, count: 3 }, + { sheetId: payload.addedSheetId, kind: 'remove-rows', index: 2, count: 1 }, + { sheetId: payload.addedSheetId, kind: 'insert-cols', index: 0, count: 3 }, + { sheetId: payload.addedSheetId, kind: 'remove-cols', index: 2, count: 1 }, + { sheetId: payload.addedSheetId, kind: 'set-row-size', start: 0, end: 0, size: 24 }, + { sheetId: payload.addedSheetId, kind: 'set-col-size', start: 0, end: 0, size: 18 }, + { + sheetId: payload.addedSheetId, + kind: 'set-rows-hidden', + start: 1, + end: 1, + hidden: true, + }, + { + sheetId: payload.addedSheetId, + kind: 'set-cols-hidden', + start: 1, + end: 1, + hidden: true, + }, + { + sheetId: payload.addedSheetId, + kind: 'set-rows-outline', + start: 0, + end: 1, + level: 1, + collapsed: false, + }, + { + sheetId: payload.addedSheetId, + kind: 'set-cols-outline', + start: 0, + end: 1, + level: 1, + }, + { + sheetId: payload.addedSheetId, + kind: 'merge-cells', + range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 1 }, + }, + { + sheetId: payload.addedSheetId, + kind: 'merge-cells', + range: { startRow: 1, endRow: 1, startColumn: 0, endColumn: 1 }, + }, + { + sheetId: payload.addedSheetId, + kind: 'unmerge-cells', + range: { startRow: 1, endRow: 1, startColumn: 0, endColumn: 1 }, + }, + ], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions: [], + pivotAdditions: [], + sheetOps: [ + { kind: 'rename-sheet', sheetId: payload.sheetId, newName: 'Renamed' }, + { kind: 'add-sheet', sheetId: payload.addedSheetId, name: 'Added' }, + { + kind: 'duplicate-sheet', + sheetId: payload.copySheetId, + name: 'Copy', + sourceSheetId: payload.sheetId, + }, + { kind: 'remove-sheet', sheetId: payload.removeSheetId }, + { kind: 'set-sheet-hidden', sheetId: payload.addedSheetId, hidden: true }, + { kind: 'reorder-sheets' }, + ], + sheetOrder: [payload.copySheetId, payload.sheetId, payload.addedSheetId], + filterStates: [ + { + sheetId: payload.sheetId, + filter: { + range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 }, + columns: [{ colId: 0, values: ['Browser Saved'] }], + }, + hiddenRows: [], + visibilityRange: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 }, + }, + ], + hyperlinkEdits: [ + { + sheetId: payload.sheetId, + row: 0, + column: 0, + target: '#Sheet1!B1', + }, + ], + cfStates: [ + { + sheetId: payload.sheetId, + rules: [ + { + ranges: [{ startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 }], + stopIfTrue: false, + rule: { + type: 'highlightCell', + subType: 'formula', + value: '=B1>0', + style: {}, + }, + }, + ], + }, + ], + dvStates: [ + { + sheetId: payload.sheetId, + rules: [ + { + ranges: [{ startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 }], + rule: { + type: 'whole', + operator: 'between', + formula1: '0', + formula2: '100', + allowBlank: true, + }, + }, + ], + }, + ], + pageSetupStates: [ + { + sheetId: payload.sheetId, + orientation: 'landscape', + printGridlines: true, + }, + ], + noteStates: [ + { + sheetId: payload.sheetId, + notes: [{ row: 0, column: 0, author: 'GenOffice', text: 'Web note' }], + }, + ], + formulaValues: [ + { + sheetId: payload.sheetId, + row: 0, + column: 2, + value: 100, + }, + ], + pivotCacheRefreshPaths: [], + pivotRefreshUpdates: [], + sheetProtections: [{ sheetId: payload.sheetId, protected: true }], + sparklineAdditions: [], + definedNamesState: null, + }) + }, + { + sessionId: opened.sessionId, + sheetId, + removeSheetId, + addedSheetId, + copySheetId, + }, + ) + + expect(saved.canceled).toBe(false) + expect(saved.touchedEntries).toContain('xl/worksheets/sheet1.xml') + expect(saved.file.sheets.map((sheet: any) => sheet.name)).toEqual(['Copy', 'Renamed', 'Added']) + expect(saved.file.sheets.find((sheet: any) => sheet.name === 'Added')?.hidden).toBe(true) + await expect(page.locator('#host-state')).toHaveText('saved', { timeout: 30_000 }) + await expect(page.locator('#file-name')).toHaveText('web-excel-browser-e2e.xlsx') + await expect(page.locator('#download-button')).toBeEnabled() + + const renamedSheetId = saved.file.sheets.find((sheet: any) => sheet.name === 'Renamed')!.id + const copySavedSheetId = saved.file.sheets.find((sheet: any) => sheet.name === 'Copy')!.id + const addedSavedSheetId = saved.file.sheets.find((sheet: any) => sheet.name === 'Added')!.id + + const savedRange = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.readWorkbookRange({ + sessionId: payload.sessionId, + sheetId: payload.sheetId, + range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 }, + }) + }, + { sessionId: saved.file.sessionId, sheetId: renamedSheetId }, + ) + expect(savedRange.cells.find((cell: any) => cell.row === 0 && cell.column === 0)?.value).toBe( + 'Browser Saved', + ) + expect(savedRange.cells.find((cell: any) => cell.row === 0 && cell.column === 1)?.value).toBe(50) + const savedFormula = savedRange.cells.find((cell: any) => cell.row === 0 && cell.column === 2) + expect(savedFormula?.formula).toBe('=B1*2') + expect(savedFormula?.value).toBe(100) + + const copyRange = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.readWorkbookRange({ + sessionId: payload.sessionId, + sheetId: payload.sheetId, + range: { startRow: 0, endRow: 1, startColumn: 0, endColumn: 2 }, + }) + }, + { sessionId: saved.file.sessionId, sheetId: copySavedSheetId }, + ) + expect(copyRange.cells.find((cell: any) => cell.row === 0 && cell.column === 0)?.value).toBe( + 'Edited Copy', + ) + expect(copyRange.cells.find((cell: any) => cell.row === 1 && cell.column === 0)?.value).toBe( + 'Browser Original', + ) + const movedCopyFormula = copyRange.cells.find( + (cell: any) => cell.row === 1 && cell.column === 2, + ) + expect(movedCopyFormula?.formula).toBe('=B2*2') + expect(movedCopyFormula?.value).toBe(84) + + const addedRange = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.readWorkbookRange({ + sessionId: payload.sessionId, + sheetId: payload.sheetId, + range: { startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }, + }) + }, + { sessionId: saved.file.sessionId, sheetId: addedSavedSheetId }, + ) + expect(addedRange.cells.find((cell: any) => cell.row === 0 && cell.column === 0)?.value).toBe( + 'Added Web Sheet', + ) + expect(addedRange.merges).toContainEqual({ + startRow: 0, + endRow: 0, + startColumn: 0, + endColumn: 1, + }) + expect(addedRange.merges).not.toContainEqual({ + startRow: 1, + endRow: 1, + startColumn: 0, + endColumn: 1, + }) + expect(addedRange.rows.find((row: any) => row.row === 0)?.height).toBe(24) + expect(addedRange.rows.find((row: any) => row.row === 0)?.outlineLevel).toBe(1) + expect(addedRange.rows.find((row: any) => row.row === 1)?.hidden).toBe(true) + expect(addedRange.rows.find((row: any) => row.row === 1)?.outlineLevel).toBe(1) + + const downloadPromise = page.waitForEvent('download') + await page.locator('#download-button').click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('web-excel-browser-e2e.xlsx') + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const downloadedZip = await JSZip.loadAsync(await readFile(downloadPath!)) + const worksheetXml = await downloadedZip.file('xl/worksheets/sheet1.xml')?.async('text') + expect(worksheetXml).toContain('Browser Saved') + expect(worksheetXml).toContain('B1*2') + expect(worksheetXml).toMatch(/]*>B1\*2<\/f>100<\/v><\/c>/) + expect(worksheetXml).toContain('') + expect(worksheetXml).toContain('') + expect(worksheetXml).toContain('') + expect(worksheetXml).toContain('') + expect(worksheetXml).toContain('B1>0') + expect(worksheetXml).toContain('') + expect(worksheetXml).toMatch( + /]*\btype="whole"[^>]*\ballowBlank="1"[^>]*\bsqref="B1"/, + ) + expect(worksheetXml).toContain('0') + expect(worksheetXml).toContain('100') + expect(worksheetXml).toMatch(/]*\borientation="landscape"/) + expect(worksheetXml).toMatch(/]*\bgridLines="1"/) + expect(worksheetXml).toContain( + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"', + ) + expect(worksheetXml).toContain('B2*2') + + const addedWorksheetXml = await worksheetContaining(downloadedZip, 'Added Web Sheet') + expect(addedWorksheetXml).toMatch(/]*\br="1"[^>]*\bht="24"/) + expect(addedWorksheetXml).toMatch(/]*\br="1"[^>]*\boutlineLevel="1"/) + expect(addedWorksheetXml).toMatch(/]*\br="2"[^>]*\bhidden="1"/) + expect(addedWorksheetXml).toMatch(/]*\br="2"[^>]*\boutlineLevel="1"/) + expect(addedWorksheetXml).toMatch(/]*\bmin="1"[^>]*\bmax="1"[^>]*\bwidth="18"/) + expect(addedWorksheetXml).toMatch(/]*\bmin="2"[^>]*\bmax="2"[^>]*\bhidden="1"/) + expect(addedWorksheetXml).toContain('') + expect(addedWorksheetXml).not.toContain('') + expect(addedWorksheetXml).toMatch(/]*\boutlineLevelRow="1"/) + expect(addedWorksheetXml).toMatch(/]*\boutlineLevelCol="1"/) + + const commentsXml = await downloadedZip.file('xl/comments1.xml')?.async('text') + expect(commentsXml).toContain('GenOffice') + expect(commentsXml).toContain('') + expect(commentsXml).toContain('Web note') + expect(downloadedZip.file('xl/drawings/vmlDrawing1.vml')).not.toBeNull() + + const worksheetRels = await downloadedZip + .file('xl/worksheets/_rels/sheet1.xml.rels') + ?.async('text') + expect(worksheetRels).toContain('/relationships/comments') + expect(worksheetRels).toContain('/relationships/vmlDrawing') + + expect(downloadedZip.file('xl/worksheets/sheet2.xml')).toBeNull() + const workbookXml = await downloadedZip.file('xl/workbook.xml')?.async('text') + expect(workbookXml).toMatch( + /\s*]*name="Copy"[\s\S]*?]*name="Renamed"[\s\S]*?]*name="Added"[^>]*\/>\s*<\/sheets>/, + ) + expect(workbookXml).toMatch( + /]*name="Added")(?=[^>]*state="hidden")[^>]*\/>/, + ) + expect(workbookXml).not.toContain('name="RemoveMe"') + + const workbookRels = await downloadedZip.file('xl/_rels/workbook.xml.rels')?.async('text') + expect(workbookRels).not.toContain('Target="worksheets/sheet2.xml"') + + const contentTypes = await downloadedZip.file('[Content_Types].xml')?.async('text') + expect(contentTypes).toContain('spreadsheetml.comments+xml') + expect(contentTypes).toContain('Extension="vml"') + expect(contentTypes).not.toContain('PartName="/xl/worksheets/sheet2.xml"') + }) +}) diff --git a/e2e/uc-webos-xlsx-host-shortcuts.spec.ts b/e2e/uc-webos-xlsx-host-shortcuts.spec.ts new file mode 100644 index 000000000..2cd5e023e --- /dev/null +++ b/e2e/uc-webos-xlsx-host-shortcuts.spec.ts @@ -0,0 +1,229 @@ +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const hostUrl = process.env.UC_WEBOS_XLSX_HOST_E2E_URL +const sheetsUrl = process.env.SHEETS_WEB_E2E_URL + +async function createWorkbookBase64(): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + Clean Save As +`, + } + for (const [path, content] of Object.entries(parts)) zip.file(path, content) + return Buffer.from(await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' })).toString( + 'base64', + ) +} + +test.describe('UC Web OS XLSX Host browser shortcuts', () => { + test.skip(!hostUrl || !sheetsUrl, 'UC Host and Sheets Web preview URLs are required') + + test('Ctrl+Shift+S saves an unchanged workbook as a result file', async ({ page }) => { + const workbookBase64 = await createWorkbookBase64() + + await page.addInitScript( + ({ sourceBase64 }) => { + const decodeBase64 = (input: string): Uint8Array => { + const binary = atob(input) + return Uint8Array.from(binary, (character) => character.charCodeAt(0)) + } + const encodeBase64 = (bytes: Uint8Array): string => { + let binary = '' + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)) + } + return btoa(binary) + } + const state = { + calls: [] as Array<{ method: string; writeMode?: string; filename?: string }>, + saves: [] as Array<{ filename: string; base64: string }>, + lastWriteMode: 'selected' as 'selected' | 'result', + } + ;(window as typeof window & { __ucShortcutMock?: typeof state }).__ucShortcutMock = state + + window.addEventListener('message', (event) => { + const message = event.data as any + if (event.source !== window || message?.type !== 'uc-plugin-rpc-request') return + + void (async () => { + try { + const params = message.params || {} + state.calls.push({ + method: message.method, + ...(params.writeMode ? { writeMode: params.writeMode } : {}), + ...(params.filename ? { filename: params.filename } : {}), + }) + + let result: unknown + switch (message.method) { + case 'uc.ready': + result = { ok: true } + break + case 'uc.host.getLaunchParams': + result = { + launchParams: { + fileName: 'clean-source.xlsx', + nodeId: 'node-source', + mode: 'edit', + locale: 'zh-CN', + file: { nodeId: 'node-source', name: 'clean-source.xlsx' }, + }, + } + break + case 'uc.fs.requestSelectedFileAccess': + state.lastWriteMode = params.writeMode + result = + params.writeMode === 'result' + ? { + nodeId: 'node-source', + resultNodeId: 'node-copy', + filename: params.filename, + version: 'copy-v1', + } + : { + nodeId: 'node-source', + filename: params.filename, + version: 'source-v1', + } + break + case 'uc.fs.readSelectedFile': + result = { + blob: new Blob([decodeBase64(sourceBase64)], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + filename: 'clean-source.xlsx', + contentType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + } + break + case 'uc.fs.pickSaveDestination': + result = { cancelled: false, filename: 'shortcut-copy.xlsx' } + break + case 'uc.fs.saveResultFile': { + if (state.lastWriteMode !== 'result') { + throw new Error('Shortcut Save As must use result mode.') + } + const bytes = new Uint8Array(await (params.blob as Blob).arrayBuffer()) + state.saves.push({ filename: params.filename, base64: encodeBase64(bytes) }) + result = { + nodeId: 'node-copy', + filename: params.filename, + version: 'copy-v1', + } + break + } + default: + throw new Error(`Unexpected UC RPC: ${message.method}`) + } + + window.postMessage( + { + type: 'uc-plugin-rpc-response', + id: message.id, + pluginId: message.pluginId, + result, + }, + window.location.origin, + ) + } catch (error) { + window.postMessage( + { + type: 'uc-plugin-rpc-response', + id: message.id, + pluginId: message.pluginId, + error: { message: error instanceof Error ? error.message : String(error) }, + }, + window.location.origin, + ) + } + })() + }) + }, + { sourceBase64: workbookBase64 }, + ) + + const hostOrigin = new URL(hostUrl!).origin + const openResponse = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=clean-source.xlsx'), + ) + await page.goto( + `${hostUrl}?ucHostOrigin=${encodeURIComponent(hostOrigin)}&sheetsUrl=${encodeURIComponent( + sheetsUrl!, + )}&pluginId=thirdparty.plugin.excel-online`, + ) + await openResponse + + const editorFrame = page.frameLocator('#office-frame') + const canvas = editorFrame.locator('canvas').first() + await expect(canvas).toBeVisible({ timeout: 30_000 }) + await canvas.click() + await page.keyboard.press('Control+Shift+S') + + await expect + .poll( + () => + page.evaluate( + () => + (window as typeof window & { __ucShortcutMock: any }).__ucShortcutMock.saves.length, + ), + { timeout: 30_000 }, + ) + .toBe(1) + + const state = await page.evaluate(() => { + const value = (window as typeof window & { __ucShortcutMock: any }).__ucShortcutMock + return { calls: value.calls, saves: value.saves } + }) + expect(state.calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ method: 'uc.fs.pickSaveDestination' }), + expect.objectContaining({ + method: 'uc.fs.requestSelectedFileAccess', + writeMode: 'result', + filename: 'shortcut-copy.xlsx', + }), + ]), + ) + expect(state.saves[0]?.filename).toBe('shortcut-copy.xlsx') + + const zip = await JSZip.loadAsync(Buffer.from(state.saves[0].base64, 'base64')) + const sheetXml = await zip.file('xl/worksheets/sheet1.xml')?.async('text') + expect(sheetXml).toContain('Clean Save As') + }) +}) diff --git a/e2e/uc-webos-xlsx-host.spec.ts b/e2e/uc-webos-xlsx-host.spec.ts new file mode 100644 index 000000000..c69c9dfe8 --- /dev/null +++ b/e2e/uc-webos-xlsx-host.spec.ts @@ -0,0 +1,381 @@ +import { expect, test } from '@playwright/test' +import JSZip from 'jszip' + +const hostUrl = process.env.UC_WEBOS_XLSX_HOST_E2E_URL +const sheetsUrl = process.env.SHEETS_WEB_E2E_URL + +async function createWorkbookBase64(): Promise { + const zip = new JSZip() + const parts: Record = { + '[Content_Types].xml': ` + + + + + + +`, + '_rels/.rels': ` + + +`, + 'xl/workbook.xml': ` + + +`, + 'xl/_rels/workbook.xml.rels': ` + + + +`, + 'xl/styles.xml': ` + + + + + + + +`, + 'xl/worksheets/sheet1.xml': ` + + + UC Source42 +`, + } + for (const [path, content] of Object.entries(parts)) zip.file(path, content) + return Buffer.from(await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' })).toString( + 'base64', + ) +} + +const emptySaveRequest = { + structuralOps: [], + chartEdits: [], + visualEdits: [], + visualAdditions: [], + tableAdditions: [], + pivotAdditions: [], + sheetOps: [], + sheetOrder: [], + filterStates: [], + hyperlinkEdits: [], + cfStates: [], + dvStates: [], + pageSetupStates: [], + noteStates: [], + formulaValues: [], + pivotCacheRefreshPaths: [], + pivotRefreshUpdates: [], + sheetProtections: [], + sparklineAdditions: [], + definedNamesState: null, +} + +test.describe('UC Web OS XLSX Host', () => { + test.skip(!hostUrl || !sheetsUrl, 'UC Host and Sheets Web preview URLs are required') + + test('opens, rejects stale normal save, and saves selected/result files', async ({ page }) => { + const workbookBase64 = await createWorkbookBase64() + + await page.addInitScript( + ({ sourceBase64 }) => { + const decodeBase64 = (input: string): Uint8Array => { + const binary = atob(input) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes + } + const encodeBase64 = (bytes: Uint8Array): string => { + let binary = '' + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)) + } + return btoa(binary) + } + + const state = { + calls: [] as Array<{ method: string; writeMode?: string; filename?: string }>, + saves: [] as Array<{ + filename: string + writeMode: string + nodeId: string + base64: string + }>, + lastWriteMode: 'selected' as 'selected' | 'result', + selectedVersion: 'source-v1', + } + ;(window as typeof window & { __ucMock?: typeof state }).__ucMock = state + + window.addEventListener('message', (event) => { + const message = event.data as any + if (event.source !== window || message?.type !== 'uc-plugin-rpc-request') return + + void (async () => { + try { + const params = message.params || {} + state.calls.push({ + method: message.method, + ...(params.writeMode ? { writeMode: params.writeMode } : {}), + ...(params.filename ? { filename: params.filename } : {}), + }) + + let result: unknown + switch (message.method) { + case 'uc.ready': + result = { ok: true } + break + case 'uc.host.getLaunchParams': + result = { + launchParams: { + fileName: 'uc-source.xlsx', + nodeId: 'node-source', + mode: 'edit', + locale: 'zh-CN', + file: { nodeId: 'node-source', name: 'uc-source.xlsx' }, + }, + } + break + case 'uc.fs.requestSelectedFileAccess': + state.lastWriteMode = params.writeMode + result = + params.writeMode === 'result' + ? { + nodeId: 'node-source', + resultNodeId: 'node-copy', + filename: params.filename, + version: 'copy-v1', + } + : { + nodeId: 'node-source', + filename: params.filename, + version: state.selectedVersion, + } + break + case 'uc.fs.readSelectedFile': + result = { + blob: new Blob([decodeBase64(sourceBase64)], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + filename: 'uc-source.xlsx', + contentType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + } + break + case 'uc.fs.pickSaveDestination': + result = { + cancelled: false, + filename: 'uc-copy.xlsx', + folderName: 'Documents', + } + break + case 'uc.fs.saveResultFile': { + const blob = params.blob as Blob + const bytes = new Uint8Array(await blob.arrayBuffer()) + const nodeId = state.lastWriteMode === 'result' ? 'node-copy' : 'node-source' + state.saves.push({ + filename: params.filename, + writeMode: state.lastWriteMode, + nodeId, + base64: encodeBase64(bytes), + }) + if (state.lastWriteMode === 'selected') state.selectedVersion = 'source-v2' + result = { + nodeId, + filename: params.filename, + version: state.lastWriteMode === 'result' ? 'copy-v1' : state.selectedVersion, + } + break + } + default: + throw new Error(`Unexpected UC RPC: ${message.method}`) + } + + window.postMessage( + { + type: 'uc-plugin-rpc-response', + id: message.id, + pluginId: message.pluginId, + result, + }, + window.location.origin, + ) + } catch (error) { + window.postMessage( + { + type: 'uc-plugin-rpc-response', + id: message.id, + pluginId: message.pluginId, + error: { + message: error instanceof Error ? error.message : String(error), + }, + }, + window.location.origin, + ) + } + })() + }) + }, + { sourceBase64: workbookBase64 }, + ) + + const hostOrigin = new URL(hostUrl!).origin + const openResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/xlsx-engine/v1/workbooks?name=uc-source.xlsx'), + ) + await page.goto( + `${hostUrl}?ucHostOrigin=${encodeURIComponent(hostOrigin)}&sheetsUrl=${encodeURIComponent( + sheetsUrl!, + )}&pluginId=thirdparty.plugin.excel-online`, + ) + + const opened = (await (await openResponsePromise).json()) as { + sessionId: string + name: string + sheets: Array<{ id: string; name: string }> + } + expect(opened.name).toBe('uc-source.xlsx') + expect(opened.sheets[0]?.name).toBe('Data') + + const editorFrame = page.frameLocator('#office-frame') + await expect(editorFrame.locator('canvas').first()).toBeVisible({ timeout: 30_000 }) + const sheetId = opened.sheets[0]!.id + + const firstSave = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + ...payload.empty, + sessionId: payload.sessionId, + mode: 'save', + edits: [ + { + sheetId: payload.sheetId, + row: 0, + column: 0, + writeValue: true, + value: 'UC Saved', + }, + ], + }) + }, + { + sessionId: opened.sessionId, + sheetId, + empty: emptySaveRequest, + }, + ) + expect(firstSave.canceled).toBe(false) + + const firstMock = await page.evaluate(() => { + const state = (window as typeof window & { __ucMock: any }).__ucMock + return { calls: state.calls, saves: state.saves, selectedVersion: state.selectedVersion } + }) + expect(firstMock.calls.map((call: any) => call.method)).toEqual( + expect.arrayContaining([ + 'uc.ready', + 'uc.host.getLaunchParams', + 'uc.fs.requestSelectedFileAccess', + 'uc.fs.readSelectedFile', + 'uc.fs.saveResultFile', + ]), + ) + expect(firstMock.saves).toHaveLength(1) + expect(firstMock.saves[0]).toMatchObject({ + filename: 'uc-source.xlsx', + writeMode: 'selected', + nodeId: 'node-source', + }) + expect(firstMock.selectedVersion).toBe('source-v2') + + const firstSavedZip = await JSZip.loadAsync(Buffer.from(firstMock.saves[0].base64, 'base64')) + const firstSheetXml = await firstSavedZip.file('xl/worksheets/sheet1.xml')?.async('text') + expect(firstSheetXml).toContain('UC Saved') + + await page.evaluate(() => { + ;(window as typeof window & { __ucMock: any }).__ucMock.selectedVersion = 'source-v3' + }) + + const staleSaveError = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + try { + await api.saveWorkbookEdits({ + ...payload.empty, + sessionId: payload.sessionId, + mode: 'save', + edits: [ + { + sheetId: payload.sheetId, + row: 0, + column: 1, + writeValue: true, + value: 'Must Not Overwrite', + }, + ], + }) + return null + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + }, + { + sessionId: firstSave.file.sessionId, + sheetId, + empty: emptySaveRequest, + }, + ) + expect(staleSaveError).toContain('VERSION_CONFLICT') + + const conflictMock = await page.evaluate(() => { + const state = (window as typeof window & { __ucMock: any }).__ucMock + return { calls: state.calls, saves: state.saves } + }) + expect(conflictMock.saves).toHaveLength(1) + expect(conflictMock.calls.filter((call: any) => call.method === 'uc.fs.saveResultFile')).toHaveLength(1) + + const saveAs = await editorFrame.locator('body').evaluate( + async (_body, payload) => { + const api = (window as typeof window & { desktopApi: any }).desktopApi + return api.saveWorkbookEdits({ + ...payload.empty, + sessionId: payload.sessionId, + mode: 'save-as', + edits: [], + }) + }, + { sessionId: firstSave.file.sessionId, empty: emptySaveRequest }, + ) + expect(saveAs.canceled).toBe(false) + + const finalMock = await page.evaluate(() => { + const state = (window as typeof window & { __ucMock: any }).__ucMock + return { calls: state.calls, saves: state.saves } + }) + expect(finalMock.saves).toHaveLength(2) + expect(finalMock.saves[1]).toMatchObject({ + filename: 'uc-copy.xlsx', + writeMode: 'result', + nodeId: 'node-copy', + }) + expect(finalMock.calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ method: 'uc.fs.pickSaveDestination' }), + expect.objectContaining({ + method: 'uc.fs.requestSelectedFileAccess', + writeMode: 'result', + filename: 'uc-copy.xlsx', + }), + ]), + ) + + const copiedZip = await JSZip.loadAsync(Buffer.from(finalMock.saves[1].base64, 'base64')) + const copiedSheetXml = await copiedZip.file('xl/worksheets/sheet1.xml')?.async('text') + expect(copiedSheetXml).toContain('UC Saved') + expect(copiedSheetXml).not.toContain('Must Not Overwrite') + }) +}) diff --git a/examples/uc-webos-xlsx-host/README.md b/examples/uc-webos-xlsx-host/README.md new file mode 100644 index 000000000..5275a6fff --- /dev/null +++ b/examples/uc-webos-xlsx-host/README.md @@ -0,0 +1,90 @@ +# UC Web OS XLSX Host + +Reference host for embedding GenOffice Sheets Web inside the existing UC Web OS plugin iframe. + +## Topology + +```text +UC Web OS +└─ UC Excel plugin iframe (this example) + └─ GenOffice Sheets iframe + └─ /xlsx-engine/* -> Rust XLSX Engine +``` + +The UC plugin remains the platform boundary. GenOffice Sheets never receives UC tenant IDs, JWTs, FsNode permissions or storage APIs directly. + +## UC RPC used + +The host talks to its parent with the existing UC plugin RPC envelope: + +```text +uc-plugin-rpc-request +uc-plugin-rpc-response +``` + +The implementation uses the currently established file APIs: + +- `uc.ready` +- `uc.host.getLaunchParams` +- `uc.fs.requestSelectedFileAccess` +- `uc.fs.readSelectedFile` +- `uc.fs.pickSaveDestination` +- `uc.fs.saveResultFile` + +Normal save requests `writeMode: 'selected'`. Save As first chooses a destination, then requests `writeMode: 'result'`. A successful Save As must return a new `nodeId`/`id`; otherwise the host rejects the result so later Ctrl+S cannot accidentally write back to the original file. + +### Optimistic version protection + +The file version returned when the workbook is opened is carried into GenOffice as the editor's `baseVersion`. Before a normal Save, the UC Host requests fresh selected-file access and compares the latest `version`/`fileVersion` with that base version. + +- same version → `uc.fs.saveResultFile` may run; +- different version → the Host returns `VERSION_CONFLICT` **before** `saveResultFile` is called; +- missing version on either side → backward-compatible save behavior is retained; +- Save As is exempt from the original file's version comparison because it creates a result file instead of overwriting the selected file. + +After a successful save, the version returned by UC becomes the next `baseVersion`. This prevents a later Ctrl+S from silently overwriting changes made by another editor/session. + +## GenOffice protocol + +The nested editor uses only the shared `office:*` iframe protocol: + +- `office:init` +- `office:pick-file` +- `office:read-file` +- `office:save-document` +- dirty/title/state events + +The loaded UC `blob` is converted to an `ArrayBuffer` and transferred to Sheets through `office:init`. Save bytes travel in the opposite direction and are wrapped in an XLSX Blob for `uc.fs.saveResultFile`. + +## Embedded URL + +The plugin accepts: + +- `sheetsUrl` — Sheets Web URL, default `http://127.0.0.1:5275` +- `ucHostOrigin` — UC Web OS parent origin; when omitted, the plugin tries `document.referrer` +- `pluginId` — UC plugin ID, default `thirdparty.plugin.excel-online` +- `locale` — fallback locale, default `zh-CN` + +Example: + +```text +http://127.0.0.1:8083/?sheetsUrl=http://127.0.0.1:5275&ucHostOrigin=http://127.0.0.1:5173&pluginId=thirdparty.plugin.excel-online +``` + +## Current picker boundary + +The confirmed UC plugin contract used by the existing Office host does not yet provide a confirmed interactive open-file picker RPC for choosing an arbitrary second file from inside Office. Therefore `office:pick-file` is deliberately isolated in `openLocalAssetPicker()` and currently uses a browser file input, matching the existing UC Office-plugin fallback. + +This does **not** affect opening the workbook that launched the plugin, normal save, or Save As — those already use UC file APIs. Once the final UC interactive picker API is fixed, replace only `openLocalAssetPicker()`; the Sheets editor and `office:*` protocol do not change. + +## Development + +```bash +npm run dev:uc-webos-xlsx-host +``` + +Build: + +```bash +npm run build:uc-webos-xlsx-host +``` diff --git a/examples/uc-webos-xlsx-host/index.html b/examples/uc-webos-xlsx-host/index.html new file mode 100644 index 000000000..d1bfac73e --- /dev/null +++ b/examples/uc-webos-xlsx-host/index.html @@ -0,0 +1,46 @@ + + + + + + GenOffice Excel - UC Web OS Host + + + + +
+ + + diff --git a/examples/uc-webos-xlsx-host/src/main.ts b/examples/uc-webos-xlsx-host/src/main.ts new file mode 100644 index 000000000..2a9c53914 --- /dev/null +++ b/examples/uc-webos-xlsx-host/src/main.ts @@ -0,0 +1,592 @@ +import type { + OfficeEditorMode, + OfficeFile, + OfficeFileDescriptor, + SelectedOfficeFile, +} from '@genoffice/office-host-api' +import { + OFFICE_PROTOCOL_VERSION, + isOfficeProtocolMessage, + type EditorToHostMessage, + type HostToEditorMessage, +} from '@genoffice/office-protocol' + +import { detectSelectedFileVersionConflict } from './versioning' + +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +const DEFAULT_SHEETS_URL = 'http://127.0.0.1:5275' +const DEFAULT_PLUGIN_ID = 'thirdparty.plugin.excel-online' + +interface UcRpcResponse { + type: 'uc-plugin-rpc-response' + id: string + pluginId: string + result?: unknown + data?: unknown + payload?: unknown + error?: unknown +} + +interface UcFileAccess { + nodeId?: string + id?: string + resultNodeId?: string + filename?: string + version?: string | number | null + fileVersion?: string | number | null + writeMode?: string + [key: string]: unknown +} + +interface PendingRpc { + resolve(value: unknown): void + reject(error: Error): void + timer: number +} + +function requireElement(id: string): T { + const element = document.getElementById(id) + if (!element) throw new Error(`Missing #${id}`) + return element as T +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' ? (value as Record) : {} +} + +function stringValue(value: unknown): string | null { + if (value === undefined || value === null) return null + const text = String(value).trim() + return text || null +} + +function normalizeXlsxName(value: string): string { + const cleaned = value + .replace(/[\\/:*?"<>|\u0000-\u001f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!cleaned) throw new Error('文件名不能为空。') + return /\.xlsx$/i.test(cleaned) ? cleaned : `${cleaned}.xlsx` +} + +function referrerOrigin(): string | null { + if (!document.referrer) return null + try { + return new URL(document.referrer).origin + } catch { + return null + } +} + +const frame = requireElement('office-frame') +const errorPanel = requireElement('host-error') +const query = new URLSearchParams(window.location.search) +const sheetsUrl = query.get('sheetsUrl') || DEFAULT_SHEETS_URL +const sheetsOrigin = new URL(sheetsUrl).origin +const pluginId = query.get('pluginId') || DEFAULT_PLUGIN_ID +const ucHostOrigin = (() => { + const origin = query.get('ucHostOrigin') || referrerOrigin() + if (!origin) { + throw new Error('UC Host origin is required. Use ?ucHostOrigin=https://webos.example.com.') + } + return new URL(origin).origin +})() + +let ucSeq = 0 +let officeSeq = 0 +let editorReady = false +let currentMode: OfficeEditorMode = 'edit' +let currentLocale = query.get('locale') || 'zh-CN' +let currentFile: OfficeFile | null = null +let currentAccess: UcFileAccess | null = null +const pendingRpc = new Map() +const localAssets = new Map() + +function showError(error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + errorPanel.textContent = message + errorPanel.style.display = 'block' + console.error('[UC GenOffice Excel Host]', error) +} + +function officeRequestId(prefix: string): string { + officeSeq += 1 + return `${prefix}-${Date.now()}-${officeSeq}` +} + +function ucRequestId(): string { + ucSeq += 1 + return `uc-${Date.now()}-${ucSeq}` +} + +function ucResult(message: UcRpcResponse): unknown { + if (message.result !== undefined) return message.result + if (message.data !== undefined) return message.data + if (message.payload !== undefined) return message.payload + return message +} + +function rpcError(error: unknown): Error { + if (error instanceof Error) return error + const value = asRecord(error) + const message = stringValue(value.message) || stringValue(value.error) || JSON.stringify(error) + return new Error(message || 'UC RPC failed.') +} + +function ucCall(method: string, params?: unknown, timeoutMs = 30_000): Promise { + const id = ucRequestId() + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + pendingRpc.delete(id) + reject(new Error(`UC RPC timed out: ${method}`)) + }, timeoutMs) + pendingRpc.set(id, { resolve, reject, timer }) + window.parent.postMessage( + { + type: 'uc-plugin-rpc-request', + id, + pluginId, + method, + params, + }, + ucHostOrigin, + ) + }) +} + +function sendOffice(message: HostToEditorMessage, transfer: Transferable[] = []): void { + const target = frame.contentWindow + if (!target) throw new Error('GenOffice Sheets iframe is not available.') + target.postMessage(message, sheetsOrigin, transfer) +} + +function officeCapabilities() { + return { + ai: false, + open: false, + save: currentMode === 'edit', + saveAs: currentMode === 'edit', + saveHistoryVersion: false, + exportDocx: false, + exportPptx: false, + exportXlsx: true, + close: true, + autoSave: 'host' as const, + download: false, + print: true, + systemFilePicker: true, + pageCropMarks: false, + } +} + +function sendOfficeInit(): void { + if (!editorReady || !currentFile) return + const bytes = currentFile.bytes.slice(0) + sendOffice( + { + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:init', + requestId: officeRequestId('init'), + payload: { + kind: 'xlsx', + mode: currentMode, + locale: currentLocale, + capabilities: officeCapabilities(), + file: { ...currentFile, bytes }, + }, + }, + [bytes], + ) +} + +function unwrapLaunchParams(value: unknown): Record { + const root = asRecord(value) + return asRecord(root.launchParams || root.params || root.context || root) +} + +async function requestSelectedFileAccess( + writeMode: 'selected' | 'result', + filename: string, +): Promise { + const result = await ucCall( + 'uc.fs.requestSelectedFileAccess', + { + writeMode, + filename, + state: `excel-${writeMode}-${Date.now()}`, + }, + 30_000, + ) + if (!result || typeof result !== 'object') { + throw new Error('宿主未返回有效的文件访问授权。') + } + return result as UcFileAccess +} + +async function readSelectedFile( + access: UcFileAccess, + fallbackName: string, + fallbackId: string, +): Promise { + const result = asRecord(await ucCall('uc.fs.readSelectedFile', undefined, 120_000)) + const blob = result.blob + if (!(blob instanceof Blob)) throw new Error('宿主未返回可读取的 Excel 文件 blob。') + + const name = normalizeXlsxName(stringValue(result.filename) || stringValue(access.filename) || fallbackName) + const id = + stringValue(access.nodeId) || stringValue(access.id) || fallbackId || `uc-xlsx-${Date.now()}` + const version = stringValue(access.version) || stringValue(access.fileVersion) || null + + return { + id, + name, + mimeType: stringValue(result.contentType) || XLSX_MIME, + size: blob.size, + version, + bytes: await blob.arrayBuffer(), + } +} + +async function initializeFromUc(): Promise { + await ucCall('uc.ready', undefined, 30_000) + const launch = unwrapLaunchParams(await ucCall('uc.host.getLaunchParams', undefined, 30_000)) + const launchFile = asRecord(launch.file || launch.selectedFile || launch.node || {}) + const filename = normalizeXlsxName( + stringValue(launch.fileName) || + stringValue(launchFile.name) || + stringValue(launchFile.filename) || + query.get('fileName') || + 'workbook.xlsx', + ) + const nodeId = + stringValue(launch.nodeId) || stringValue(launchFile.nodeId) || stringValue(launchFile.id) + if (!nodeId) throw new Error('UC 启动参数缺少 nodeId,无法安全打开工作簿。') + + if (launch.mode === 'view' || launchFile.mode === 'view') currentMode = 'view' + const launchLocale = stringValue(launch.locale) || stringValue(launchFile.locale) + if (launchLocale) currentLocale = launchLocale + + currentAccess = await requestSelectedFileAccess('selected', filename) + currentFile = await readSelectedFile(currentAccess, filename, nodeId) + sendOfficeInit() +} + +async function pickSaveDestination(suggestedName: string): Promise<{ filename: string } | null> { + const response = asRecord( + await ucCall( + 'uc.fs.pickSaveDestination', + { + title: '另存为 Excel 工作簿', + confirmText: '保存', + suggestedName, + fileTypes: [ + { + id: 'xlsx', + label: 'Excel 工作簿', + extension: '.xlsx', + mimeType: XLSX_MIME, + }, + ], + activeFileTypeId: 'xlsx', + allowFileTypeChange: false, + }, + 300_000, + ), + ) + if (response.cancelled) return null + return { filename: normalizeXlsxName(stringValue(response.filename) || suggestedName) } +} + +function saveResponseDescriptor( + response: unknown, + access: UcFileAccess, + filename: string, + fallback: OfficeFile | null, +): OfficeFileDescriptor { + const root = asRecord(response) + const candidate = asRecord(root.file || root.node || root.result || root.savedFile || root) + const id = + stringValue(candidate.nodeId) || + stringValue(candidate.id) || + stringValue(root.nodeId) || + stringValue(root.id) || + stringValue(access.resultNodeId) || + stringValue(access.nodeId) || + fallback?.id || + '' + const version = + stringValue(candidate.version) || + stringValue(candidate.fileVersion) || + stringValue(root.version) || + stringValue(root.fileVersion) || + stringValue(access.version) || + fallback?.version || + null + + if (!id) throw new Error('保存成功,但宿主没有返回 nodeId/id。') + return { + id, + name: filename, + mimeType: XLSX_MIME, + version, + } +} + +async function saveOfficeDocument( + message: Extract, +): Promise { + if (currentMode !== 'edit') { + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-document-result', + requestId: message.requestId, + payload: { ok: false, code: 'SAVE_FAILED', error: '当前工作簿为只读模式。' }, + }) + return + } + + try { + const saveAs = message.payload.mode === 'saveAs' || !currentFile + let filename = normalizeXlsxName(currentFile?.name || message.payload.file.name || 'workbook.xlsx') + let writeMode: 'selected' | 'result' = 'selected' + + if (saveAs) { + const destination = await pickSaveDestination(filename) + if (!destination) { + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-document-result', + requestId: message.requestId, + payload: { ok: false, code: 'CANCELLED', error: '已取消另存为。' }, + }) + return + } + filename = destination.filename + writeMode = 'result' + } + + const access = await requestSelectedFileAccess(writeMode, filename) + if (!saveAs) { + const conflict = detectSelectedFileVersionConflict(message.payload.baseVersion, access) + if (conflict) { + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-document-result', + requestId: message.requestId, + payload: { + ok: false, + code: conflict.code, + error: conflict.error, + }, + }) + return + } + } + + const bytes = message.payload.bytes.slice(0) + const response = await ucCall( + 'uc.fs.saveResultFile', + { + blob: new Blob([bytes], { type: XLSX_MIME }), + filename, + }, + 120_000, + ) + const descriptor = saveResponseDescriptor(response, access, filename, currentFile) + + if (saveAs && currentFile && descriptor.id === currentFile.id) { + throw new Error( + 'writeMode=result 已完成,但后端没有返回新文件的 nodeId/id;否则后续 Ctrl+S 无法安全写回新文件。', + ) + } + + currentAccess = access + currentFile = { + ...descriptor, + size: bytes.byteLength, + bytes, + } + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-document-result', + requestId: message.requestId, + payload: { + ok: true, + file: { + ...descriptor, + size: bytes.byteLength, + }, + }, + }) + } catch (error) { + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-document-result', + requestId: message.requestId, + payload: { + ok: false, + code: 'SAVE_FAILED', + error: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +async function openLocalAssetPicker( + message: Extract, +): Promise { + // The current UC plugin contract does not yet expose a confirmed interactive + // open-file picker RPC. Keep the fallback isolated here; when UC adds one, + // replace only this function and keep the office:* protocol unchanged. + const input = document.createElement('input') + input.type = 'file' + input.multiple = message.payload.multiple === true + if (message.payload.accept?.length) input.accept = message.payload.accept.join(',') + input.style.display = 'none' + document.body.append(input) + + const files = await new Promise((resolve) => { + let settled = false + const finish = (value: File[] | null) => { + if (settled) return + settled = true + input.remove() + resolve(value) + } + input.addEventListener('change', () => finish(input.files ? [...input.files] : null), { + once: true, + }) + input.addEventListener('cancel', () => finish(null), { once: true }) + input.click() + }) + if (!files?.length) return null + + const selected: SelectedOfficeFile[] = [] + for (const file of files) { + const id = `local-asset:${crypto.randomUUID()}` + const bytes = await file.arrayBuffer() + const officeFile: OfficeFile = { + id, + name: file.name, + mimeType: file.type || 'application/octet-stream', + size: bytes.byteLength, + version: String(file.lastModified), + bytes, + } + localAssets.set(id, officeFile) + selected.push({ + id, + name: officeFile.name, + mimeType: officeFile.mimeType, + size: officeFile.size, + version: officeFile.version, + transport: 'token', + token: id, + }) + } + return selected +} + +function fileForRead(fileId: string): OfficeFile | null { + if (currentFile?.id === fileId) return currentFile + return localAssets.get(fileId) || null +} + +async function handleEditorMessage(message: EditorToHostMessage): Promise { + switch (message.type) { + case 'office:ready': + if (message.payload.kind !== 'xlsx') return + editorReady = true + sendOfficeInit() + return + case 'office:pick-file': { + const files = await openLocalAssetPicker(message) + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:pick-file-result', + requestId: message.requestId, + payload: { files }, + }) + return + } + case 'office:read-file': { + const file = fileForRead(message.payload.fileId) + if (!file) { + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:error', + requestId: message.requestId, + payload: { code: 'READ_FAILED', message: '请求的文件不在当前 UC Office 会话中。' }, + }) + return + } + const bytes = file.bytes.slice(0) + sendOffice( + { + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:read-file-result', + requestId: message.requestId, + payload: { file: { ...file, bytes } }, + }, + [bytes], + ) + return + } + case 'office:save-document': + await saveOfficeDocument(message) + return + case 'office:save-history-version': + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-history-version-result', + requestId: message.requestId, + payload: { ok: false, code: 'SAVE_FAILED', error: 'UC Excel Host 暂未启用历史版本保存。' }, + }) + return + case 'office:export-document': + sendOffice({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:export-document-result', + requestId: message.requestId, + payload: { ok: false, code: 'SAVE_FAILED', error: 'UC Excel Host 暂未启用独立导出。' }, + }) + return + case 'office:dirty-change': + case 'office:title-change': + case 'office:state-result': + case 'office:save-result': + case 'office:close-request': + case 'office:close-cancelled': + return + case 'office:error': + showError(new Error(`${message.payload.code}: ${message.payload.message}`)) + return + } +} + +window.addEventListener('message', (event) => { + if (event.source === window.parent && event.origin === ucHostOrigin) { + const message = event.data as Partial + if ( + message?.type === 'uc-plugin-rpc-response' && + message.pluginId === pluginId && + typeof message.id === 'string' + ) { + const pending = pendingRpc.get(message.id) + if (!pending) return + pendingRpc.delete(message.id) + window.clearTimeout(pending.timer) + if (message.error !== undefined && message.error !== null) pending.reject(rpcError(message.error)) + else pending.resolve(ucResult(message as UcRpcResponse)) + } + return + } + + if (event.source === frame.contentWindow && event.origin === sheetsOrigin) { + if (!isOfficeProtocolMessage(event.data)) return + void handleEditorMessage(event.data as EditorToHostMessage).catch(showError) + } +}) + +frame.src = `${sheetsUrl.replace(/\/$/, '')}/?hostOrigin=${encodeURIComponent(window.location.origin)}` +void initializeFromUc().catch(showError) diff --git a/examples/uc-webos-xlsx-host/src/versioning.ts b/examples/uc-webos-xlsx-host/src/versioning.ts new file mode 100644 index 000000000..6cf4aa28e --- /dev/null +++ b/examples/uc-webos-xlsx-host/src/versioning.ts @@ -0,0 +1,42 @@ +export interface VersionedFileAccess { + version?: string | number | null | undefined + fileVersion?: string | number | null | undefined +} + +function normalizeVersion(value: unknown): string | null { + if (value === undefined || value === null) return null + const text = String(value).trim() + return text || null +} + +export function platformAccessVersion(access: VersionedFileAccess): string | null { + return normalizeVersion(access.version) ?? normalizeVersion(access.fileVersion) +} + +export interface VersionConflict { + code: 'VERSION_CONFLICT' + expectedVersion: string + actualVersion: string + error: string +} + +/** + * UC versions are optional, so absence keeps backward compatibility. When both + * the editor's baseVersion and the newly granted selected-file access expose a + * version, a mismatch must stop the write before saveResultFile is called. + */ +export function detectSelectedFileVersionConflict( + baseVersion: string | null | undefined, + access: VersionedFileAccess, +): VersionConflict | null { + const expectedVersion = normalizeVersion(baseVersion) + const actualVersion = platformAccessVersion(access) + if (!expectedVersion || !actualVersion || expectedVersion === actualVersion) return null + + return { + code: 'VERSION_CONFLICT', + expectedVersion, + actualVersion, + error: `文件版本已变化(编辑基线 ${expectedVersion},当前 ${actualVersion}),请重新打开后再保存。`, + } +} diff --git a/examples/uc-webos-xlsx-host/tsconfig.json b/examples/uc-webos-xlsx-host/tsconfig.json new file mode 100644 index 000000000..f9aa761fb --- /dev/null +++ b/examples/uc-webos-xlsx-host/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"] + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/examples/uc-webos-xlsx-host/vite.config.ts b/examples/uc-webos-xlsx-host/vite.config.ts new file mode 100644 index 000000000..1b5729ef0 --- /dev/null +++ b/examples/uc-webos-xlsx-host/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + root: __dirname, + server: { + host: '127.0.0.1', + port: 8083, + strictPort: true, + }, +}) diff --git a/examples/web-xlsx-host/README.md b/examples/web-xlsx-host/README.md new file mode 100644 index 000000000..062ae8da2 --- /dev/null +++ b/examples/web-xlsx-host/README.md @@ -0,0 +1,31 @@ +# GenOffice Excel Web Host Demo + +This example hosts GenOffice Sheets Web in an iframe and exercises the versioned `office:*` protocol. + +It demonstrates the host responsibilities expected from UC Web OS or another embedding platform: + +- create a blank workbook with `office:new`; +- open a real `.xlsx` with `office:init`; +- answer editor file-pick/read requests; +- receive dirty/title state; +- persist editor save bytes through `office:save-document`; +- switch edit/view mode and locale; +- download the currently saved workbook for round-trip verification. + +## Development + +```bash +npm run dev:xlsx-engine +npm run dev:web:sheets +npm run dev:web-xlsx-host +``` + +Default URLs: + +- Sheets Web: `http://127.0.0.1:5275` +- XLSX Host Demo: `http://127.0.0.1:8082` +- XLSX Engine Service: `http://127.0.0.1:7301` + +The host can point at another Sheets Web origin with the `sheetsUrl` query parameter. Sheets Web itself only calls same-origin `/xlsx-engine/*`; the serving layer proxies that path to the Rust engine. + +The permanent Sheets Web GitHub Actions workflow builds the production Web surfaces, starts both previews plus the Rust engine, and runs a Chromium iframe round-trip against this host. diff --git a/examples/web-xlsx-host/index.html b/examples/web-xlsx-host/index.html new file mode 100644 index 000000000..6be290b92 --- /dev/null +++ b/examples/web-xlsx-host/index.html @@ -0,0 +1,37 @@ + + + + + + GenOffice Excel Web Host Demo + + + +
+
+
+ GenOffice Excel Web + iframe Host Demo +
+
+ + + + + + + +
+
+ 新建工作簿 + clean + loading +
+
+
+ +
+
+ + + diff --git a/examples/web-xlsx-host/src/main.ts b/examples/web-xlsx-host/src/main.ts new file mode 100644 index 000000000..ad5cbfc78 --- /dev/null +++ b/examples/web-xlsx-host/src/main.ts @@ -0,0 +1,431 @@ +import type { OfficeEditorMode, OfficeFile } from '@genoffice/office-host-api' +import { + OFFICE_PROTOCOL_VERSION, + isOfficeProtocolMessage, + type EditorToHostMessage, + type HostToEditorMessage, +} from '@genoffice/office-protocol' + +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + +function requireElement(id: string): T { + const element = document.getElementById(id) + if (!element) throw new Error(`Missing #${id}`) + return element as T +} + +function defaultSheetsUrl(): string { + const configured = import.meta.env.VITE_SHEETS_URL + if (typeof configured === 'string' && configured.trim()) return configured.trim() + const hostname = window.location.hostname || 'localhost' + return `${window.location.protocol}//${hostname}:5275` +} + +const frame = requireElement('office-frame') +const picker = requireElement('xlsx-picker') +const newButton = requireElement('new-button') +const saveButton = requireElement('save-button') +const downloadButton = requireElement('download-button') +const modeButton = requireElement('mode-button') +const localeButton = requireElement('locale-button') +const fileName = requireElement('file-name') +const dirtyState = requireElement('dirty-state') +const hostState = requireElement('host-state') + +const query = new URLSearchParams(window.location.search) +const sheetsUrl = query.get('sheetsUrl') || defaultSheetsUrl() +const sheetsOrigin = new URL(sheetsUrl).origin + +let editorReady = false +let currentFile: OfficeFile | null = null +let dirty = false +let mode: OfficeEditorMode = 'edit' +let locale = 'zh-CN' +let requestCounter = 0 +let versionCounter = 0 +let historyVersionCounter = 0 +let hostStatus = 'loading' +const files = new Map() +const historyVersions: OfficeFile[] = [] + +function requestId(prefix: string): string { + requestCounter += 1 + return `${prefix}-${requestCounter}` +} + +function send(message: HostToEditorMessage): void { + frame.contentWindow?.postMessage(message, sheetsOrigin) +} + +function setHostState(value: string): void { + hostStatus = value + hostState.textContent = value +} + +function render(): void { + fileName.textContent = currentFile?.name ?? '新建工作簿' + dirtyState.textContent = dirty ? 'dirty' : 'clean' + hostState.textContent = hostStatus + saveButton.disabled = !editorReady || mode === 'view' + downloadButton.disabled = !currentFile + modeButton.disabled = !editorReady + localeButton.disabled = !editorReady + modeButton.textContent = mode === 'edit' ? '切换为预览' : '切换为编辑' + localeButton.textContent = locale === 'zh-CN' ? '切换 English' : '切换中文' +} + +function officeCapabilities() { + return { + ai: false, + open: true, + save: true, + saveAs: true, + saveHistoryVersion: true, + exportDocx: false, + exportPptx: false, + exportXlsx: true, + close: true, + autoSave: 'host' as const, + download: false, + print: true, + systemFilePicker: true, + pageCropMarks: false, + } +} + +function sendNew(): void { + if (!editorReady) return + currentFile = null + dirty = false + versionCounter = 0 + historyVersionCounter = 0 + setHostState('opening') + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:new', + requestId: requestId('new'), + payload: { + kind: 'xlsx', + mode, + locale, + capabilities: officeCapabilities(), + }, + }) + render() +} + +function sendInit(): void { + if (!editorReady || !currentFile) return + setHostState('opening') + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:init', + requestId: requestId('init'), + payload: { + kind: 'xlsx', + mode, + locale, + capabilities: officeCapabilities(), + file: { ...currentFile, bytes: currentFile.bytes.slice(0) }, + }, + }) +} + +function downloadBytes(bytes: ArrayBuffer, name: string, mimeType = XLSX_MIME): void { + const blob = new Blob([bytes], { type: mimeType }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = name + anchor.style.display = 'none' + document.body.append(anchor) + anchor.click() + anchor.remove() + setTimeout(() => URL.revokeObjectURL(url), 0) +} + +function downloadCurrentFile(): void { + if (!currentFile) return + downloadBytes(currentFile.bytes, currentFile.name, currentFile.mimeType || XLSX_MIME) +} + +async function toOfficeFile(file: File): Promise { + const bytes = await file.arrayBuffer() + return { + id: `xlsx:${crypto.randomUUID()}`, + name: file.name, + mimeType: file.type || XLSX_MIME, + size: bytes.byteLength, + version: 'v1', + bytes, + } +} + +function normalizeXlsxName(value: string): string { + const name = value.trim() + return /\.xlsx$/i.test(name) ? name : `${name}.xlsx` +} + +async function handleEditorMessage(message: EditorToHostMessage): Promise { + switch (message.type) { + case 'office:ready': + if (message.payload.kind !== 'xlsx') return + editorReady = true + setHostState('ready') + render() + sendNew() + break + case 'office:dirty-change': + dirty = message.payload.dirty + render() + break + case 'office:title-change': + if (currentFile) currentFile = { ...currentFile, name: message.payload.title } + render() + break + case 'office:pick-file': { + const input = document.createElement('input') + input.type = 'file' + input.multiple = message.payload.multiple === true + if (message.payload.accept?.length) input.accept = message.payload.accept.join(',') + input.addEventListener( + 'change', + async () => { + const selected = input.files?.[0] + if (!selected) { + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:pick-file-result', + requestId: message.requestId, + payload: { files: null }, + }) + return + } + const officeFile = await toOfficeFile(selected) + files.set(officeFile.id, officeFile) + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:pick-file-result', + requestId: message.requestId, + payload: { + files: [ + { + id: officeFile.id, + name: officeFile.name, + mimeType: officeFile.mimeType, + size: officeFile.size, + version: officeFile.version, + transport: 'token', + token: `demo:${officeFile.id}`, + }, + ], + }, + }) + }, + { once: true }, + ) + input.click() + break + } + case 'office:read-file': { + const stored = files.get(message.payload.fileId) + if (!stored) { + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:error', + requestId: message.requestId, + payload: { code: 'NOT_FOUND', message: 'Demo XLSX file was not found.' }, + }) + return + } + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:read-file-result', + requestId: message.requestId, + payload: { file: { ...stored, bytes: stored.bytes.slice(0) } }, + }) + break + } + case 'office:save-document': { + const saveAs = message.payload.mode === 'saveAs' + let name = currentFile?.name ?? message.payload.file.name ?? 'Untitled.xlsx' + if (saveAs) { + const requested = window.prompt('另存为文件名', name) + if (requested === null || !requested.trim()) { + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-document-result', + requestId: message.requestId, + payload: { ok: false, code: 'CANCELLED', error: '已取消另存为。' }, + }) + setHostState('ready') + render() + break + } + name = normalizeXlsxName(requested) + } + + versionCounter += 1 + const bytes = message.payload.bytes.slice(0) + const id = saveAs || !currentFile ? `xlsx:${crypto.randomUUID()}` : currentFile.id + currentFile = { + id, + name, + mimeType: XLSX_MIME, + size: bytes.byteLength, + version: `v${versionCounter}`, + bytes, + } + files.set(id, currentFile) + dirty = false + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-document-result', + requestId: message.requestId, + payload: { + ok: true, + file: { + id, + name, + mimeType: XLSX_MIME, + size: bytes.byteLength, + version: currentFile.version, + }, + }, + }) + setHostState('saved') + render() + break + } + case 'office:save-history-version': { + if (!currentFile) { + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-history-version-result', + requestId: message.requestId, + payload: { ok: false, code: 'NOT_FOUND', error: 'Save the workbook before creating history.' }, + }) + break + } + historyVersionCounter += 1 + const bytes = message.payload.bytes.slice(0) + const history: OfficeFile = { + ...currentFile, + id: `history:${currentFile.id}:${historyVersionCounter}`, + size: bytes.byteLength, + version: `history-${historyVersionCounter}`, + bytes, + } + historyVersions.push(history) + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save-history-version-result', + requestId: message.requestId, + payload: { ok: true }, + }) + setHostState(`history saved (${historyVersionCounter})`) + render() + break + } + case 'office:export-document': { + if (message.payload.format !== 'xlsx') { + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:export-document-result', + requestId: message.requestId, + payload: { ok: false, code: 'SAVE_FAILED', error: 'Demo Host only exports XLSX.' }, + }) + break + } + const exportName = normalizeXlsxName(message.payload.file.name || 'Untitled.xlsx') + downloadBytes(message.payload.bytes.slice(0), exportName, XLSX_MIME) + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:export-document-result', + requestId: message.requestId, + payload: { ok: true }, + }) + setHostState('exported') + render() + break + } + case 'office:close-request': + setHostState('close requested') + render() + break + case 'office:save-result': + setHostState(message.payload.ok ? 'saved' : `save failed: ${message.payload.error ?? ''}`) + render() + break + case 'office:state-result': + dirty = message.payload.dirty + mode = message.payload.mode + setHostState(message.payload.saving ? 'saving' : hostStatus) + render() + break + case 'office:error': + setHostState(`error: ${message.payload.message}`) + console.error('[GenOffice Excel Web]', message.payload) + break + default: + break + } +} + +window.addEventListener('message', (event) => { + if (event.source !== frame.contentWindow || event.origin !== sheetsOrigin) return + if (!isOfficeProtocolMessage(event.data)) return + void handleEditorMessage(event.data as EditorToHostMessage) +}) + +newButton.addEventListener('click', sendNew) + +picker.addEventListener('change', async () => { + const browserFile = picker.files?.[0] + if (!browserFile) return + currentFile = await toOfficeFile(browserFile) + files.set(currentFile.id, currentFile) + dirty = false + versionCounter = 1 + historyVersionCounter = 0 + render() + sendInit() +}) + +saveButton.addEventListener('click', () => { + if (!editorReady || mode === 'view') return + setHostState('saving') + render() + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:save', + requestId: requestId('save'), + }) +}) + +downloadButton.addEventListener('click', downloadCurrentFile) + +modeButton.addEventListener('click', () => { + mode = mode === 'edit' ? 'view' : 'edit' + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:set-mode', + requestId: requestId('mode'), + payload: { mode }, + }) + render() +}) + +localeButton.addEventListener('click', () => { + locale = locale === 'zh-CN' ? 'en-US' : 'zh-CN' + send({ + protocol: OFFICE_PROTOCOL_VERSION, + type: 'office:set-locale', + requestId: requestId('locale'), + payload: { locale }, + }) + render() +}) + +frame.src = `${sheetsUrl.replace(/\/$/, '')}/?hostOrigin=${encodeURIComponent(window.location.origin)}` +render() diff --git a/examples/web-xlsx-host/src/style.css b/examples/web-xlsx-host/src/style.css new file mode 100644 index 000000000..7b4e6cd4d --- /dev/null +++ b/examples/web-xlsx-host/src/style.css @@ -0,0 +1,19 @@ +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; font-family: 'Segoe UI', system-ui, sans-serif; color: #242424; background: #f5f6f7; } +button, .button { font: inherit; } +.app-shell { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100vh; } +.toolbar { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 20px; padding: 10px 14px; border-bottom: 1px solid #d5d9dc; background: #fff; } +.brand { display: flex; flex-direction: column; gap: 2px; white-space: nowrap; } +.brand strong { color: #185c37; font-size: 14px; } +.brand span { color: #73777c; font-size: 11px; } +.actions { display: flex; align-items: center; gap: 8px; min-width: 0; } +button, .button { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; padding: 0 12px; border: 1px solid #c8cdd1; border-radius: 5px; background: #fff; color: #242424; cursor: pointer; text-decoration: none; } +button:hover:not(:disabled), .button:hover { background: #f3f5f4; } +button:disabled { cursor: default; opacity: .45; } +.primary { border-color: #217346; background: #217346; color: #fff; } +.primary:hover { background: #185c37; } +.status { display: flex; align-items: center; gap: 8px; white-space: nowrap; font-size: 12px; } +.pill { padding: 3px 8px; border-radius: 999px; background: #eef1f3; color: #61666b; } +.editor-shell { min-height: 0; padding: 10px; } +#office-frame { width: 100%; height: 100%; border: 1px solid #d5d9dc; border-radius: 6px; background: #fff; } +@media (max-width: 980px) { .toolbar { grid-template-columns: 1fr; gap: 8px; } .actions, .status { flex-wrap: wrap; } } diff --git a/examples/web-xlsx-host/vite.config.ts b/examples/web-xlsx-host/vite.config.ts new file mode 100644 index 000000000..3c79cb9c6 --- /dev/null +++ b/examples/web-xlsx-host/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + root: __dirname, + server: { + host: '0.0.0.0', + port: 8082, + strictPort: true, + }, +}) diff --git a/package.json b/package.json index 60df5ea19..89afea523 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,21 @@ "dev:web:slides": "npm run dev:web -w @genoffice/slides", "dev:web-ppt-host": "npx vite --config examples/web-ppt-host/vite.config.ts", "dev:web:slides-host": "concurrently -k -n slides,host -c yellow,green \"npm run dev:web:slides\" \"npm run dev:web-ppt-host\"", + "dev:web:sheets": "npm run dev:web -w @genoffice/sheets", + "dev:xlsx-engine": "cargo run --manifest-path services/xlsx-engine-service/Cargo.toml", + "dev:web-xlsx-host": "npx vite --config examples/web-xlsx-host/vite.config.ts", + "dev:uc-webos-xlsx-host": "npx vite --config examples/uc-webos-xlsx-host/vite.config.ts", + "dev:web:sheets-engine": "concurrently -k -n sheets,xlsx-engine -c green,cyan \"npm run dev:web:sheets\" \"npm run dev:xlsx-engine\"", + "dev:web:sheets-host": "concurrently -k -n sheets,xlsx-engine,host -c green,cyan,blue \"npm run dev:web:sheets\" \"npm run dev:xlsx-engine\" \"npm run dev:web-xlsx-host\"", "build": "npm run build -w @genoffice/docs", "build:web:docs": "npm run build:web -w @genoffice/docs", "build:web-host": "npx vite build --config examples/web-host/vite.config.ts", "build:web:slides": "npm run build:web -w @genoffice/slides", "build:web-ppt-host": "npx vite build --config examples/web-ppt-host/vite.config.ts", + "build:web:sheets": "npm run build:web -w @genoffice/sheets", + "build:web-xlsx-host": "npx vite build --config examples/web-xlsx-host/vite.config.ts", + "build:uc-webos-xlsx-host": "npx vite build --config examples/uc-webos-xlsx-host/vite.config.ts", + "build:xlsx-engine": "cargo build --release --manifest-path services/xlsx-engine-service/Cargo.toml", "build:all": "npm run build -w @genoffice/docs && npm run build -w @genoffice/sheets && npm run build -w @genoffice/slides && npm run build -w @genoffice/pdf && npm run build -w @genoffice/markdown && npm run build -w @genoffice/shell", "shell": "npm run build:all && electron apps/shell", "test:e2e": "playwright test --config e2e/playwright.config.ts", diff --git a/packages/iframe-bridge/src/index.ts b/packages/iframe-bridge/src/index.ts index afaaf6923..7096ca5ba 100644 --- a/packages/iframe-bridge/src/index.ts +++ b/packages/iframe-bridge/src/index.ts @@ -3,8 +3,8 @@ import { isOfficeProtocolMessage, type OfficeProtocolMessage } from '@genoffice/ export interface OfficeIframeBridgeOptions { targetWindow: Window targetOrigin: string - sourceWindow?: Window - requestTimeoutMs?: number + sourceWindow?: Window | undefined + requestTimeoutMs?: number | undefined } export type OfficeMessageHandler = ( diff --git a/packages/office-host-api/src/index.ts b/packages/office-host-api/src/index.ts index cd533620c..1af5d62f8 100644 --- a/packages/office-host-api/src/index.ts +++ b/packages/office-host-api/src/index.ts @@ -1,7 +1,7 @@ export type OfficeDocumentKind = 'docx' | 'pptx' | 'xlsx' export type OfficeEditorMode = 'view' | 'edit' export type OfficeSaveMode = 'save' | 'saveAs' -export type OfficeExportFormat = 'docx' | 'pptx' +export type OfficeExportFormat = 'docx' | 'pptx' | 'xlsx' export type OfficeAutoSavePolicy = 'disabled' | 'host' | 'editor' export interface OfficeHostCapabilities { @@ -12,6 +12,7 @@ export interface OfficeHostCapabilities { saveHistoryVersion: boolean exportDocx: boolean exportPptx: boolean + exportXlsx: boolean close: boolean autoSave: OfficeAutoSavePolicy download: boolean @@ -28,6 +29,7 @@ export const DEFAULT_STANDALONE_OFFICE_CAPABILITIES: OfficeHostCapabilities = { saveHistoryVersion: false, exportDocx: true, exportPptx: true, + exportXlsx: true, close: false, autoSave: 'disabled', download: true, @@ -44,6 +46,7 @@ export const DEFAULT_EMBEDDED_OFFICE_CAPABILITIES: OfficeHostCapabilities = { saveHistoryVersion: true, exportDocx: true, exportPptx: true, + exportXlsx: true, close: true, autoSave: 'host', download: false, @@ -56,8 +59,8 @@ export interface OfficeFileDescriptor { id: string name: string mimeType: string - size?: number - version?: string | null + size?: number | undefined + version?: string | null | undefined } export interface OfficeFile extends OfficeFileDescriptor { @@ -67,29 +70,35 @@ export interface OfficeFile extends OfficeFileDescriptor { export interface SaveDocumentInput { file: OfficeFileDescriptor bytes: ArrayBuffer - baseVersion?: string | null - mode?: OfficeSaveMode + baseVersion?: string | null | undefined + mode?: OfficeSaveMode | undefined /** First persistence of a blank editor document; the Host should choose/create its destination. */ - newDocument?: boolean + newDocument?: boolean | undefined } export interface SaveDocumentResult { ok: boolean - file?: OfficeFileDescriptor - error?: string - code?: 'VERSION_CONFLICT' | 'PERMISSION_DENIED' | 'NOT_FOUND' | 'SAVE_FAILED' | 'CANCELLED' + file?: OfficeFileDescriptor | undefined + error?: string | undefined + code?: + | 'VERSION_CONFLICT' + | 'PERMISSION_DENIED' + | 'NOT_FOUND' + | 'SAVE_FAILED' + | 'CANCELLED' + | undefined } export interface SaveHistoryVersionInput { file: OfficeFileDescriptor bytes: ArrayBuffer - baseVersion?: string | null + baseVersion?: string | null | undefined } export interface SaveHistoryVersionResult { ok: boolean - error?: string - code?: 'PERMISSION_DENIED' | 'NOT_FOUND' | 'SAVE_FAILED' | 'CANCELLED' + error?: string | undefined + code?: 'PERMISSION_DENIED' | 'NOT_FOUND' | 'SAVE_FAILED' | 'CANCELLED' | undefined } export interface ExportDocumentInput { @@ -100,21 +109,21 @@ export interface ExportDocumentInput { export interface ExportDocumentResult { ok: boolean - error?: string - code?: 'PERMISSION_DENIED' | 'SAVE_FAILED' | 'CANCELLED' + error?: string | undefined + code?: 'PERMISSION_DENIED' | 'SAVE_FAILED' | 'CANCELLED' | undefined } export interface PickFileOptions { - multiple?: boolean - accept?: string[] - mode?: 'file' | 'folder' + multiple?: boolean | undefined + accept?: string[] | undefined + mode?: 'file' | 'folder' | undefined } export interface SelectedOfficeFile extends OfficeFileDescriptor { transport: 'buffer' | 'token' - bytes?: ArrayBuffer - token?: string - url?: string + bytes?: ArrayBuffer | undefined + token?: string | undefined + url?: string | undefined } export interface OfficeHostApi { diff --git a/packages/office-protocol/src/index.ts b/packages/office-protocol/src/index.ts index c052e6c6a..53d446d7e 100644 --- a/packages/office-protocol/src/index.ts +++ b/packages/office-protocol/src/index.ts @@ -18,16 +18,16 @@ export const OFFICE_PROTOCOL_VERSION = 1 as const export interface OfficeInitPayload { kind: OfficeDocumentKind mode: OfficeEditorMode - locale?: string + locale?: string | undefined file: OfficeFile - capabilities?: Partial + capabilities?: Partial | undefined } export interface OfficeNewPayload { kind: OfficeDocumentKind mode: OfficeEditorMode - locale?: string - capabilities?: Partial + locale?: string | undefined + capabilities?: Partial | undefined } export interface OfficeEditorState { @@ -35,7 +35,7 @@ export interface OfficeEditorState { dirty: boolean saving: boolean mode: OfficeEditorMode - title?: string + title?: string | undefined } export interface OfficeProtocolErrorPayload { @@ -59,13 +59,13 @@ export type HostToEditorMessage = | { protocol: typeof OFFICE_PROTOCOL_VERSION type: 'office:set-locale' - requestId?: string + requestId?: string | undefined payload: { locale: string } } | { protocol: typeof OFFICE_PROTOCOL_VERSION type: 'office:set-mode' - requestId?: string + requestId?: string | undefined payload: { mode: OfficeEditorMode } } | { @@ -117,7 +117,7 @@ export type HostToEditorMessage = | { protocol: typeof OFFICE_PROTOCOL_VERSION type: 'office:error' - requestId?: string + requestId?: string | undefined payload: OfficeProtocolErrorPayload } @@ -147,7 +147,7 @@ export type EditorToHostMessage = protocol: typeof OFFICE_PROTOCOL_VERSION type: 'office:save-result' requestId: string - payload: { ok: boolean; error?: string } + payload: { ok: boolean; error?: string | undefined } } | { protocol: typeof OFFICE_PROTOCOL_VERSION @@ -156,9 +156,9 @@ export type EditorToHostMessage = payload: { file: OfficeFileDescriptor bytes: ArrayBuffer - baseVersion?: string | null - mode?: OfficeSaveMode - newDocument?: boolean + baseVersion?: string | null | undefined + mode?: OfficeSaveMode | undefined + newDocument?: boolean | undefined } } | { @@ -168,7 +168,7 @@ export type EditorToHostMessage = payload: { file: OfficeFileDescriptor bytes: ArrayBuffer - baseVersion?: string | null + baseVersion?: string | null | undefined } } | { @@ -184,7 +184,7 @@ export type EditorToHostMessage = | { protocol: typeof OFFICE_PROTOCOL_VERSION type: 'office:close-request' - requestId?: string + requestId?: string | undefined payload: { reason: 'file-menu' | 'window-close' } } | { @@ -208,7 +208,7 @@ export type EditorToHostMessage = | { protocol: typeof OFFICE_PROTOCOL_VERSION type: 'office:error' - requestId?: string + requestId?: string | undefined payload: OfficeProtocolErrorPayload } diff --git a/services/xlsx-engine-service/Cargo.toml b/services/xlsx-engine-service/Cargo.toml new file mode 100644 index 000000000..f62bb8023 --- /dev/null +++ b/services/xlsx-engine-service/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xlsx-engine-service" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +axum = "0.8" +base64 = "0.22" +ironcalc = "0.7.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +uuid = { version = "1", features = ["v4", "serde"] } +xlsx-sidecar = { path = "../../apps/sheets/native/xlsx-engine" } diff --git a/services/xlsx-engine-service/README.md b/services/xlsx-engine-service/README.md new file mode 100644 index 000000000..4ec83afa2 --- /dev/null +++ b/services/xlsx-engine-service/README.md @@ -0,0 +1,130 @@ +# XLSX Engine Service + +The Web Sheets runtime keeps the existing React + Univer editor in the browser and moves XLSX parsing/preservation/export behind this Rust HTTP service. + +## Development + +```bash +npm run dev:xlsx-engine +``` + +The default listener is `127.0.0.1:7301`. Override it with `XLSX_ENGINE_LISTEN`. + +The Sheets Vite development and preview servers proxy same-origin browser calls from `/xlsx-engine/*` to this service. + +## Current API + +- `GET /health` +- `GET /metrics` +- `POST /v1/sessions` +- `POST /v1/workbooks` +- `POST /v1/workbooks/blank` +- `GET /v1/sessions/:sessionId` +- `DELETE /v1/sessions/:sessionId` +- `POST /v1/sessions/:sessionId/ranges` +- `POST /v1/sessions/:sessionId/formulas` +- `POST /v1/sessions/:sessionId/recalc` +- `GET /v1/sessions/:sessionId/archive/manifest` +- `POST /v1/sessions/:sessionId/archive/read` +- `POST /v1/sessions/:sessionId/archive/scan` +- `POST /v1/sessions/:sessionId/archive/save` + +Workbook APIs are session-addressed. Browser clients treat `sessionId` as an opaque string and may send it in `X-Xlsx-Session` as a future routing key. + +The service directly reuses the existing `xlsx-sidecar` workbook sessions and IronCalc recalculation support. Browser preservation-save planning stays in the shared Sheets gateway, while this service owns workbook/session access and archive assembly. + +## Request limits + +The service keeps HTTP bodies bounded in production while leaving enough room for preservation saves, whose archive mutations carry base64-encoded package parts. + +- `XLSX_ENGINE_MAX_WORKBOOK_MB` — maximum raw `.xlsx` upload size, default `100` MiB. +- `XLSX_ENGINE_MAX_REQUEST_MB` — maximum HTTP request body size, default `384` MiB. + +`XLSX_ENGINE_MAX_REQUEST_MB` must be greater than or equal to `XLSX_ENGINE_MAX_WORKBOOK_MB`. Invalid or zero values fail service startup rather than silently disabling the protection. Raw workbook uploads that exceed their configured limit return HTTP `413 Payload Too Large`. + +The request limit intentionally exceeds the raw workbook limit because base64 content expands binary payloads by roughly one third and a preservation save can contain multiple replaced or added package parts. + +## Heavy request admission + +Workbook parsing, range/formula reads, recalculation and archive operations are admitted through a bounded semaphore before the expensive work starts. + +- `XLSX_ENGINE_MAX_HEAVY_REQUESTS` — maximum admitted heavy requests, default `4`. +- `XLSX_ENGINE_HEAVY_QUEUE_TIMEOUT_SECS` — maximum time a request may wait for a heavy-work slot, default `15` seconds. + +Both values must be positive integers. A request that cannot obtain a slot within the configured queue timeout returns HTTP `503 Service Unavailable` **before** its XLSX operation starts. + +The admission timeout is intentionally not an in-flight execution timeout. Most sidecar/archive operations are synchronous today; aborting only the HTTP future would not reliably stop the underlying file operation and could leave a half-finished save. Once a request receives a slot, it is allowed to finish atomically. A future hard execution deadline should be added only together with cooperative cancellation or an isolated worker-process boundary. + +`GET /health` reports `maxHeavyRequests`, `availableHeavySlots` and `heavyQueueTimeoutSecs` so a deployment can observe whether the configured pool is saturated. + +## Observability + +Every HTTP response carries `X-Request-Id`. + +- If a caller supplies a safe `X-Request-Id` containing only letters, digits, `-`, `_`, `.`, or `:` and no more than 128 characters, the service preserves it. +- Otherwise the service generates an opaque `req_` value. + +Each completed request writes one JSON line to stdout with only operational fields: + +```json +{"event":"http_request","requestId":"req_...","method":"POST","path":"/v1/workbooks","status":201,"durationMs":42} +``` + +The log intentionally records the URL **path only**. It does not log query strings, workbook names, request bodies, file bytes, UC users, tenants, FsNode IDs, JWTs, or plugin permissions. + +Service startup also emits a JSON `service_started` event containing only listener and Engine limit configuration. + +`GET /metrics` exposes a small Prometheus-text-compatible operational surface: + +- `genoffice_xlsx_requests_total` +- `genoffice_xlsx_server_errors_total` +- `genoffice_xlsx_heavy_admission_rejects_total` +- `genoffice_xlsx_heavy_slots` +- `genoffice_xlsx_heavy_slots_available` +- `genoffice_xlsx_workbook_sessions` +- `genoffice_xlsx_lightweight_sessions` + +The endpoint intentionally has no user-, tenant-, workbook-name-, or file-level labels, keeping metric cardinality bounded and avoiding storage metadata leakage. Production ingress may restrict `/metrics` to the internal monitoring network while keeping `/health` available to the load balancer. + +## Session expiry + +Workbook sessions are intentionally in-memory for the first single-node milestone, but abandoned browser sessions are no longer allowed to live forever. + +- `XLSX_ENGINE_SESSION_TTL_SECS` — idle workbook/session lifetime, default `3600` seconds. +- `XLSX_ENGINE_CLEANUP_INTERVAL_SECS` — expired-session sweep interval, default `60` seconds. + +The cleanup interval must be less than or equal to the session TTL. Invalid or zero values fail service startup. + +Metadata reads, range/formula reads, recalculation and archive operations refresh a workbook session's last-access timestamp. A preservation save registers the newly produced workbook session with a fresh TTL. When a session expires, the service closes the native workbook session, removes metadata and recalculation cache entries, and deletes the session's temporary workbook directory. Explicit `DELETE /v1/sessions/:sessionId` performs the same cleanup immediately. + +## Workspace isolation and crash recovery + +Workbook and scratch files live under an endpoint-specific workspace. The default base directory is the operating system temp directory plus `genoffice-xlsx-engine-v2`; it can be overridden with `XLSX_ENGINE_WORK_ROOT`. + +For example, `127.0.0.1:7301` uses a root similar to: + +```text +/127_0_0_1_7301/ +├─ workbooks/ +└─ scratch/ +``` + +The service binds its TCP listener before touching this directory. Only after the endpoint is exclusively owned does startup remove leftovers from a previous crashed process and create a clean workspace. Different listen addresses/ports use different roots and are not deleted by each other. A graceful shutdown removes the current endpoint workspace immediately; an ungraceful process exit leaves it for the next successful owner of that endpoint to clean. + +## Production deployment + +The first production topology is intentionally single-node: + +```text +Nginx +├─ / -> Sheets Web static files +└─ /xlsx-engine/* -> 127.0.0.1:7301 +``` + +No Redis, database, object storage, or message queue is required for the first milestone. + +The service boundary is intentionally prepared for later horizontal expansion without changing Sheets Web, UC Excel Host, or the `office:*` iframe protocol. Session placement and routing can be introduced behind the same API when multiple Rust instances are needed. + +The Rust service must remain independent of UC Web OS authentication and storage concepts: it does not receive tenant IDs, JWTs, CSRF tokens, FsNode IDs, or plugin permissions. UC owns files and authorization; the engine owns spreadsheet processing. + +The remaining single-node hardening work is primarily deployment tuning and, if required by real workloads, a cooperative/worker-process execution deadline. Those concerns stay inside the engine service and do not change the browser or UC Host contracts. diff --git a/services/xlsx-engine-service/src/main.rs b/services/xlsx-engine-service/src/main.rs new file mode 100644 index 000000000..9768f2ea0 --- /dev/null +++ b/services/xlsx-engine-service/src/main.rs @@ -0,0 +1,993 @@ +mod observability; + +use std::{ + collections::HashMap, + fs, io, + net::SocketAddr, + path::{Path as FsPath, PathBuf}, + sync::Arc, + time::{Duration, Instant}, +}; + +use axum::{ + body::{Body, Bytes}, + extract::{DefaultBodyLimit, Path, Query, State}, + http::{header, HeaderValue, StatusCode}, + middleware, + response::Response, + routing::{get, post}, + Json, Router, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use ironcalc::{base::Model, export::save_to_xlsx}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock, Semaphore}; +use uuid::Uuid; +use xlsx_sidecar::{ + archive::{archive_manifest, read_entries_to_dir, save_archive, scan_entries_for_text, EntryContent}, + recalc::{recalc_cells, RecalcCache, RecalcEdit, RecalcRead}, + CellRange, WorkbookSessions, +}; + +const XLSX_MIME: &str = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; +const DEFAULT_MAX_WORKBOOK_MB: usize = 100; +// Archive mutation JSON carries base64 content, so it needs headroom above the +// raw workbook upload limit while still remaining bounded in production. +const DEFAULT_MAX_REQUEST_MB: usize = 384; +const DEFAULT_SESSION_TTL_SECS: u64 = 60 * 60; +const DEFAULT_CLEANUP_INTERVAL_SECS: u64 = 60; +const DEFAULT_MAX_HEAVY_REQUESTS: usize = 4; +const DEFAULT_HEAVY_QUEUE_TIMEOUT_SECS: u64 = 15; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WorkbookSession { + session_id: String, + source: SessionSource, + #[serde(skip)] + last_access: Instant, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +enum SessionSource { + Blank, + Uploaded, +} + +trait SessionStore: Send + Sync { + fn kind(&self) -> &'static str; +} + +#[derive(Default)] +struct MemorySessionStore { + sessions: RwLock>, +} + +impl SessionStore for MemorySessionStore { + fn kind(&self) -> &'static str { + "memory" + } +} + +struct EngineState { + workbooks: WorkbookSessions, + recalc: RecalcCache, + files: HashMap, + metadata: HashMap, + last_access: HashMap, +} + +impl Default for EngineState { + fn default() -> Self { + Self { + workbooks: WorkbookSessions::new(), + recalc: RecalcCache::new(), + files: HashMap::new(), + metadata: HashMap::new(), + last_access: HashMap::new(), + } + } +} + +#[derive(Clone)] +struct AppState { + sessions: Arc, + engine: Arc>, + metrics: Arc, + heavy_slots: Arc, + max_heavy_requests: usize, + heavy_queue_timeout: Duration, + max_workbook_bytes: usize, + session_ttl: Duration, + cleanup_interval: Duration, + workbook_root: PathBuf, + scratch_root: PathBuf, +} + +struct EngineWorkspace { + root: PathBuf, + workbook_root: PathBuf, + scratch_root: PathBuf, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateSessionRequest { + #[serde(default)] + source: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenWorkbookQuery { + name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReadRangeRequest { + sheet_id: String, + range: CellRange, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReadFormulaCellsRequest { + sheet_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RecalcRequest { + edits: Vec, + reads: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ArchiveEntriesRequest { + entries: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ArchiveScanRequest { + entries: Vec, + needle: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ArchiveContentRequest { + name: String, + content_base64: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ArchiveSaveRequest { + name: Option, + #[serde(default)] + replacements: Vec, + #[serde(default)] + removals: Vec, + #[serde(default)] + additions: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HealthResponse { + ok: bool, + service: &'static str, + session_store: &'static str, + max_heavy_requests: usize, + available_heavy_slots: usize, + heavy_queue_timeout_secs: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateSessionResponse { + session_id: String, + source: SessionSource, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ArchiveReadEntry { + name: String, + content_base64: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ArchiveReadResponse { + entries: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ArchiveScanResponse { + matches: Vec, +} + +type ApiError = (StatusCode, String); + +async fn acquire_heavy_permit_with( + slots: Arc, + wait: Duration, +) -> Result { + match tokio::time::timeout(wait, slots.acquire_owned()).await { + Ok(Ok(permit)) => Ok(permit), + Ok(Err(_)) => Err(internal_error("XLSX engine admission semaphore is closed.")), + Err(_) => Err(( + StatusCode::SERVICE_UNAVAILABLE, + format!( + "XLSX engine is busy; no heavy request slot became available within {}s.", + wait.as_secs() + ), + )), + } +} + +async fn acquire_heavy_permit(state: &AppState) -> Result { + let result = + acquire_heavy_permit_with(state.heavy_slots.clone(), state.heavy_queue_timeout).await; + if matches!(&result, Err((status, _)) if *status == StatusCode::SERVICE_UNAVAILABLE) { + state.metrics.record_heavy_admission_reject(); + } + result +} + +async fn health(State(state): State) -> Json { + Json(HealthResponse { + ok: true, + service: "xlsx-engine-service", + session_store: state.sessions.kind(), + max_heavy_requests: state.max_heavy_requests, + available_heavy_slots: state.heavy_slots.available_permits(), + heavy_queue_timeout_secs: state.heavy_queue_timeout.as_secs(), + }) +} + +async fn create_session( + State(state): State, + Json(request): Json, +) -> (StatusCode, Json) { + let source = match request.source.as_deref() { + Some("uploaded") => SessionSource::Uploaded, + _ => SessionSource::Blank, + }; + + // This lightweight endpoint only reserves an opaque application session. + // Real workbook editing sessions are created by /v1/workbooks or + // /v1/workbooks/blank and are backed by xlsx-sidecar WorkbookSessions. + let session_id = format!("xls_{}", Uuid::new_v4().simple()); + let session = WorkbookSession { + session_id: session_id.clone(), + source: source.clone(), + last_access: Instant::now(), + }; + + state + .sessions + .sessions + .write() + .await + .insert(session_id.clone(), session); + + ( + StatusCode::CREATED, + Json(CreateSessionResponse { session_id, source }), + ) +} + +async fn create_blank_workbook( + State(state): State, + Query(query): Query, +) -> Result<(StatusCode, Json), ApiError> { + let _permit = acquire_heavy_permit(&state).await?; + let name = safe_workbook_name(query.name.as_deref().unwrap_or("Untitled.xlsx")); + let directory = workbook_directory(&state); + fs::create_dir_all(&directory).map_err(internal_error)?; + let path = directory.join(&name); + + let mut model = Model::new_empty("Untitled", "en", "UTC", "en") + .map_err(internal_error)?; + model.evaluate(); + save_to_xlsx(&model, path.to_string_lossy().as_ref()).map_err(internal_error)?; + + register_workbook_path(&state, path, name).await +} + +async fn open_workbook( + State(state): State, + Query(query): Query, + bytes: Bytes, +) -> Result<(StatusCode, Json), ApiError> { + if bytes.len() > state.max_workbook_bytes { + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + format!( + "Workbook exceeds the configured {}MB upload limit.", + state.max_workbook_bytes / (1024 * 1024) + ), + )); + } + + let _permit = acquire_heavy_permit(&state).await?; + let name = safe_workbook_name(query.name.as_deref().unwrap_or("workbook.xlsx")); + let directory = workbook_directory(&state); + fs::create_dir_all(&directory).map_err(internal_error)?; + let path = directory.join(&name); + fs::write(&path, &bytes).map_err(internal_error)?; + register_workbook_path(&state, path, name).await +} + +async fn register_workbook_path( + state: &AppState, + path: PathBuf, + name: String, +) -> Result<(StatusCode, Json), ApiError> { + let bytes = fs::read(&path).map_err(internal_error)?; + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + let mut engine = state.engine.lock().await; + let metadata = match engine.workbooks.open(&path) { + Ok(metadata) => metadata, + Err(error) => { + if let Some(parent) = path.parent() { + let _ = fs::remove_dir_all(parent); + } + return Err((StatusCode::UNPROCESSABLE_ENTITY, error.to_string())); + } + }; + let session_id = metadata.session_id.clone(); + let value = web_metadata_value(metadata, &name, &sha256)?; + engine.files.insert(session_id.clone(), path); + engine.metadata.insert(session_id.clone(), value.clone()); + engine.last_access.insert(session_id, Instant::now()); + Ok((StatusCode::CREATED, Json(value))) +} + +fn touch_workbook_session(engine: &mut EngineState, session_id: &str) { + if engine.files.contains_key(session_id) { + engine + .last_access + .insert(session_id.to_string(), Instant::now()); + } +} + +async fn get_session_metadata( + State(state): State, + Path(session_id): Path, +) -> Result, ApiError> { + let mut engine = state.engine.lock().await; + let metadata = engine + .metadata + .get(&session_id) + .cloned() + .ok_or_else(|| (StatusCode::NOT_FOUND, "Unknown workbook session.".to_string()))?; + touch_workbook_session(&mut engine, &session_id); + Ok(Json(metadata)) +} + +async fn read_range( + State(state): State, + Path(session_id): Path, + Json(request): Json, +) -> Result, ApiError> { + let _permit = acquire_heavy_permit(&state).await?; + let mut engine = state.engine.lock().await; + touch_workbook_session(&mut engine, &session_id); + let result = engine + .workbooks + .read_range(&session_id, &request.sheet_id, &request.range) + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(serde_json::to_value(result).map_err(internal_error)?)) +} + +async fn read_formula_cells( + State(state): State, + Path(session_id): Path, + Json(request): Json, +) -> Result, ApiError> { + let _permit = acquire_heavy_permit(&state).await?; + let mut engine = state.engine.lock().await; + touch_workbook_session(&mut engine, &session_id); + let result = engine + .workbooks + .read_formula_cells(&session_id, &request.sheet_id) + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(serde_json::to_value(result).map_err(internal_error)?)) +} + +async fn recalc_workbook( + State(state): State, + Path(session_id): Path, + Json(request): Json, +) -> Result, ApiError> { + let _permit = acquire_heavy_permit(&state).await?; + let mut engine = state.engine.lock().await; + let path = engine + .files + .get(&session_id) + .cloned() + .ok_or_else(|| (StatusCode::NOT_FOUND, "Unknown workbook session.".to_string()))?; + touch_workbook_session(&mut engine, &session_id); + let result = recalc_cells(&mut engine.recalc, &path, &request.edits, &request.reads) + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(serde_json::to_value(result).map_err(internal_error)?)) +} + +async fn archive_manifest_for_session( + State(state): State, + Path(session_id): Path, +) -> Result, ApiError> { + let _permit = acquire_heavy_permit(&state).await?; + let path = session_path(&state, &session_id).await?; + let entries = archive_manifest(&path) + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(serde_json::json!({ "entries": entries }))) +} + +async fn archive_read_for_session( + State(state): State, + Path(session_id): Path, + Json(request): Json, +) -> Result, ApiError> { + let _permit = acquire_heavy_permit(&state).await?; + let path = session_path(&state, &session_id).await?; + let directory = scratch_directory(&state, "read"); + fs::create_dir_all(&directory).map_err(internal_error)?; + + let result = (|| -> Result, ApiError> { + let extracted = read_entries_to_dir(&path, &request.entries, &directory) + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + extracted + .into_iter() + .map(|entry| { + let content = fs::read(entry.path).map_err(internal_error)?; + Ok(ArchiveReadEntry { + name: entry.name, + content_base64: BASE64.encode(content), + }) + }) + .collect() + })(); + + let _ = fs::remove_dir_all(&directory); + Ok(Json(ArchiveReadResponse { entries: result? })) +} + +async fn archive_scan_for_session( + State(state): State, + Path(session_id): Path, + Json(request): Json, +) -> Result, ApiError> { + let _permit = acquire_heavy_permit(&state).await?; + let path = session_path(&state, &session_id).await?; + let matches = scan_entries_for_text(&path, &request.entries, &request.needle) + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(ArchiveScanResponse { matches })) +} + +async fn archive_save_for_session( + State(state): State, + Path(session_id): Path, + Json(request): Json, +) -> Result { + let _permit = acquire_heavy_permit(&state).await?; + let source_path = session_path(&state, &session_id).await?; + let name = safe_workbook_name(request.name.as_deref().unwrap_or("workbook.xlsx")); + let directory = workbook_directory(&state); + let content_directory = directory.join("patch"); + fs::create_dir_all(&content_directory).map_err(internal_error)?; + let target_path = directory.join(&name); + + let save_result = (|| -> Result<(), ApiError> { + let replacements = write_archive_content(&content_directory, "replace", &request.replacements)?; + let additions = write_archive_content(&content_directory, "add", &request.additions)?; + save_archive( + &source_path, + &target_path, + &replacements, + &request.removals, + &additions, + ) + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(()) + })(); + let _ = fs::remove_dir_all(&content_directory); + save_result?; + + let bytes = fs::read(&target_path).map_err(internal_error)?; + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + + let mut engine = state.engine.lock().await; + let metadata = engine + .workbooks + .open(&target_path) + .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?; + let saved_session_id = metadata.session_id.clone(); + let value = web_metadata_value(metadata, &name, &sha256)?; + engine.files.insert(saved_session_id.clone(), target_path); + engine.metadata.insert(saved_session_id.clone(), value); + engine + .last_access + .insert(saved_session_id.clone(), Instant::now()); + drop(engine); + + let mut response = Response::new(Body::from(bytes)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(XLSX_MIME), + ); + response.headers_mut().insert( + "x-xlsx-session", + HeaderValue::from_str(&saved_session_id).map_err(internal_error)?, + ); + Ok(response) +} + +async fn delete_session( + State(state): State, + Path(session_id): Path, +) -> StatusCode { + if state + .sessions + .sessions + .write() + .await + .remove(&session_id) + .is_some() + { + return StatusCode::NO_CONTENT; + } + + let mut engine = state.engine.lock().await; + let path = engine.files.remove(&session_id); + engine.metadata.remove(&session_id); + engine.last_access.remove(&session_id); + if let Some(path) = path.as_deref() { + engine.recalc.purge(path); + } + let closed = engine.workbooks.close(&session_id).is_ok(); + drop(engine); + + remove_workbook_directory(path); + + if closed { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +fn remove_workbook_directory(path: Option) { + if let Some(path) = path { + if let Some(parent) = path.parent() { + let _ = fs::remove_dir_all(parent); + } + } +} + +async fn cleanup_expired_sessions(state: &AppState) { + let now = Instant::now(); + { + let mut sessions = state.sessions.sessions.write().await; + sessions.retain(|_, session| now.duration_since(session.last_access) < state.session_ttl); + } + + let expired_paths = { + let mut engine = state.engine.lock().await; + let expired_ids = engine + .last_access + .iter() + .filter_map(|(session_id, last_access)| { + (now.duration_since(*last_access) >= state.session_ttl).then(|| session_id.clone()) + }) + .collect::>(); + let mut paths = Vec::with_capacity(expired_ids.len()); + + for session_id in expired_ids { + engine.last_access.remove(&session_id); + let path = engine.files.remove(&session_id); + engine.metadata.remove(&session_id); + if let Some(path) = path.as_deref() { + engine.recalc.purge(path); + } + let _ = engine.workbooks.close(&session_id); + if let Some(path) = path { + paths.push(path); + } + } + paths + }; + + for path in expired_paths { + remove_workbook_directory(Some(path)); + } +} + +async fn session_cleanup_loop(state: AppState) { + let mut ticker = tokio::time::interval(state.cleanup_interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + cleanup_expired_sessions(&state).await; + } +} + +async fn session_path(state: &AppState, session_id: &str) -> Result { + let mut engine = state.engine.lock().await; + let path = engine + .files + .get(session_id) + .cloned() + .ok_or_else(|| (StatusCode::NOT_FOUND, "Unknown workbook session.".to_string()))?; + engine + .last_access + .insert(session_id.to_string(), Instant::now()); + Ok(path) +} + +fn write_archive_content( + directory: &FsPath, + prefix: &str, + items: &[ArchiveContentRequest], +) -> Result, ApiError> { + items + .iter() + .enumerate() + .map(|(index, item)| { + let content = BASE64 + .decode(&item.content_base64) + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + let path = directory.join(format!("{prefix}-{index}.bin")); + fs::write(&path, content).map_err(internal_error)?; + Ok(EntryContent { + name: item.name.clone(), + content_path: path, + }) + }) + .collect() +} + +fn workspace_key(address: SocketAddr) -> String { + address + .to_string() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' || character == '_' { + character + } else { + '_' + } + }) + .collect() +} + +fn prepare_workspace(address: SocketAddr) -> Result { + let base = match std::env::var("XLSX_ENGINE_WORK_ROOT") { + Ok(path) if !path.trim().is_empty() => PathBuf::from(path), + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "XLSX_ENGINE_WORK_ROOT must not be empty.", + )) + } + Err(std::env::VarError::NotPresent) => { + std::env::temp_dir().join("genoffice-xlsx-engine-v2") + } + Err(error) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Unable to read XLSX_ENGINE_WORK_ROOT: {error}"), + )) + } + }; + let root = base.join(workspace_key(address)); + + // main() binds the listener before reaching this function. Therefore no + // other healthy Engine can own this exact endpoint while we remove leftovers + // from a previous crashed process. Other ports/addresses use different roots. + if root.exists() { + fs::remove_dir_all(&root)?; + } + let workbook_root = root.join("workbooks"); + let scratch_root = root.join("scratch"); + fs::create_dir_all(&workbook_root)?; + fs::create_dir_all(&scratch_root)?; + + Ok(EngineWorkspace { + root, + workbook_root, + scratch_root, + }) +} + +fn workbook_directory(state: &AppState) -> PathBuf { + state + .workbook_root + .join(Uuid::new_v4().to_string()) +} + +fn scratch_directory(state: &AppState, kind: &str) -> PathBuf { + state + .scratch_root + .join(format!("{kind}-{}", Uuid::new_v4())) +} + +fn web_metadata_value(metadata: T, name: &str, sha256: &str) -> Result { + let mut value = serde_json::to_value(metadata).map_err(internal_error)?; + let object = value + .as_object_mut() + .ok_or_else(|| internal_error("Workbook metadata was not an object."))?; + object.remove("path"); + object.insert("name".into(), Value::String(name.to_string())); + object.insert("sha256".into(), Value::String(sha256.to_string())); + object.insert("readOnly".into(), Value::Bool(false)); + object.insert("needsSaveAs".into(), Value::Bool(false)); + Ok(value) +} + +fn safe_workbook_name(input: &str) -> String { + let mut name = input + .chars() + .map(|character| match character { + '/' | '\\' | '\0' => '_', + other => other, + }) + .collect::(); + if name.trim().is_empty() { + name = "workbook.xlsx".to_string(); + } + if !name.to_ascii_lowercase().ends_with(".xlsx") { + name.push_str(".xlsx"); + } + name +} + +fn configured_limit_bytes(name: &str, default_mb: usize) -> Result { + let megabytes = match std::env::var(name) { + Ok(raw) => raw.parse::().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} must be a positive integer number of MiB."), + ) + })?, + Err(std::env::VarError::NotPresent) => default_mb, + Err(error) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Unable to read {name}: {error}"), + )) + } + }; + + if megabytes == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} must be greater than zero."), + )); + } + + megabytes.checked_mul(1024 * 1024).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} is too large for this platform."), + ) + }) +} + +fn configured_positive_usize(name: &str, default_value: usize) -> Result { + let value = match std::env::var(name) { + Ok(raw) => raw.parse::().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} must be a positive integer."), + ) + })?, + Err(std::env::VarError::NotPresent) => default_value, + Err(error) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Unable to read {name}: {error}"), + )) + } + }; + + if value == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} must be greater than zero."), + )); + } + Ok(value) +} + +fn configured_seconds(name: &str, default_seconds: u64) -> Result { + let seconds = match std::env::var(name) { + Ok(raw) => raw.parse::().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} must be a positive integer number of seconds."), + ) + })?, + Err(std::env::VarError::NotPresent) => default_seconds, + Err(error) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Unable to read {name}: {error}"), + )) + } + }; + + if seconds == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} must be greater than zero."), + )); + } + Ok(Duration::from_secs(seconds)) +} + +fn internal_error(error: impl ToString) -> ApiError { + (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let listen_addr = std::env::var("XLSX_ENGINE_LISTEN") + .unwrap_or_else(|_| "127.0.0.1:7301".to_string()); + let address: SocketAddr = listen_addr.parse()?; + let max_workbook_bytes = + configured_limit_bytes("XLSX_ENGINE_MAX_WORKBOOK_MB", DEFAULT_MAX_WORKBOOK_MB)?; + let max_request_bytes = + configured_limit_bytes("XLSX_ENGINE_MAX_REQUEST_MB", DEFAULT_MAX_REQUEST_MB)?; + if max_request_bytes < max_workbook_bytes { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "XLSX_ENGINE_MAX_REQUEST_MB must be greater than or equal to XLSX_ENGINE_MAX_WORKBOOK_MB.", + ) + .into()); + } + let max_heavy_requests = configured_positive_usize( + "XLSX_ENGINE_MAX_HEAVY_REQUESTS", + DEFAULT_MAX_HEAVY_REQUESTS, + )?; + let heavy_queue_timeout = configured_seconds( + "XLSX_ENGINE_HEAVY_QUEUE_TIMEOUT_SECS", + DEFAULT_HEAVY_QUEUE_TIMEOUT_SECS, + )?; + let session_ttl = configured_seconds("XLSX_ENGINE_SESSION_TTL_SECS", DEFAULT_SESSION_TTL_SECS)?; + let cleanup_interval = configured_seconds( + "XLSX_ENGINE_CLEANUP_INTERVAL_SECS", + DEFAULT_CLEANUP_INTERVAL_SECS, + )?; + if cleanup_interval > session_ttl { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "XLSX_ENGINE_CLEANUP_INTERVAL_SECS must be less than or equal to XLSX_ENGINE_SESSION_TTL_SECS.", + ) + .into()); + } + + // Bind first. Only after the endpoint is exclusively ours is it safe to + // remove this endpoint's workspace left by an ungraceful previous process. + let listener = tokio::net::TcpListener::bind(address).await?; + let workspace = prepare_workspace(address)?; + let workspace_root = workspace.root.clone(); + + let state = AppState { + sessions: Arc::new(MemorySessionStore::default()), + engine: Arc::new(Mutex::new(EngineState::default())), + metrics: Arc::new(observability::ServiceMetrics::default()), + heavy_slots: Arc::new(Semaphore::new(max_heavy_requests)), + max_heavy_requests, + heavy_queue_timeout, + max_workbook_bytes, + session_ttl, + cleanup_interval, + workbook_root: workspace.workbook_root, + scratch_root: workspace.scratch_root, + }; + tokio::spawn(session_cleanup_loop(state.clone())); + + let app = Router::new() + .route("/health", get(health)) + .route("/metrics", get(observability::metrics)) + .route("/v1/sessions", post(create_session)) + .route("/v1/workbooks", post(open_workbook)) + .route("/v1/workbooks/blank", post(create_blank_workbook)) + .route("/v1/sessions/{session_id}", get(get_session_metadata).delete(delete_session)) + .route("/v1/sessions/{session_id}/ranges", post(read_range)) + .route( + "/v1/sessions/{session_id}/formulas", + post(read_formula_cells), + ) + .route( + "/v1/sessions/{session_id}/recalc", + post(recalc_workbook), + ) + .route( + "/v1/sessions/{session_id}/archive/manifest", + get(archive_manifest_for_session), + ) + .route( + "/v1/sessions/{session_id}/archive/read", + post(archive_read_for_session), + ) + .route( + "/v1/sessions/{session_id}/archive/scan", + post(archive_scan_for_session), + ) + .route( + "/v1/sessions/{session_id}/archive/save", + post(archive_save_for_session), + ) + .layer(DefaultBodyLimit::max(max_request_bytes)) + .layer(middleware::from_fn_with_state( + state.clone(), + observability::request_observability, + )) + .with_state(state); + + println!( + "{}", + serde_json::json!({ + "event": "service_started", + "service": "xlsx-engine-service", + "listen": address.to_string(), + "maxWorkbookMb": max_workbook_bytes / (1024 * 1024), + "maxRequestMb": max_request_bytes / (1024 * 1024), + "sessionTtlSecs": session_ttl.as_secs(), + "maxHeavyRequests": max_heavy_requests, + "heavyQueueTimeoutSecs": heavy_queue_timeout.as_secs(), + }) + ); + + let result = axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await; + + // Graceful shutdown leaves no workspace. If the process is killed, the + // next process that successfully binds this endpoint removes it at startup. + let _ = fs::remove_dir_all(&workspace_root); + result?; + Ok(()) +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn heavy_request_admission_times_out_before_work_starts() { + let slots = Arc::new(Semaphore::new(1)); + let held = slots.clone().acquire_owned().await.expect("first permit"); + + match acquire_heavy_permit_with(slots.clone(), Duration::from_millis(20)).await { + Err((status, message)) => { + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!(message.contains("busy")); + } + Ok(_) => panic!("a saturated admission queue must time out"), + } + + drop(held); + let permit = acquire_heavy_permit_with(slots, Duration::from_millis(20)) + .await + .expect("permit after release"); + drop(permit); + } +} diff --git a/services/xlsx-engine-service/src/observability.rs b/services/xlsx-engine-service/src/observability.rs new file mode 100644 index 000000000..a68b21c53 --- /dev/null +++ b/services/xlsx-engine-service/src/observability.rs @@ -0,0 +1,149 @@ +use std::{ + sync::atomic::{AtomicU64, Ordering}, + time::Instant, +}; + +use axum::{ + body::Body, + extract::{Request, State}, + http::{header, HeaderValue}, + middleware::Next, + response::Response, +}; +use serde_json::json; +use uuid::Uuid; + +use crate::AppState; + +const REQUEST_ID_HEADER: &str = "x-request-id"; + +#[derive(Default)] +pub(crate) struct ServiceMetrics { + requests_total: AtomicU64, + server_errors_total: AtomicU64, + heavy_admission_rejects_total: AtomicU64, +} + +impl ServiceMetrics { + pub(crate) fn record_heavy_admission_reject(&self) { + self.heavy_admission_rejects_total + .fetch_add(1, Ordering::Relaxed); + } + + fn requests_total(&self) -> u64 { + self.requests_total.load(Ordering::Relaxed) + } + + fn server_errors_total(&self) -> u64 { + self.server_errors_total.load(Ordering::Relaxed) + } + + fn heavy_admission_rejects_total(&self) -> u64 { + self.heavy_admission_rejects_total.load(Ordering::Relaxed) + } +} + +fn incoming_request_id(request: &Request) -> Option { + let value = request.headers().get(REQUEST_ID_HEADER)?.to_str().ok()?; + if value.is_empty() || value.len() > 128 { + return None; + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return None; + } + Some(value.to_string()) +} + +fn generated_request_id() -> String { + format!("req_{}", Uuid::new_v4().simple()) +} + +pub(crate) async fn request_observability( + State(state): State, + request: Request, + next: Next, +) -> Response { + let request_id = incoming_request_id(&request).unwrap_or_else(generated_request_id); + let method = request.method().as_str().to_string(); + let path = request.uri().path().to_string(); + let started = Instant::now(); + + state.metrics.requests_total.fetch_add(1, Ordering::Relaxed); + let mut response = next.run(request).await; + let status = response.status(); + if status.is_server_error() { + state + .metrics + .server_errors_total + .fetch_add(1, Ordering::Relaxed); + } + + if let Ok(value) = HeaderValue::from_str(&request_id) { + response.headers_mut().insert(REQUEST_ID_HEADER, value); + } + + println!( + "{}", + json!({ + "event": "http_request", + "requestId": request_id, + "method": method, + "path": path, + "status": status.as_u16(), + "durationMs": started.elapsed().as_millis(), + }) + ); + + response +} + +pub(crate) async fn metrics(State(state): State) -> Response { + let workbook_sessions = { + let engine = state.engine.lock().await; + engine.metadata.len() + }; + let lightweight_sessions = state.sessions.sessions.read().await.len(); + + let body = format!( + concat!( + "# HELP genoffice_xlsx_requests_total HTTP requests observed by the XLSX engine.\n", + "# TYPE genoffice_xlsx_requests_total counter\n", + "genoffice_xlsx_requests_total {}\n", + "# HELP genoffice_xlsx_server_errors_total HTTP 5xx responses returned by the XLSX engine.\n", + "# TYPE genoffice_xlsx_server_errors_total counter\n", + "genoffice_xlsx_server_errors_total {}\n", + "# HELP genoffice_xlsx_heavy_admission_rejects_total Heavy requests rejected before work started because the queue timed out.\n", + "# TYPE genoffice_xlsx_heavy_admission_rejects_total counter\n", + "genoffice_xlsx_heavy_admission_rejects_total {}\n", + "# HELP genoffice_xlsx_heavy_slots Configured heavy-work admission slots.\n", + "# TYPE genoffice_xlsx_heavy_slots gauge\n", + "genoffice_xlsx_heavy_slots {}\n", + "# HELP genoffice_xlsx_heavy_slots_available Heavy-work slots currently available.\n", + "# TYPE genoffice_xlsx_heavy_slots_available gauge\n", + "genoffice_xlsx_heavy_slots_available {}\n", + "# HELP genoffice_xlsx_workbook_sessions Active workbook sessions.\n", + "# TYPE genoffice_xlsx_workbook_sessions gauge\n", + "genoffice_xlsx_workbook_sessions {}\n", + "# HELP genoffice_xlsx_lightweight_sessions Active lightweight reserved sessions.\n", + "# TYPE genoffice_xlsx_lightweight_sessions gauge\n", + "genoffice_xlsx_lightweight_sessions {}\n" + ), + state.metrics.requests_total(), + state.metrics.server_errors_total(), + state.metrics.heavy_admission_rejects_total(), + state.max_heavy_requests, + state.heavy_slots.available_permits(), + workbook_sessions, + lightweight_sessions, + ); + + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"), + ); + response +}