Skip to content
Open
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ app_v2_new/ # the runtime app (UI5 2.x)
ui5.yaml
package.json
eslint.config.mjs
scripts/ # UI5 <-> abapGit BSP conversion tooling
ui5-to-bsp.mjs
bsp-to-ui5.mjs
validate-bsp.mjs
validate-ui5.mjs
.github/workflows/
sync-app-v2-new.yml # weekly sync from cap2UI5/dev
```
Expand Down Expand Up @@ -61,6 +66,122 @@ npm run lint # eslint + ui5lint
npm run build # ui5 build --clean-dest
```

## BSP conversion scripts

The `scripts/` directory contains four Node 20 ESM scripts (no dependencies)
that convert the UI5 app to and from an abapGit-compatible BSP repository
layout. Run them directly with `node`.

### `ui5-to-bsp.mjs` &mdash; UI5 webapp -> abapGit BSP

Converts a UI5 build output directory into a BSP repository layout
(subfolder, `FOLDER_LOGIC=FULL`).

```bash
# build the UI5 app first
cd app_v2_new && npm ci && npm run build && cd ..

node scripts/ui5-to-bsp.mjs \
--src app_v2_new/dist \
--out output \
--bsp Z2UI5_V2 \
--pkg '$TMP' \
--odata /sap/bc/z2ui5 \
--title "abap2UI5 v2"
```

| Flag | Default | Description |
|---|---|---|
| `--src` | (required) | UI5 build output directory (`dist/`) |
| `--out` | `result` | BSP output directory |
| `--bsp` | (required) | BSP application name (`Z***` or `/NAMESPACE/NAME`) |
| `--pkg` | `$TMP` | ABAP package / devclass |
| `--odata` | `/sap/bc/z2ui5` | URI injected into `manifest.json` `dataSources.*.uri` |
| `--title` | `""` | BSP description (`TEXT`/`CTEXT` field) |

Customer-namespace example produces `output/src/#z2ui5#ui5_apps/`:

```bash
node scripts/ui5-to-bsp.mjs \
--src app_v2_new/dist --out output \
--bsp /Z2UI5/MY_APP --pkg /Z2UI5/UI5_APPS \
--odata /sap/bc/z2ui5
```

### `bsp-to-ui5.mjs` &mdash; abapGit BSP -> UI5 webapp

Reverse converter. Auto-discovers `STARTING_FOLDER` from `.abapgit.xml`,
locates the `*.wapa.xml` index, and rebuilds the original `webapp/`
folder structure from the `PAGES` list.

```bash
node scripts/bsp-to-ui5.mjs \
--src output \
--out webapp_restored \
--odata /rest/root/z2ui5 # optional, rewrites manifest URI
```

| Flag | Default | Description |
|---|---|---|
| `--src` | `.` | BSP repo root (must contain `.abapgit.xml`) |
| `--out` | `webapp` | UI5 webapp output directory |
| `--odata` | `""` | Optional `manifest.json` `dataSources.*.uri` rewrite |

### `validate-bsp.mjs` &mdash; check generated BSP layout

Structural validation against the abapGit WAPA invariants. Hard fail on
errors, soft warnings for non-blocking issues.

```bash
node scripts/validate-bsp.mjs \
--root output \
--bsp Z2UI5_V2 \
--pkg '$TMP' \
--odata /sap/bc/z2ui5
```

Verifies: `.abapgit.xml` well-formed; package folder with `package.devc.xml`;
WAPA header complete; per page `PAGEKEY = upper(PAGENAME)`, file exists at
the encoded name, exactly one of `PAGETYPE`/`MIMETYPE`, `APPLNAME` matches
header; `PAGES` alphabetically sorted; at most one `IS_START_PAGE`
(`index.html` must be it); no duplicates or orphan files; `manifest.json`
valid JSON with patched odata.

### `validate-ui5.mjs` &mdash; check UI5 webapp coherence

