Files
ismis-helper/export-xlsx.js
adrianbonpin 53d5ca6d04 feat(v1.4.0): CSV/Excel/JSON/NextUp exports + hybrid smart year standing
- Export modules (export-csv/json/xlsx) mirroring the PDF layout, plus a
  Next Up import CSV matching the raycast/nextup column contract.
- Hybrid Year Level: View Grades history (exact per-semester standing),
  falling back to units math (prospectus total - lacking), then profile.
  Popup adds a year-mode setting (auto/custom/off).
- On-page button row now offers PDF / CSV / Excel / JSON / Next Up.
- Bump version to 1.4.0.
- Tag-only build workflow with an optional AMO sign step for a signed .xpi;
  separate ci.yml keeps tests on pushes/PRs.
- Tests: year-standing parser tests + export tests (CSV quoting, JSON schema,
  XLSX structure); CI validates XLSX via openpyxl.
2026-08-20 06:45:21 +08:00

230 lines
11 KiB
JavaScript

(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// ---- Minimal ZIP writer (STORED, no compression) ----
// Produces a valid ZIP containing the XLSX parts. Stored entries are used
// because we have no deflate available (browser + zero-dependency Node).
function crc32(buf) {
let table = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function zipStore(entries) {
// entries: [{ name, data: Uint8Array }]
const chunks = [];
const central = [];
let offset = 0;
const enc = new TextEncoder();
for (const e of entries) {
const nameBytes = enc.encode(e.name);
const crc = crc32(e.data);
const local = new Uint8Array(30 + nameBytes.length);
const dv = new DataView(local.buffer);
dv.setUint32(0, 0x04034b50, true); // local file header sig
dv.setUint16(4, 20, true); // version needed
dv.setUint16(6, 0, true); // flags
dv.setUint16(8, 0, true); // method: stored
dv.setUint16(10, 0, true); // mod time
dv.setUint16(12, 0x21, true); // mod date
dv.setUint32(14, crc, true);
dv.setUint32(18, e.data.length, true); // compressed size
dv.setUint32(22, e.data.length, true); // uncompressed size
dv.setUint16(26, nameBytes.length, true); // name len
dv.setUint16(28, 0, true); // extra len
local.set(nameBytes, 30);
chunks.push(local, e.data);
const cd = new Uint8Array(46 + nameBytes.length);
const cdv = new DataView(cd.buffer);
cdv.setUint32(0, 0x02014b50, true); // central dir sig
cdv.setUint16(4, 20, true); // version made by
cdv.setUint16(6, 20, true); // version needed
cdv.setUint16(8, 0, true); // flags
cdv.setUint16(10, 0, true); // method stored
cdv.setUint16(12, 0, true); // mod time
cdv.setUint16(14, 0x21, true); // mod date
cdv.setUint32(16, crc, true);
cdv.setUint32(20, e.data.length, true);
cdv.setUint32(24, e.data.length, true);
cdv.setUint16(28, nameBytes.length, true);
cdv.setUint16(30, 0, true); // extra len
cdv.setUint16(32, 0, true); // comment len
cdv.setUint16(34, 0, true); // disk start
cdv.setUint16(36, 0, true); // internal attrs
cdv.setUint32(38, 0, true); // external attrs
cdv.setUint32(42, offset, true); // local header offset
cd.set(nameBytes, 46);
central.push(cd);
offset += local.length + e.data.length;
}
const cdStart = offset;
let cdLen = 0;
for (const c of central) cdLen += c.length;
const eocd = new Uint8Array(22);
const eocdv = new DataView(eocd.buffer);
eocdv.setUint32(0, 0x06054b50, true);
eocdv.setUint16(8, entries.length, true); // total entries (this disk)
eocdv.setUint16(10, entries.length, true);
eocdv.setUint32(12, cdLen, true);
eocdv.setUint32(16, cdStart, true);
chunks.push(...central, eocd);
const total = chunks.reduce((s, c) => s + c.length, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const c of chunks) { out.set(c, pos); pos += c.length; }
return out;
}
// ---- XML helpers ----
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
// ---- XLSX builder ----
// Builds a single-sheet workbook: a title/header block + a course table
// + a totals row, mirroring the CSV layout.
function buildStudyLoadXlsx(data) {
const header = data.header || {};
const enc = new TextEncoder();
// Gather all strings into a `sharedStrings` table; cells reference them.
const strings = [];
const sIndex = new Map();
function addS(str) {
const key = String(str == null ? '' : str);
if (!sIndex.has(key)) { sIndex.set(key, strings.length); strings.push(key); }
return sIndex.get(key);
}
// rows: array of arrays of { text } (all text cells for simplicity)
const rows = [];
rows.push([{ text: 'ISMIS HELPER - STUDY LOAD' }]);
rows.push([{ text: 'Program' }, { text: header.program || '' }]);
rows.push([{ text: 'Student Name' }, { text: header.fullName || header.studentName || '' }]);
rows.push([{ text: 'Student ID' }, { text: header.studentId || '' }]);
rows.push([{ text: 'Semester' }, { text: header.semester || '' }]);
rows.push([{ text: 'Year Level' }, { text: header.yearLevel || '' }]);
rows.push([{ text: 'Total Units' }, { text: data.totalUnits || '' }]);
rows.push([]); // blank
rows.push([{ text: 'Group' }, { text: 'Course Code' }, { text: 'Description' },
{ text: 'Schedule' }, { text: 'Faculty' }, { text: 'Units' }, { text: 'Status' }]);
for (const c of (data.courses || [])) {
rows.push([
{ text: c.group }, { text: c.courseCode }, { text: c.description },
{ text: c.schedule }, { text: c.faculty || c.teacher }, { text: c.units },
{ text: c.status }
]);
}
rows.push([{ text: 'Total' }, { text: '' }, { text: '' }, { text: '' },
{ text: '' }, { text: data.totalUnits || '' }, { text: '' }]);
// Pre-compute shared string refs so sheet1.xml can reference them.
const cellRefs = rows.map((row) => row.map((c) => c.text === '' ? -1 : addS(c.text)));
// ---- sheet1.xml ----
let sheetXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
sheetXml += '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">';
sheetXml += '<cols>' +
'<col min="1" max="1" width="8" customWidth="1"/>' +
'<col min="2" max="2" width="14" customWidth="1"/>' +
'<col min="3" max="3" width="42" customWidth="1"/>' +
'<col min="4" max="4" width="38" customWidth="1"/>' +
'<col min="5" max="5" width="24" customWidth="1"/>' +
'<col min="6" max="6" width="10" customWidth="1"/>' +
'<col min="7" max="7" width="12" customWidth="1"/>' +
'</cols>';
sheetXml += '<sheetData>';
const colNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
cellRefs.forEach((refs, ri) => {
sheetXml += '<row r="' + (ri + 1) + '">';
refs.forEach((s, ci) => {
if (s < 0) return;
sheetXml += '<c r="' + colNames[ci] + (ri + 1) + '" t="s"><v>' + s + '</v></c>';
});
sheetXml += '</row>';
});
sheetXml += '</sheetData></worksheet>';
// ---- sharedStrings.xml ----
let ssXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
ssXml += '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' +
cellRefs.reduce((s, r) => s + r.filter((x) => x >= 0).length, 0) +
'" uniqueCount="' + strings.length + '">';
for (const s of strings) ssXml += '<si><t>' + esc(s) + '</t></si>';
ssXml += '</sst>';
// ---- other parts ----
const ct = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' +
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
'</Types>';
const rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const wb = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
'<sheets><sheet name="Study Load" sheetId="1" r:id="rId1"/></sheets></workbook>';
const wbRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' +
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
'</Relationships>';
const xlRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const styles = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>' +
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>' +
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>' +
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
'</styleSheet>';
const parts = [
{ name: '[Content_Types].xml', data: enc.encode(ct) },
{ name: '_rels/.rels', data: enc.encode(rels) },
{ name: 'xl/workbook.xml', data: enc.encode(wb) },
{ name: 'xl/_rels/workbook.xml.rels', data: enc.encode(wbRels) },
{ name: 'xl/worksheets/sheet1.xml', data: enc.encode(sheetXml) },
{ name: 'xl/sharedStrings.xml', data: enc.encode(ssXml) },
{ name: 'xl/styles.xml', data: enc.encode(styles) }
];
return zipStore(parts);
}
return { buildStudyLoadXlsx };
});