Files
ismis-helper/export-csv.js
T
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

129 lines
4.8 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';
// RFC-4180 field encoding: quote fields containing commas, quotes, or
// newlines; double up embedded quotes.
function csvField(value) {
let s = String(value == null ? '' : value);
if (/[",\r\n]/.test(s)) {
s = '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function csvRow(cells) {
return cells.map(csvField).join(',') + '\r\n';
}
// Build a CSV representation of the study load.
// data: { header, courses, totalUnits }
// header fields: program, studentName, studentId, semester, yearLevel
function buildStudyLoadCsv(data) {
const header = data.header || {};
const lines = [];
lines.push(csvRow(['ISMIS HELPER - STUDY LOAD']));
lines.push(csvRow(['Program', header.program || '']));
lines.push(csvRow(['Student Name', header.fullName || header.studentName || '']));
lines.push(csvRow(['Student ID', header.studentId || '']));
lines.push(csvRow(['Semester', header.semester || '']));
lines.push(csvRow(['Year Level', header.yearLevel || '']));
lines.push(csvRow(['Total Units', data.totalUnits || '']));
lines.push(''); // blank separator line
lines.push(csvRow(['Group', 'Course Code', 'Description', 'Schedule', 'Faculty', 'Units', 'Status']));
for (const c of (data.courses || [])) {
lines.push(csvRow([
c.group, c.courseCode, c.description, c.schedule,
c.faculty || c.teacher, c.units, c.status
]));
}
lines.push(csvRow(['Total', '', '', '', '', data.totalUnits || '']));
lines.push(''); // final blank line, per RFC
// UTF-8 BOM so Excel opens it without mojibake.
return '\uFEFF' + lines.join('');
}
// Build a Next Up import CSV following the exact column contract in
// raycast/nextup/src/lib/csv-utils.ts:
// title, courseCode, days, startTime, endTime, room, meetingLink,
// color, icon, units, classLink, professorName, professorEmail, groupName
// One row per schedule block (a course may have multiple meeting segments).
function buildNextUpCsv(data, parseSchedule) {
const lines = [];
lines.push(csvRow(['title', 'courseCode', 'days', 'startTime', 'endTime',
'room', 'meetingLink', 'color', 'icon', 'units', 'classLink',
'professorName', 'professorEmail', 'groupName']));
const resolver = parseSchedule || parseScheduleBlocks;
const groupName = (data.header && (data.header.semester || '')) || '';
for (const c of (data.courses || [])) {
const blocks = resolver(c.schedule);
if (!blocks.length) {
lines.push(csvRow([c.description || c.courseCode, c.courseCode, '',
'', '', '', '', '', '', c.units, '', c.faculty || '', '', groupName]));
continue;
}
for (const b of blocks) {
lines.push(csvRow([
c.description || c.courseCode,
c.courseCode,
b.days,
b.startTime,
b.endTime,
b.room || '',
'', '', '',
c.units,
'',
c.faculty || '',
'',
groupName
]));
}
}
return '\uFEFF' + lines.join('');
}
// Split a study-load schedule string like
// "F 01:30 PM - 04:30 PM LB486TC TC; T Th 03:00 PM - 04:30 PM LB468"
// into [{ days: 'MO,TU', startTime: '01:30', endTime: '16:30', room }]
// Day codes map to the 3-letter codes Next Up expects (MO,TU,WE,TH,FR).
function parseScheduleBlocks(schedule) {
const s = String(schedule || '').trim();
if (!s) return [];
const blocks = [];
const segRe = /([A-Za-z]{1,3}(?:\s+[A-Za-z]{1,3})*)\s+(\d{1,2}):(\d{2})\s*(AM|PM)\s*-\s*(\d{1,2}):(\d{2})\s*(AM|PM)\s*([A-Za-z0-9]*)/gi;
const dayMap = {
M: 'MO', MO: 'MO', MON: 'MO',
T: 'TU', TU: 'TU', TUE: 'TU',
W: 'WE', WE: 'WE', WED: 'WE',
TH: 'TH', THU: 'TH', THUR: 'TH', THURS: 'TH',
F: 'FR', FR: 'FR', FRI: 'FR',
SA: 'SA', SAT: 'SA',
S: 'SU', SU: 'SU', SUN: 'SU', SUNDAY: 'SU'
};
let m;
while ((m = segRe.exec(s)) !== null) {
const dayToCode = (d) => dayMap[d.toUpperCase().replace(/\./g, '')] || '';
const days = m[1].split(/\s+/).map(dayToCode).filter(Boolean);
const to24 = (h, ampm, minute) => {
let hour = parseInt(h, 10) % 12;
if (/PM/i.test(ampm)) hour += 12;
return String(hour).padStart(2, '0') + ':' + minute;
};
blocks.push({
days: days.join(','),
startTime: to24(m[2], m[4], m[3]),
endTime: to24(m[5], m[7], m[6]),
room: m[8] || ''
});
}
return blocks;
}
return { buildStudyLoadCsv, buildNextUpCsv, parseScheduleBlocks, csvField };
});