```bash
node scripts/validate-ui5.mjs --root webapp_restored
```

| Flag | Default | Description |
|---|---|---|
| `--root` | `webapp` | UI5 webapp directory to validate |

Verifies: `manifest.json` exists and parses (BOM-tolerant); `sap.app.id` set;
`Component.js` present for `sap.app.type=application`; `dataSources` URIs
non-empty; `sap.ui5.rootView.viewName` resolves to a file; every
`*.view.xml`'s `controllerName` resolves to a `*.controller.js`; CSS
resources listed in `sap.ui5.resources.css` exist on disk.

### Round-trip verification

Prove the encoding is lossless: UI5 -> BSP -> UI5 should byte-match the
original (modulo `manifest.json` re-serialization).

```bash
cd app_v2_new && npm ci && npm run build && cd ..

node scripts/ui5-to-bsp.mjs --src app_v2_new/dist --out output \
--bsp Z2UI5_V2 --pkg '$TMP' --odata /sap/bc/z2ui5
node scripts/validate-bsp.mjs --root output --bsp Z2UI5_V2 --pkg '$TMP' --odata /sap/bc/z2ui5

ORIG_URI=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('app_v2_new/dist/manifest.json','utf8'))['sap.app'].dataSources.http.uri)")
node scripts/bsp-to-ui5.mjs --src output --out restored --odata "$ORIG_URI"
node scripts/validate-ui5.mjs --root restored

diff -r --brief --exclude=manifest.json app_v2_new/dist restored
```

## License

Apache-2.0 - see [LICENSE](./LICENSE).
173 changes: 173 additions & 0 deletions scripts/bsp-to-ui5.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env node
// Reverse converter: abapGit BSP layout -> UI5 webapp directory.
//
// Reads a generated/cloned BSP repo, locates the *.wapa.xml index, and
// reconstructs the original webapp/ folder structure by following the
// PAGES entries (PAGENAME holds the original case-preserved path).
//
// Usage:
// node bsp-to-ui5.mjs --src <bsp-repo-root> --out <webapp-dir> [--odata <uri>]
//
// Options:
// --src <dir> BSP repo root (contains .abapgit.xml). Default: "."
// --out <dir> UI5 webapp output directory. Default: "webapp"
// --odata <uri> Optional: overwrite manifest.json dataSource uris
// (e.g. "/rest/root/z2ui5" to revert to CAP backend)

import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { parseArgs } from 'node:util';

const { values: args } = parseArgs({
options: {
src: { type: 'string', default: '.' },
out: { type: 'string', default: 'webapp' },
odata: { type: 'string', default: '' },
},
});

const root = resolve(args.src);
const outDir = resolve(args.out);

const fsEncode = (n) => n.toLowerCase().replace(/\//g, '#');

function pageFilename(applnameFs, pagename) {
const dot = pagename.indexOf('.');
let extra, ext;
if (dot < 0) { extra = pagename; ext = ''; }
else { extra = pagename.substring(0, dot); ext = pagename.substring(dot + 1); }
extra = extra.replace(/\//g, '_-').toLowerCase();
ext = ext.replace(/\//g, '_-').toLowerCase();
return ext ? `${applnameFs}.wapa.${extra}.${ext}` : `${applnameFs}.wapa.${extra}`;
}

async function findWapaXml(dir) {
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); }
catch { return null; }
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
const r = await findWapaXml(full);
if (r) return r;
} else if (e.isFile() && e.name.endsWith('.wapa.xml')) {
return full;
}
}
return null;
}

function stripBom(buf) {
if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
return buf.subarray(3);
}
return buf;
}

// --- 1. resolve STARTING_FOLDER from .abapgit.xml (default /src/) ---
let startingFolder = '/src/';
const dotAbapgit = join(root, '.abapgit.xml');
if (existsSync(dotAbapgit)) {
const c = await readFile(dotAbapgit, 'utf8');
const m = c.match(/<STARTING_FOLDER>([^<]+)<\/STARTING_FOLDER>/);
if (m) startingFolder = m[1];
} else {
console.warn(`warn: .abapgit.xml not found at ${dotAbapgit}, using default STARTING_FOLDER=/src/`);
}

