peoplesoft-excel-download: bouw de Excel-export zelf uit de grid

De knop "Downloaden naar Excel" in de PeopleSoft-grids levert een bestand op dat Excel
niet of niet goed leest. Dit script vangt de klik op en schrijft zelf een xlsx uit wat er
in de tabel staat, dus zonder de server.

De klik wordt in de capture-fase op document opgevangen, want PeopleSoft hertekent de
pagina constant met AJAX en een handler op de knop zelf overleeft dat niet. De knop-id
verraadt bij welke grid hij hoort. Een flex-grid staat soms in twee tabellen, een
vastgezet en een scrollend deel met dezelfde rijen, en die kolommen worden per rij-index
weer aan elkaar geplakt.

Getallen komen als getal binnen volgens de nl-BE-notatie, met haakjes en een min achteraan
als negatief. Schrijven gebeurt met SheetJS via @require; is die niet geladen, dan valt
het terug op een CSV met puntkomma's en een BOM, wat Excel hier zonder importwizard opent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Glenn Goossens
2026-09-01 16:59:22 +02:00
co-authored by Claude Opus 5
parent 216a0d432f
commit 785ce600da
2 changed files with 290 additions and 0 deletions
@@ -0,0 +1,66 @@
# peoplesoft-excel-download
Laat de knop *Downloaden naar Excel* in de grids van PeopleSoft weer doen wat hij belooft. Het
script vangt de klik op en bouwt zelf een `.xlsx` uit wat er in de tabel staat.
De grid-knop van PeopleSoft zelf levert een bestand op dat Excel niet wil of niet goed leest.
Deze aanpak omzeilt de server volledig: wat je op je scherm ziet is wat in het bestand komt.
[**Installeren**](https://raw.githubusercontent.com/glgoose/ua-userscripts/main/scripts/peoplesoft-excel-download/peoplesoft-excel-download.user.js)
· gebeurt er niets, zie de [installatie-uitleg](../../README.md).
## Gebruik
- Klik in een grid gewoon op *Downloaden naar Excel*. Er verschijnt rechtsonder een groene
melding met de bestandsnaam en het aantal rijen.
- De bestandsnaam bestaat uit de paginatitel, de naam van de grid en een tijdstempel, dus twee
exports van dezelfde pagina overschrijven elkaar niet.
- Getallen komen als getal in het bestand, niet als tekst. `1.234,56`, `€ 1.234,56`, `(1.234,56)`
en een min achteraan worden alle vier correct gelezen volgens de nl-BE-notatie, en negatief
blijft negatief.
- De kopregel wordt vastgezet en de kolombreedtes worden op de inhoud gezet.
- Bij een grid die je kan bewerken, wordt de waarde uit het invoerveld of de dropdown gelezen,
niet de opmaak eromheen.
## Hoe het werkt
- De klik wordt opgevangen in de **capture**-fase op `document`, want PeopleSoft hertekent de
pagina constant met AJAX. Een handler op de knop zelf zou dat niet overleven.
- De knop-id (`<GRID>$hexcel$<n>` of `$hdown$`) verraadt bij welke grid hij hoort, en daarmee
wordt de bijbehorende container gevonden.
- Een PeopleSoft flex-grid is soms in twee tabellen gesplitst, een vastgezet deel en een
scrollend deel, met dezelfde rijen. Het script leest alle tabellen en plakt de kolommen per
rij-index weer aan elkaar.
- Het schrijven gebeurt met [SheetJS](https://sheetjs.com/) 0.18.5, via `@require` van cdnjs.
Die bibliotheek zit dus niet in dit bestand, wat verklaart waarom Tampermonkey deze rij als
honderden kB toont.
- Is SheetJS niet geladen, dan valt het script terug op een CSV met puntkomma's en een BOM, wat
Excel in een Nederlandstalige omgeving zonder importwizard opent.
## Beperkingen
- **Alleen wat in de DOM staat.** Een grid die zijn rijen pagineert, exporteert de pagina die je
ziet. Zet de grid eerst op alle rijen.
- **Getalherkenning is heuristiek.** Iets dat op een getal lijkt maar tekst hoort te blijven,
zoals een code met punten erin, kan als getal in het bestand komen.
- **`@grant GM_info`** staat in de kop maar wordt in de code niet gebruikt. Het staat er nog
omdat het de uitvoeringscontext bepaalt en het script daarmee werkt; weghalen verandert die
context en is niet getest.
## Ontwikkelen
Eén bestand, geen build. De enige dependency komt via `@require` binnen.
Tijdens het ontwikkelen laad je het script via een dev-stub in plaats van een geplakte kopie,
zie [Ontwikkelen](../../README.md#ontwikkelen).
```sh
node --check peoplesoft-excel-download.user.js
```
De commentaren in dit bestand zijn in het Engels, de andere scripts in deze repo zijn in het
Nederlands.
## Licentie
[GPL-3.0-or-later](../../LICENSE).
@@ -0,0 +1,224 @@
// ==UserScript==
// @name PeopleSoft: werkende "Downloaden naar Excel"
// @namespace https://github.com/glgoose/ua-userscripts
// @version 1.1.0
// @description Vervangt de kapotte PeopleSoft grid-knop "Downloaden naar Excel" door een echte XLSX-export die uit de DOM wordt opgebouwd.
// @author Glenn Goossens
// @license GPL-3.0-or-later
// @homepageURL https://github.com/glgoose/ua-userscripts/tree/main/scripts/peoplesoft-excel-download
// @supportURL https://github.com/glgoose/ua-userscripts/issues
// @downloadURL https://raw.githubusercontent.com/glgoose/ua-userscripts/main/scripts/peoplesoft-excel-download/peoplesoft-excel-download.user.js
// @updateURL https://raw.githubusercontent.com/glgoose/ua-userscripts/main/scripts/peoplesoft-excel-download/peoplesoft-excel-download.user.js
// @match https://app.psoft.uantwerpen.be/*
// @require https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js
// @grant GM_info
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* ------------------------------------------------------------------ *
* 1. Intercept every PeopleSoft "download to Excel" grid button.
* Their ids always look like <RECORD_FIELD>$hexcel$<n>.
* We listen in the CAPTURE phase on document so the handler also
* survives PeopleSoft's constant AJAX re-rendering of the page.
* ------------------------------------------------------------------ */
document.addEventListener('click', function (ev) {
const btn = ev.target.closest && ev.target.closest('a[id*="$hexcel$"], a[id*="$hdown$"]');
if (!btn) return;
ev.preventDefault();
ev.stopImmediatePropagation();
try {
exportGridForButton(btn);
} catch (err) {
console.error('[psoft-excel-fix]', err);
toast('Export mislukt: ' + err.message, true);
}
}, true);
/* ------------------------------------------------------------------ *
* 2. Find the grid that belongs to the button and export it.
* ------------------------------------------------------------------ */
function exportGridForButton(btn) {
// "UA_LR_RESUL_DER$hexcel$0" -> name "UA_LR_RESUL_DER", occurrence "0"
const m = btn.id.match(/^(.*)\$(?:hexcel|hdown)\$(\d+)$/);
const gridName = m ? m[1] : null;
const occ = m ? m[2] : '0';
let container =
(gridName && document.getElementById('win0div' + gridName + '$' + occ)) ||
btn.closest('.ps_box-grid-flex, .ps_box-grid, .ps_grid_body, table.PSLEVEL1GRID');
// fall back: walk up until we find something that contains a data table
if (!container) {
let p = btn.parentElement;
while (p && !p.querySelector('table')) p = p.parentElement;
container = p;
}
if (!container) throw new Error('grid niet gevonden');
const aoa = readGrid(container);
if (!aoa.length) throw new Error('geen rijen gevonden');
const base = fileBase(gridName);
writeXlsx(aoa, base, gridName || 'Data');
toast('Geëxporteerd: ' + base + '.xlsx (' + (aoa.length - 1) + ' rijen)');
}
/* ------------------------------------------------------------------ *
* 3. DOM -> array of arrays.
* A PeopleSoft flex grid can be split into a frozen part and a
* scrolling part; each part is its own <table> with the SAME rows.
* We read every table and glue the columns together per row index.
* ------------------------------------------------------------------ */
function readGrid(container) {
const tables = [...container.querySelectorAll('table')]
.filter(t => t.tBodies.length && t.tBodies[0].rows.length);
if (!tables.length) throw new Error('geen tabel in de grid');
const headerParts = [];
const bodyParts = [];
tables.forEach(t => {
// header
let head = [];
if (t.tHead && t.tHead.rows.length) {
head = [...t.tHead.rows[t.tHead.rows.length - 1].cells].map(cellText);
}
// body
const rows = [...t.tBodies[0].rows]
.filter(r => !r.classList.contains('ps_grid-headerrow'))
.map(r => [...r.cells].map(readCell));
if (!rows.length) return;
if (!head.length) head = rows[0].map((_, i) => 'Kolom ' + (i + 1));
headerParts.push(head);
bodyParts.push(rows);
});
if (!bodyParts.length) throw new Error('geen datarijen');
const header = [].concat(...headerParts);
const nRows = Math.max(...bodyParts.map(b => b.length));
const out = [header];
for (let i = 0; i < nRows; i++) {
let row = [];
bodyParts.forEach((part, p) => {
const r = part[i] || [];
const width = headerParts[p].length;
for (let c = 0; c < width; c++) row.push(r[c] === undefined ? '' : r[c]);
});
// drop fully empty rows
if (row.some(v => v !== '' && v !== null)) out.push(row);
}
return out;
}
function cellText(el) {
return (el.textContent || '').replace(/\s+/g, ' ').trim();
}
function readCell(td) {
// editable grids hold their value in an input/select/textarea
const f = td.querySelector('input:not([type=hidden]), select, textarea');
let raw;
if (f) {
raw = f.tagName === 'SELECT'
? (f.options[f.selectedIndex] ? f.options[f.selectedIndex].text : '')
: f.value;
} else {
raw = cellText(td);
}
return toNumberIfPossible(raw);
}
/* nl-BE number formatting: "." = thousands, "," = decimals */
function toNumberIfPossible(s) {
if (typeof s !== 'string') return s;
let v = s.replace(/ /g, ' ').trim();
if (!v) return '';
let neg = false;
if (/^\(.*\)$/.test(v)) { neg = true; v = v.slice(1, -1).trim(); }
if (/-$/.test(v)) { neg = true; v = v.slice(0, -1).trim(); } // trailing minus
v = v.replace(/^€\s*/, '').replace(/\s/g, '');
let num = null;
if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(v)) {
num = parseFloat(v.replace(/\./g, '').replace(',', '.'));
} else if (/^-?\d+(,\d+)?$/.test(v)) {
num = parseFloat(v.replace(',', '.'));
} else if (/^-?\d+(\.\d+)?$/.test(v) && !/^\d{1,3}\.\d{3}$/.test(v)) {
num = parseFloat(v);
}
if (num === null || !isFinite(num)) return s.trim();
return neg ? -Math.abs(num) : num;
}
/* ------------------------------------------------------------------ *
* 4. Build and save the workbook.
* ------------------------------------------------------------------ */
function writeXlsx(aoa, base, sheetName) {
if (typeof XLSX === 'undefined') { writeCsv(aoa, base); return; }
const ws = XLSX.utils.aoa_to_sheet(aoa);
// number format + column widths
const range = XLSX.utils.decode_range(ws['!ref']);
const widths = [];
for (let C = range.s.c; C <= range.e.c; C++) {
let w = 8;
for (let R = range.s.r; R <= range.e.r; R++) {
const cell = ws[XLSX.utils.encode_cell({ r: R, c: C })];
if (!cell) continue;
if (cell.t === 'n' && R > 0) cell.z = '#,##0.00';
const len = String(cell.v == null ? '' : cell.v).length;
if (len + 2 > w) w = len + 2;
}
widths.push({ wch: Math.min(w, 34) });
}
ws['!cols'] = widths;
ws['!freeze'] = { xSplit: 0, ySplit: 1 };
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, String(sheetName).slice(0, 28) || 'Data');
XLSX.writeFile(wb, base + '.xlsx');
}
function writeCsv(aoa, base) {
const esc = v => {
const s = typeof v === 'number' ? String(v).replace('.', ',') : String(v == null ? '' : v);
return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const csv = '' + aoa.map(r => r.map(esc).join(';')).join('\r\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = base + '.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
function fileBase(gridName) {
const title = (document.title || 'peoplesoft')
.replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '_').slice(0, 60);
const d = new Date();
const stamp = d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '_' + pad(d.getHours()) + pad(d.getMinutes());
return title + '_' + (gridName || 'grid') + '_' + stamp;
}
const pad = n => String(n).padStart(2, '0');
/* ------------------------------------------------------------------ *
* 5. Tiny toast so you can see it actually fired.
* ------------------------------------------------------------------ */
function toast(msg, isError) {
const el = document.createElement('div');
el.textContent = msg;
el.style.cssText =
'position:fixed;z-index:2147483647;bottom:20px;right:20px;max-width:380px;' +
'padding:10px 14px;border-radius:6px;font:13px/1.4 system-ui,sans-serif;' +
'color:#fff;box-shadow:0 4px 14px rgba(0,0,0,.3);' +
'background:' + (isError ? '#b3261e' : '#1f6f43') + ';';
document.body.appendChild(el);
setTimeout(() => el.remove(), 4500);
}
})();