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.
This commit is contained in:
+229
@@ -0,0 +1,229 @@
|
||||
(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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ---- 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 };
|
||||
});
|
||||
Reference in New Issue
Block a user