const srcRoot = join(root, startingFolder.replace(/^\//, ''));
if (!existsSync(srcRoot)) {
console.error(`error: STARTING_FOLDER does not exist: ${srcRoot}`);
process.exit(1);
}

// --- 2. locate the WAPA index ---
const wapaPath = await findWapaXml(srcRoot);
if (!wapaPath) {
console.error(`error: no *.wapa.xml found under ${srcRoot}`);
process.exit(1);
}
const pkgFolder = dirname(wapaPath);
const wapaXml = await readFile(wapaPath, 'utf8');

// --- 3. extract APPLNAME from header ---
const headerMatch = wapaXml.match(/<ATTRIBUTES>([\s\S]*?)<\/ATTRIBUTES>\s*<PAGES>/);
if (!headerMatch) {
console.error('error: cannot parse WAPA header ATTRIBUTES block');
process.exit(1);
}
const applname = (headerMatch[1].match(/<APPLNAME>([^<]+)<\/APPLNAME>/) || [])[1];
if (!applname) {
console.error('error: APPLNAME missing in WAPA xml');
process.exit(1);
}
const applnameFs = fsEncode(applname);

// --- 4. parse PAGES ---
const pagesMatch = wapaXml.match(/<PAGES>([\s\S]*?)<\/PAGES>/);
if (!pagesMatch) {
console.error('error: <PAGES> missing in WAPA xml');
process.exit(1);
}

const itemRe = /<item>\s*<ATTRIBUTES>([\s\S]*?)<\/ATTRIBUTES>\s*<\/item>/g;
const pages = [];
let m;
while ((m = itemRe.exec(pagesMatch[1])) !== null) {
const block = m[1];
const get = (tag) => (block.match(new RegExp(`<${tag}>([^<]*)<\/${tag}>`)) || [])[1];
pages.push({ pagename: get('PAGENAME') });
}

if (pages.length === 0) {
console.error('error: no <item> entries inside <PAGES>');
process.exit(1);
}

// --- 5. restore each page into the webapp directory ---
await mkdir(outDir, { recursive: true });

let copied = 0;
let skipped = 0;
let patchedManifest = false;

for (const p of pages) {
if (!p.pagename) { skipped++; continue; }
const srcFile = join(pkgFolder, pageFilename(applnameFs, p.pagename));
if (!existsSync(srcFile)) {
console.warn(`warn: source file missing for page ${p.pagename}: ${srcFile}`);
skipped++;
continue;
}

const destFile = join(outDir, p.pagename);
await mkdir(dirname(destFile), { recursive: true });

let content = await readFile(srcFile);

if (p.pagename === 'manifest.json' && args.odata) {
try {
const json = JSON.parse(stripBom(content).toString('utf8'));
const ds = json?.['sap.app']?.dataSources;
if (ds && typeof ds === 'object') {
for (const k of Object.keys(ds)) {
if (ds[k] && typeof ds[k].uri === 'string') ds[k].uri = args.odata;
}
}
content = Buffer.from(JSON.stringify(json, null, '\t'), 'utf8');
patchedManifest = true;
} catch (err) {
console.warn(`warn: could not patch manifest.json (${err.message})`);
}
}

await writeFile(destFile, content);
copied++;
}

console.log(`Restored UI5 webapp at ${outDir}`);
console.log(` source: ${srcRoot}`);
console.log(` applname: ${applname} (fs: ${applnameFs})`);
console.log(` pages: ${copied} restored, ${skipped} skipped`);
if (args.odata) console.log(` odata: ${patchedManifest ? `patched -> ${args.odata}` : 'no manifest.json found'}`);
Loading