Author SHA1 Message Date
adrianbonpin 7adad7cbc8 feat(security): encrypt profile passwords at rest with AES-GCM
ci / test (push) Successful in 57s
ci / test (pull_request) Successful in 52s
Passwords are now encrypted with Web Crypto AES-GCM (256-bit) before being
stored in browser.storage.local. The random key is persisted so it survives
browser/service-worker restarts (otherwise old ciphertext could never be
decrypted). Encryption/decryption happen in the background via
encrypt-password/decrypt-password messages; autoLogin decrypts on the fly and
falls back to a legacy plaintext password for pre-encryption profiles. The
popup never reveals a stored password and preserves it when editing with a
blank field.

Removes the plaintext-password-at-rest smell flagged in the design review.
Note: this is at-rest obfuscation-level security (the key ships with the
extension); the extension only talks to the school portal.
2026-08-20 12:27:57 +08:00
adrianbonpin eb7087d506 feat(progress): live status bar + % complete during exports
ci / test (push) Successful in 39s
ci / test (pull_request) Successful in 44s
Adds a progress bar below the export buttons showing the current step
(reading table, fetching course descriptions i/N, profile, year level,
building file) with a 0-100% fill. Threads an onProgress callback through
scrapeStudyLoad and resolveYearLevel. Buttons disable during export to
prevent double-triggers; bar clears ~3s after success.
2026-08-20 09:02:15 +08:00
adrianbonpin 90e4107bf0 fix(year-level): prefer units-passed standing over calendar-year history count
ci / test (push) Successful in 2m21s
ci / test (pull_request) Successful in 38s
The grade-history method counted distinct academic years, which over-advanced
standing for students who repeated/failed a year (e.g. showing Year 5 when the
student is genuinely Year 4, since a partial 2025 year didn't advance them).

Reorder the hybrid so the UNITS math (total prospectus units minus still-lacking
units = units actually passed) is primary, and View Grades history is only a
fallback when units data is unavailable.
2026-08-20 08:54:55 +08:00
adrianbonpin 3067335274 ci: run tests on v1.4.0 feature branch
ci / test (push) Successful in 2m9s
ci / test (pull_request) Successful in 42s
2026-08-20 06:45:36 +08:00
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
2 changed files with 10 additions and 27 deletions
+1 -5
View File
@@ -76,12 +76,8 @@ jobs:
# Attach zips and the signed xpi (if present) as release assets
for f in dist/*.zip dist/*.xpi; do
[ -f "$f" ] || continue
NAME="$(basename "$f")"
# URL-encode the asset name so '+' (in 'ismis-helper-1.4.0-an+fx.xpi')
# survives the query string instead of being decoded to a space.
ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$NAME")
curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-F "attachment=@${f}" \
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ENC}"
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$(basename "${f}")"
done
+9 -22
View File
@@ -20,31 +20,18 @@ const CHROME_BG_PARTS = [
'background.js',
];
// Directories never copied into a build target.
const EXCLUDED_DIRS = new Set([
'.git', 'node_modules', 'dist', 'docs', 'scripts', 'test', 'web-ext-artifacts',
]);
// Repo-root files that are for contributors, not for the shipped extension.
// manifest.json is here because it is generated per target, not copied.
const DEV_ONLY_FILES = new Set([
'manifest.json', 'AGENTS.md', 'README.md', 'package.json', 'package-lock.json',
]);
// Everything to copy from ROOT into a target, relative paths.
// `excludeDev` is true when reading ROOT (skip contributor files) and false when
// reading a built target (its generated manifest.json MUST be packaged).
function collectFiles(srcDir, { dir = '', excludeDev = false, out = [] } = {}) {
function collectFiles(srcDir, out = [], prefix = '') {
for (const entry of fs.readdirSync(srcDir)) {
const abs = path.join(srcDir, entry);
const rel = dir ? dir + '/' + entry : entry;
if (entry.startsWith('.') || EXCLUDED_DIRS.has(entry)) continue;
if (excludeDev && DEV_ONLY_FILES.has(entry)) continue;
if (fs.statSync(abs).isDirectory()) {
collectFiles(abs, { dir: rel, excludeDev, out });
} else {
out.push(rel);
const rel = prefix ? prefix + '/' + entry : entry;
if (entry === '.git' || entry === 'node_modules' || entry === 'dist' ||
entry === 'docs' || entry === 'scripts' || entry === 'test' ||
entry === 'manifest.json' || entry.startsWith('.')) {
continue;
}
if (fs.statSync(abs).isDirectory()) collectFiles(abs, out, rel);
else out.push(rel);
}
return out;
}
@@ -135,7 +122,7 @@ function buildTarget(target, version) {
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
const relList = collectFiles(ROOT, { excludeDev: true });
const relList = collectFiles(ROOT);
copyDir(relList, dir);
if (target === 'firefox') {