diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..594f92c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: test + +# Runs the app build + the CAP app's jest suite on every pull request (and +# on pushes to main): assemble the app from src/ + the committed core mirror +# (run/input/core), install and run its tests. Make this check "Required" in +# the branch protection rules of `main` so a PR can only be merged once the +# tests pass. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - run: npm run assemble + # one install covers the app AND the vendored core (its deps are part + # of the app lock, placed under core/node_modules/) + - run: npm ci + working-directory: run/output/cap2UI5 + - run: npm test diff --git a/.github/workflows/update_cap.yml b/.github/workflows/update_cap.yml new file mode 100644 index 0000000..d21ab8f --- /dev/null +++ b/.github/workflows/update_cap.yml @@ -0,0 +1,98 @@ +name: update cap + +# The one pipeline of this repo: mirror the published core package from +# builder-abap2UI5-js, assemble the full CAP app (src/ + vendored core + +# webapp overlay), gate it with the app's jest suite, commit the refreshed +# mirror here and publish the built app 1:1 to the deployable repo +# cap2UI5/cap2UI5. +# +# Runs event-driven on every push to main. That includes the trigger +# commits builder-abap2UI5-js pushes here via deploy key (its trigger_cap +# workflow updates UPSTREAM_HEAD after a core rebuild), so the app follows +# every core update — plus changes to the build tooling itself. The nightly +# cron remains as a safety net. +# +# Cross-repo push uses an SSH deploy key: the public half is registered on +# cap2UI5/cap2UI5 with write access, the private half is the secret +# ACTION_KEY_APP here. Without the secret the workflow still builds and +# tests, it just skips the publish. + +on: + push: + branches: [main] + schedule: + - cron: "0 6 * * *" # 06:00 UTC nightly — after the builder-abap2UI5-js pipelines (03:00–05:00) + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: update-cap + cancel-in-progress: false + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: main + - uses: actions/setup-node@v5 + with: + node-version: 22 + - run: npm run mirror_core + - name: commit refreshed mirror + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add run/input + if git diff --cached --quiet; then + echo "mirror unchanged" + else + git commit -m "mirror: update run/input/core ($(cut -c1-12 run/input/UPSTREAM_COMMIT))" + git pull --rebase origin main + git push origin HEAD:main + fi + - run: npm run assemble + # one install covers the app AND the vendored core (its deps are part + # of the app lock, placed under core/node_modules/) + - run: npm ci + working-directory: run/output/cap2UI5 + - run: npm test + - name: publish built app to cap2UI5/cap2UI5 + env: + ACTION_KEY_APP: ${{ secrets.ACTION_KEY_APP }} + run: | + set -euo pipefail + if [ -z "$ACTION_KEY_APP" ]; then + echo "::warning::secret ACTION_KEY_APP not set — skipping publish to cap2UI5/cap2UI5" + exit 0 + fi + UPSTREAM=$(cat run/input/UPSTREAM_COMMIT) + APP="$RUNNER_TEMP/cap2UI5" + + mkdir -p ~/.ssh + echo "$ACTION_KEY_APP" > ~/.ssh/app_key + chmod 600 ~/.ssh/app_key + ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null + export GIT_SSH_COMMAND="ssh -i ~/.ssh/app_key -o IdentitiesOnly=yes" + + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git clone --depth 1 --branch main git@github.com:cap2UI5/cap2UI5.git "$APP" + + PUBLISH_TARGET="$APP" npm run publish + + cd "$APP" + git add -A + if git diff --cached --quiet; then + echo "app unchanged — no publish commit" >> "$GITHUB_STEP_SUMMARY" + else + git commit -m "build: publish app (core @ ${UPSTREAM:0:12})" \ + -m "builder: ${GITHUB_SHA}" \ + -m "run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + git push origin HEAD:main + echo "published to cap2UI5/cap2UI5@main: $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb36405 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.mirror_tmp/ +.DS_Store +run/output/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8fe8a25 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# AGENTS.md — builder-cap2UI5 + +Guidance for AI agents and contributors. Single source of truth for this +repository's structure and rules. Read before making any change. + +## The cap2UI5 ecosystem (where this repo fits) + +cap2UI5 brings the [abap2UI5](https://github.com/abap2UI5/abap2UI5) concept +to CAP/Node.js, organized as six repos under the `cap2UI5` org (since the +monorepo split, 2026-07): + +| Repo | Role | +|---|---| +| [builder-abap2UI5-js](https://github.com/cap2UI5/builder-abap2UI5-js) | generates the platform-neutral core package `abap2UI5` (engine, transpiled classes, webapp, samples) into its `core/` | +| **builder-cap2UI5** (this repo) | generates the full CAP app from `src/` + the mirrored core and publishes it 1:1 into the app repo | +| [cap2UI5](https://github.com/cap2UI5/cap2UI5) | the finished, deployable CAP app — fully generated by this repo (except `.github/`) | +| [builder-cap2UI5-web](https://github.com/cap2UI5/builder-cap2UI5-web) | browser build tooling → [web-cap2UI5-build](https://github.com/cap2UI5/web-cap2UI5-build) → GitHub Pages | +| [docs](https://github.com/cap2UI5/docs) | VitePress documentation | + +Upstream trigger: builder-abap2UI5-js:`trigger_cap` pushes a new +`UPSTREAM_HEAD` here (deploy key `ACTION_KEY_CAP`) after a core rebuild; +that push starts `update_cap`. + +## Layout + +| Path | What it is | Hand-edit? | +|---|---|---| +| `src/` | hand-written SOURCE of the CAP app: skeleton (`server.js`, `z2ui5-service.*`, `db/`, `mta.yaml`, `xs-security.json`), platform wiring (draft store → CDS entity `cap2ui5.z2ui5_t_01`, app discovery → `srv/app/`), the custom app `srv/app/z2ui5_cl_app_read_odata.js`, app `README.md`, `.devcontainer/`, `AGENTS.md` (published into the app repo) | **yes — app changes go here** | +| `run/input/core/` | mirror of builder-abap2UI5-js:`core/` (committed; upstream sha in `run/input/UPSTREAM_COMMIT`) | never — rewritten by `npm run mirror_core` | +| `run/output/cap2UI5/` | the assembled app (gitignored staging; `node_modules` preserved across builds) | never | +| `scripts/` | `mirror-core.js`, `assemble-cap.js`, `publish-cap.js` | yes | +| `UPSTREAM_HEAD` | trigger slot written by builder-abap2UI5-js | never by hand | + +## Build & test + +```bash +npm run mirror_core # snapshot builder-abap2UI5-js:core/ → run/input/core + # (MIRROR_SOURCE=/path/to/builder-abap2UI5-js for a local checkout) +npm run assemble # src/ + vendored core + webapp overlay → run/output/cap2UI5 +cd run/output/cap2UI5 && npm ci && cd ../../.. # ONE install covers app + core +npm test # the app's jest suite, run inside run/output/cap2UI5 +npm run publish # 1:1 copy → ../cap2UI5 (sibling checkout) or PUBLISH_TARGET=… +npm run build_cap # assemble + publish +``` + +## The dependency mechanics (important, easy to get wrong) + +- In `src/`, the app links the framework as + `"abap2UI5": "file:../run/input/core"` (run `mirror_core` before working + in `src/` standalone). +- The assemble step **vendors** the core into the app (`core/`) and rewrites + the dependency to `file:./core` in `package.json`/`package-lock.json` + (ordered string rewrites in `scripts/assemble-cap.js`). +- Because the vendored core is INSIDE the app's package root, npm manages + its deps as part of the app tree: assemble merges the core's own lock + entries into the app lock under `core/node_modules/`. That keeps the + published lock deterministic and offline (no registry roundtrip) and one + `npm ci` at the app root installs everything. +- `publish-cap.js` wipes the target except `node_modules`, `.git` and + `.github` — the app repo's workflows (`test`, `trigger_web`) are owned by + the app repo and survive every publish; **everything else there is + regenerated**, including README, devcontainer and AGENTS.md (all shipped + from `src/`). + +## Workflows (.github/workflows) + +- `update_cap`: on push to main (incl. `UPSTREAM_HEAD` triggers), nightly + 06:00 UTC (after the builder-abap2UI5-js pipelines at 03:00–05:00) and + manual. Mirrors the core, commits the refreshed mirror, assembles, runs + the app's jest suite, then publishes to cap2UI5/cap2UI5 via deploy key + (secret `ACTION_KEY_APP`; skipped with a warning when unset). +- `test`: PR gate — assemble from the committed mirror + `npm ci` + jest. + +## Rules + +- Never edit the cap2UI5 app repo directly — change `src/` here and + republish. Your own apps go in `src/srv/app/` or an external + `Z2UI5_APP_DIRS` folder. +- Never edit `run/input/core` — it mirrors builder-abap2UI5-js; framework + changes go into builder-abap2UI5-js:`src/`. +- Keep the rewrite pairs in `assemble-cap.js` in sync with the dependency + paths if you ever move `run/input/core` or the vendored `core/`. +- The app's jest suite gates every publish; keep it green. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..64967ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +All project guidance lives in **[AGENTS.md](AGENTS.md)** — the single source +of truth for this repository (ecosystem overview, folder scheme, build and +publish flow, generated-vs-hand-written rules). + +Read `AGENTS.md` before making any change. diff --git a/README.md b/README.md index fba98ba..3bd4a01 100644 --- a/README.md +++ b/README.md @@ -1 +1,100 @@ -# builder-cap2UI5 \ No newline at end of file +# builder-cap2UI5 — the CAP app build project + +[![test](https://github.com/cap2UI5/builder-cap2UI5/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/cap2UI5/builder-cap2UI5/actions/workflows/test.yml) +[![update cap](https://github.com/cap2UI5/builder-cap2UI5/actions/workflows/update_cap.yml/badge.svg?branch=main)](https://github.com/cap2UI5/builder-cap2UI5/actions/workflows/update_cap.yml) + +This project **generates the deployable CAP app** +[cap2UI5](https://github.com/cap2UI5/cap2UI5) from the hand-maintained source +in [`src/`](src/) plus the published core package (the npm package +`abap2UI5`), mirrored from +[builder-abap2UI5-js](https://github.com/cap2UI5/builder-abap2UI5-js). + +> [!IMPORTANT] +> Everything in this project is generated automatically — by AI (Claude) and +> by a sync pipeline that consumes the upstream +> [abap2UI5](https://github.com/abap2UI5/abap2UI5) sources. Review and test +> before relying on it. + +> [!IMPORTANT] +> **The cap2UI5 app repo is a build artifact — do not hand-edit it.** Every +> publish wipes and rewrites it. The hand-written source lives in +> [`src/`](src/); edit there and re-run the build. (Your own apps go in +> `src/srv/app/` or an external `Z2UI5_APP_DIRS` folder — never in the app +> repo's `srv/app/`, which is regenerated.) + +cap2UI5 is organized as three repositories, from framework to finished app: + +| Repository | What it is | +|---|---| +| [`builder-abap2UI5-js`](https://github.com/cap2UI5/builder-abap2UI5-js) | abap2UI5 ported to JavaScript — generates the core package `abap2UI5` | +| [`builder-cap2UI5`](https://github.com/cap2UI5/builder-cap2UI5) (here) | generates the full CAP app from `src/` + the published core | +| [`cap2UI5`](https://github.com/cap2UI5/cap2UI5) | the finished, deployable CAP app (**generated**) | + +## Build + +```bash +npm run mirror_core # snapshot builder-abap2UI5-js:core/ → run/input/core + # (MIRROR_SOURCE=/path/to/builder-abap2UI5-js uses a local checkout) + +npm run build_cap # assemble + publish → regenerate ../cap2UI5 (a sibling + # checkout of cap2UI5/cap2UI5; override with PUBLISH_TARGET=…) +``` + +No dependencies — the scripts are plain Node. What the build does: + +| Step | What it does | +|---|---| +| `npm run mirror_core` | snapshot the published core package from builder-abap2UI5-js into `run/input/core` (the upstream commit is recorded in `run/input/UPSTREAM_COMMIT`) | +| `npm run assemble` | `src/` → `run/output/cap2UI5` (verbatim), vendor `run/input/core` → `core/`, then overlay the core's `app/z2ui5/webapp` → `app/z2ui5/webapp` (the copy served by CDS statics and zipped by the mta html5 module — taken from the published core, so the two cannot drift) | +| `npm run publish` | 1:1 copy `run/output/cap2UI5` → the app checkout (`../cap2UI5` or `PUBLISH_TARGET`) — the very last step | +| `npm run build_cap` | `assemble` then `publish` | +| `npm test` | runs the CAP app's own jest suite in `run/output/cap2UI5` (needs one `npm ci` there first — it covers the app **and** the vendored core) | + +The framework is consumed as the npm dependency `abap2UI5`: `src/` links the +mirrored package (`file:../run/input/core`), the published app the **vendored +copy** (`file:./core`) — the app repo is fully self-contained. The build's +only transformations: that dependency-path rewrite in `package.json` / +`package-lock.json`, plus merging the core's own lock entries into the app +lock (under `core/node_modules/`), since the vendored core sits inside the +app's package root and npm manages its deps as part of the app tree. + +## `src/` — the hand-written source + +`src/` is the **source of truth** for the CAP app: the skeleton (`server.js`, +`z2ui5-service.*`, `db/`, `package.json`, `mta.yaml`, `srv/external/`), the +platform wiring (draft store → CDS entity `cap2ui5.z2ui5_t_01`, app discovery +→ `srv/app/`), the bundled custom app `srv/app/z2ui5_cl_app_read_odata.js`, +and the docs. Published as the app repo minus the vendored core and the +generated webapp overlay. + +`src/` is itself a fully functional minimal CAP project — the starting point +of cap2UI5 with the same basic setup as abap2UI5: a mini frontend +(`app/index.html`), the http service (`POST /rest/root/z2ui5`) and the draft +persistence. Run and test it standalone (it links the core via +`file:../run/input/core`, so run `npm run mirror_core` first): + +```bash +cd src +npm install +npx cds watch # → http://localhost:4004/index.html +npm test # jest: boots the server via cds.test(), asserts all three layers +``` + +See the [minimal base section](src/README.md#the-minimal-base) in the source +README for details. + +## Pipeline + +The **update cap** workflow (`.github/workflows/update_cap.yml`) runs on +every push to `main` — including the trigger commits builder-abap2UI5-js +pushes here via deploy key (its `trigger_cap` workflow updates +`UPSTREAM_HEAD` after a core rebuild) — plus a nightly cron as safety net. +It mirrors the core, assembles the app, gates it with the app's jest suite, +commits the refreshed `run/input/core` here and publishes the built app 1:1 +to [cap2UI5](https://github.com/cap2UI5/cap2UI5) (deploy key, secret +`ACTION_KEY_APP`). The **test** workflow runs the same assemble + jest gate +on every pull request. + +## License + +MIT. diff --git a/UPSTREAM_HEAD b/UPSTREAM_HEAD new file mode 100644 index 0000000..3ff4be6 --- /dev/null +++ b/UPSTREAM_HEAD @@ -0,0 +1 @@ +8d084d2437340b9588b48ccf1f4c728cb65b6617 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9eac884 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "builder-cap2ui5", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "builder-cap2ui5", + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e53ee6 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "builder-cap2ui5", + "version": "1.0.0", + "private": true, + "description": "Builder project for the full CAP app \u2014 mirrors the published core package (abap2UI5) from builder-abap2UI5-js, assembles src/ + the vendored core + its webapp and publishes the result 1:1 into the cap2UI5 app repository", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "scripts": { + "mirror_core": "node scripts/mirror-core.js", + "assemble": "node scripts/assemble-cap.js", + "publish": "node scripts/publish-cap.js", + "build_cap": "node scripts/assemble-cap.js && node scripts/publish-cap.js", + "test": "npm --prefix run/output/cap2UI5 test" + }, + "repository": "github:cap2UI5/builder-cap2UI5" +} diff --git a/run/input/UPSTREAM_COMMIT b/run/input/UPSTREAM_COMMIT new file mode 100644 index 0000000..3ff4be6 --- /dev/null +++ b/run/input/UPSTREAM_COMMIT @@ -0,0 +1 @@ +8d084d2437340b9588b48ccf1f4c728cb65b6617 diff --git a/run/input/core/README.md b/run/input/core/README.md new file mode 100644 index 0000000..52aceb8 --- /dev/null +++ b/run/input/core/README.md @@ -0,0 +1,52 @@ +# abap2UI5 core — the platform-neutral framework package + +This is the npm package `abap2UI5`: the engine, the transpiled framework +classes, the z2ui5 webapp and the bundled samples — **no CAP, no express, no +platform code**. Every platform consumes this one package: + +| Consumer | How | +|---|---| +| [`cap2UI5`](https://github.com/cap2UI5/cap2UI5) | the full CAP app — `"abap2UI5": "file:./core"` (vendored copy of this package) | +| [`adapters/cap/`](../adapters/cap/) | minimal CAP wrapper | +| [`adapters/node/`](../adapters/node/) | bare `node:http` server | +| [`adapters/express/`](../adapters/express/) | express middleware | +| [`adapters/web/`](../adapters/web/) | serverless browser bundle | + +> [!IMPORTANT] +> **This folder is a generated build artifact** — assembled from the +> hand-written source in [`src/`](../src/) plus the transpiled +> upstream [abap2UI5](https://github.com/abap2UI5/abap2UI5) sources. Do not +> hand-edit it; edit `src/` and re-run `npm run build_core` at the +> [repo root](../). + +## The seam + +The platform surface is tiny — see [`srv/z2ui5/engine.js`](srv/z2ui5/engine.js): + +```js +const engine = require("abap2UI5/engine"); +engine.set_store({ load, save }); // draft persistence port +engine.register_app_dir("/my/apps"); // or register_app_class(Cls) +http POST → await engine.roundtrip(body, reqInfo) // → JSON string +http GET → engine.bootstrap_html(reqInfo) // → { html, headers } +statics → engine.WEBAPP_DIR (+ engine.ui5_resources_dir()) +``` + +Everything below the engine is platform-agnostic: without an injected draft +store a volatile in-memory fallback is used (fine for tests, wrong for +production — inject your own). + +## Layout + +| Path | What it is | +|---|---| +| `srv/z2ui5/` | the framework: `engine.js`, the ports (`z2ui5_port.js`, `z2ui5_asset.js`), the class trees `00/`–`99/` | +| `srv/app/samples/` | the bundled sample apps (transpiled from upstream) | +| `app/z2ui5/webapp/` | the z2ui5 frontend (mirrored + patched from upstream) | + +The internal layout intentionally mirrors the historical CAP project paths so +the exports map, asset port and serialized draft references stay stable. + +## License + +MIT. diff --git a/run/input/core/app/z2ui5/webapp/Component.js b/run/input/core/app/z2ui5/webapp/Component.js new file mode 100644 index 0000000..b110470 --- /dev/null +++ b/run/input/core/app/z2ui5/webapp/Component.js @@ -0,0 +1,249 @@ +sap.ui.define( + [ + "sap/ui/core/UIComponent", + "z2ui5/model/models", + "z2ui5/core/Server", + "sap/ui/VersionInfo", + "z2ui5/core/DebugTool", + "z2ui5/core/Lib", + "z2ui5/core/AppState", + "z2ui5/Util", + "sap/ui/core/routing/HashChanger", + ], + ( + UIComponent, + Models, + Server, + VersionInfo, + DebugTool, + Lib, + AppState, + DateUtil, + HashChanger, + ) => { + "use strict"; + + return UIComponent.extend("z2ui5.Component", { + metadata: { + manifest: "json", + interfaces: ["sap.ui.core.IAsyncContentCreation"], + }, + + init() { + // The global "z2ui5" object holds the shared state for the whole + // app; core/AppState owns it. initGlobal() creates the global if + // needed, resets the internal state to clean defaults and provides + // a fresh oConfig - so the base init() and all helpers can rely on + // a fully initialized global from here on. + AppState.initGlobal(); + + UIComponent.prototype.init.call(this); + + AppState.getGlobal("oConfig").ComponentData = this.getComponentData(); + + // The date helpers are a public contract: apps use them via the + // z2ui5.Util global (XML view formatter strings) or via + // core:require of the z2ui5/Util module. Publish the global here - + // since the custom controls were split out of App.controller.js, + // nothing else loads the module eagerly anymore. + AppState.setGlobal("Util", DateUtil); + + AppState.state.oDeviceModel = Models.createDeviceModel(); + this.setModel(AppState.state.oDeviceModel, "device"); + + this._initLaunchpad(); + this._initVersionInfo(); + + this._installUnloadListener(); + this._installDebugToolShortcut(); + this._installScrollListener(); + + // The stopped router removed with the manifest routing section used + // to initialize the HashChanger (and its underlying hasher + // singleton) as a side effect. Without that init hasher never + // learns the URL's current hash, so the app-state cleanup after + // every roundtrip (View1._updateBrowserHistory calling + // replaceHash("")) is treated as a change and rewrites the URL to + // "...#" - every app start ended with a dangling "#". Initialize it + // explicitly; inside the FLP the shell has already done this and + // init() is a guarded no-op. + HashChanger.getInstance().init(); + }, + + // ------------------------------------------------------------------ + // Event listeners installed in init() and removed in exit() + // ------------------------------------------------------------------ + + _installUnloadListener() { + this._boundUnload = this._onUnload.bind(this); + // Safari on iOS does not fire "beforeunload" reliably, so we use + // "pagehide" there. iPads on iPadOS 13+ report a Mac user agent + // ("desktop site" default) - the touch-point probe catches those, + // while real Macs report 0 touch points. + const isIos = + /iPad|iPhone/.test(navigator.userAgent) || + (navigator.userAgent.includes("Mac") && navigator.maxTouchPoints > 1); + this._unloadEvent = isIos ? "pagehide" : "beforeunload"; + window.addEventListener(this._unloadEvent, this._boundUnload); + }, + + _installDebugToolShortcut() { + // Ctrl + F12 opens / closes the in-app debug tool. + this._boundKeydown = (event) => { + if (event.ctrlKey && event.key === "F12") { + const state = AppState.state; + if (!state.debugTool) state.debugTool = new DebugTool(); + state.debugTool.toggle(); + } + }; + document.addEventListener("keydown", this._boundKeydown); + }, + + _installScrollListener() { + // Scroll events do not bubble, but they do trigger capture-phase + // listeners on ancestors - a single document-level listener observes + // every scrollable container. Server.onScrollCapture records the + // last scrolled element per view slot for the S_SCROLL request info. + this._boundScroll = (event) => Server.onScrollCapture(event); + document.addEventListener("scroll", this._boundScroll, { + capture: true, + passive: true, + }); + }, + + // ------------------------------------------------------------------ + // SAP Fiori Launchpad integration (only when running inside FLP) + // ------------------------------------------------------------------ + + _initLaunchpad() { + const Container = sap.ui.require("sap/ushell/Container"); + if (!Container) return; // not running inside the launchpad -> nothing to do + + const launchpad = { Container }; + this._launchpad = launchpad; + AppState.state.oLaunchpad = launchpad; + + // The FLP services load asynchronously. By the time they resolve, the + // component may already have been destroyed (e.g. user navigated away + // before the services were ready). setIfAlive guards against writing + // to a stale launchpad object in that case. + const setIfAlive = (key, value) => { + if (Lib.isAlive(this) && this._launchpad === launchpad) { + launchpad[key] = value; + } + }; + + // ShellUIService is a UI5 service (factory + // sap.ushell.ui5service.ShellUIService, declared in manifest.json), + // not a Container service. Requesting it via Container.getServiceAsync + // resolves to sap/ushell/services/ShellUIService.js, which does not + // exist in the (ABAP) launchpad and fails with a 404. The component's + // getService() honors the manifest declaration and returns the + // correctly scoped instance. + this.getService("ShellUIService") + .then((s) => setIfAlive("ShellUIService", s)) + .catch((e) => + Lib.logError("Component: ShellUIService init failed", e), + ); + + Container.getServiceAsync("CrossApplicationNavigation") + .then((s) => setIfAlive("CrossAppNavigator", s)) + .catch((e) => + Lib.logError( + "Component: CrossApplicationNavigation init failed", + e, + ), + ); + + sap.ui.require( + ["sap/ushell/services/AppConfiguration"], + (ac) => setIfAlive("AppConfiguration", ac), + (e) => Lib.logError("Component: AppConfiguration init failed", e), + ); + }, + + async _initVersionInfo() { + try { + const info = await VersionInfo.load(); + if (Lib.isAlive(this)) { + AppState.getGlobal("oConfig").S_UI5 = { + VERSION: info.version, + BUILDTIMESTAMP: info.buildTimestamp, + GAV: info.gav, + THEME: this._getTheme(), + }; + } + } catch (e) { + Lib.logError("Component: VersionInfo load failed", e); + } + }, + + _getTheme() { + // sap/ui/core/Theming only exists since UI5 1.118, so it must not be + // a hard dependency of this module - older bootstraps (e.g. 1.108) + // would fail to load the component. On 1.118+ the core itself loads + // Theming, so the probing require finds it; otherwise fall back to + // the legacy Configuration API. + try { + const Theming = sap.ui.require("sap/ui/core/Theming"); + if (Theming?.getTheme) return Theming.getTheme(); + /* ui5lint-disable no-globals, no-deprecated-api -- + deliberate fallback for UI5 releases without sap/ui/core/Theming + (added in 1.118); the modern API is used in the branch above. */ + if (sap.ui.getCore) { + return sap.ui.getCore().getConfiguration().getTheme(); + } + /* ui5lint-enable no-globals, no-deprecated-api */ + } catch (e) { + Lib.logError("Component: reading theme failed", e); + } + return ""; + }, + + _onUnload() { + window.removeEventListener(this._unloadEvent, this._boundUnload); + this.destroy(); + }, + + // ------------------------------------------------------------------ + // Component teardown + // ------------------------------------------------------------------ + + exit() { + window.removeEventListener(this._unloadEvent, this._boundUnload); + document.removeEventListener("keydown", this._boundKeydown); + document.removeEventListener("scroll", this._boundScroll, { + capture: true, + }); + + // The debug tool is created lazily by the Ctrl+F12 shortcut - + // destroy it (which also closes its dialog) so a re-launch (FLP) + // does not leak the control instance. + if (AppState.state.debugTool) { + AppState.state.debugTool.destroy(); + AppState.state.debugTool = null; + } + + Server.endSession(); + + // Robust launchpad teardown: + // 1. Clear the FLP dirty flag so it does not carry over into the + // next app the user opens. + // 2. Detach the shared launchpad object so a subsequent re-launch + // starts from a clean state and any still-pending init Promises + // become no-ops via setIfAlive(). + try { + this._launchpad?.Container?.setDirtyFlag?.(false); + } catch (e) { + Lib.logError("Component: clearing FLP dirty flag failed", e); + } + if (AppState.state.oLaunchpad === this._launchpad) { + AppState.state.oLaunchpad = null; + } + this._launchpad = null; + + if (UIComponent.prototype.exit) UIComponent.prototype.exit.call(this); + }, + }); + }, +); diff --git a/run/input/core/app/z2ui5/webapp/Util.js b/run/input/core/app/z2ui5/webapp/Util.js new file mode 100644 index 0000000..fb9b864 --- /dev/null +++ b/run/input/core/app/z2ui5/webapp/Util.js @@ -0,0 +1,35 @@ +// PUBLIC date helpers for apps: exposed as the z2ui5.Util global and as +// the z2ui5/Util module (used in XML view formatter strings). Part of the +// public contract - do not rename or change the existing functions. +sap.ui.define([], () => { + "use strict"; + + // Splits an 8-character ABAP date string "YYYYMMDD" into the [year, month, + // day] tuple JavaScript's Date constructor expects. Note: Date months are + // 0-based, so we subtract 1 from the month component. + function parseYmd(d) { + return [ + Number(d.slice(0, 4)), + Number(d.slice(4, 6)) - 1, + Number(d.slice(6, 8)), + ]; + } + + return { + DateCreateObject(s) { + return new Date(s); + }, + DateAbapDateToDateObject(d) { + return new Date(...parseYmd(d)); + }, + // t is an ABAP time string "HHMMSS"; if omitted we default to midnight. + DateAbapDateTimeToDateObject(d, t = "000000") { + return new Date( + ...parseYmd(d), + Number(t.slice(0, 2)), + Number(t.slice(2, 4)), + Number(t.slice(4, 6)), + ); + }, + }; +}); diff --git a/run/input/core/app/z2ui5/webapp/cc/CameraPicture.js b/run/input/core/app/z2ui5/webapp/cc/CameraPicture.js new file mode 100644 index 0000000..5297f64 --- /dev/null +++ b/run/input/core/app/z2ui5/webapp/cc/CameraPicture.js @@ -0,0 +1,221 @@ +sap.ui.define( + [ + "sap/ui/core/Control", + "sap/m/Dialog", + "sap/m/Button", + "sap/ui/core/HTML", + "z2ui5/core/Lib", + ], + (Control, Dialog, Button, HTML, Lib) => { + "use strict"; + // Camera button: opens a dialog with the live camera stream, captures + // a photo on demand and hands it to the backend as a base64 JPEG in + // `value` (plus a small preview thumbnail) via the OnPhoto event. + const _CTX_2D_OPTS = { willReadFrequently: true }; + const _THUMB_W = 300; + // width/height size the trigger button; a bare number is treated as px. + const toCssSize = (val) => (/^\d+$/.test(val) ? `${val}px` : val); + return Control.extend("z2ui5.cc.CameraPicture", { + metadata: { + properties: { + id: { type: "string" }, + value: { type: "string" }, + thumbnail: { type: "string" }, + // Empty default leaves the trigger button auto-sized; a bare + // number is treated as px (see renderer / onAfterRendering). + width: { type: "string", defaultValue: "" }, + height: { type: "string", defaultValue: "" }, + autoplay: { type: "boolean", defaultValue: true }, + facingMode: { type: "string" }, + deviceId: { type: "string" }, + }, + events: { + OnPhoto: { + allowPreventDefault: true, + parameters: { + photo: { + type: "string", + }, + }, + }, + // Fired when the trigger button is pressed (the backend binds it + // via the `press` attribute, the same way as OnPhoto). + press: { + allowPreventDefault: true, + parameters: {}, + }, + }, + }, + + capture() { + const video = document.getElementById(`${this.getId()}-video`); + const canvas = document.getElementById(`${this.getId()}-canvas`); + if (!video || !canvas) return; + + const videoWidth = video.videoWidth; + const videoHeight = video.videoHeight; + canvas.width = videoWidth; + canvas.height = videoHeight; + + const ctx = canvas.getContext("2d", _CTX_2D_OPTS); + if (!ctx) return; + ctx.drawImage(video, 0, 0, videoWidth, videoHeight); + + // Full-resolution JPEG (quality 0.85) for the value, plus a small + // thumbnail (max width _THUMB_W) at lower quality for previews. + let resultb64; + try { + resultb64 = canvas.toDataURL("image/jpeg", 0.85); + } catch (e) { + Lib.logError("CameraPicture: canvas toDataURL failed", e); + return; + } + + const thumbH = videoWidth + ? Math.round((videoHeight * _THUMB_W) / videoWidth) + : _THUMB_W; + const thumbCanvas = document.createElement("canvas"); + thumbCanvas.width = _THUMB_W; + thumbCanvas.height = thumbH; + const thumbCtx = thumbCanvas.getContext("2d", _CTX_2D_OPTS); + if (thumbCtx) thumbCtx.drawImage(canvas, 0, 0, _THUMB_W, thumbH); + + let thumbB64 = ""; + try { + thumbB64 = thumbCanvas.toDataURL("image/jpeg", 0.7); + } catch (e) { + Lib.logError("CameraPicture: thumb toDataURL failed", e); + } + + if (Lib.isDestroyed(this)) return; + this.setProperty("value", resultb64); + this.setProperty("thumbnail", thumbB64); + this.fireOnPhoto({ photo: resultb64 }); + this._stopCamera(); + }, + + _stopCamera() { + // Stop every track of the active stream so the OS frees the camera. + if (this._stream) { + for (const track of this._stream.getTracks()) track.stop(); + } + this._stream = null; + const video = document.getElementById(`${this.getId()}-video`); + if (video) video.srcObject = null; + }, + + onPicture() { + if (this._oScanDialog?.isOpen()) return; + if (!this._oScanDialog) { + this._oScanDialog = new Dialog({ + title: "Device Photo Function", + contentWidth: "640px", + contentHeight: "480px", + horizontalScrolling: false, + verticalScrolling: false, + stretch: true, + afterClose: () => this._stopCamera(), + content: [ + new HTML({ + id: `${this.getId()}PictureContainer`, + content: `