From b65af3aae77a9b9f0dd523d30c4b7b0be51d75d8 Mon Sep 17 00:00:00 2001 From: Valentin GHIRARDI Date: Sat, 12 Sep 2026 14:29:44 +0200 Subject: [PATCH] initial commit --- .gitignore | 19 ++ README.md | 182 +++++++++++++++ build.sh | 46 ++++ manifest.json | 55 +++++ src/content.js | 229 ++++++++++++++++++ src/icon.svg | 12 + src/inject.js | 27 +++ src/net-hook.js | 107 +++++++++ src/panel.css | 373 +++++++++++++++++++++++++++++ src/viewer.js | 519 +++++++++++++++++++++++++++++++++++++++++ tests/build-harness.js | 66 ++++++ tests/shot.sh | 19 ++ tools/dump-graph.js | 52 +++++ tools/make-fixture.js | 40 ++++ 14 files changed, 1746 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 build.sh create mode 100644 manifest.json create mode 100644 src/content.js create mode 100644 src/icon.svg create mode 100644 src/inject.js create mode 100644 src/net-hook.js create mode 100644 src/panel.css create mode 100644 src/viewer.js create mode 100644 tests/build-harness.js create mode 100755 tests/shot.sh create mode 100644 tools/dump-graph.js create mode 100644 tools/make-fixture.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..974dfe7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Build output +dist/ + +# web-ext sign / run +web-ext-artifacts/ +.web-extension-id + +# AMO API credentials — never commit these +.env +*.secret + +# Node, if web-ext ends up installed locally +node_modules/ + +# Editors / OS +.vscode/ +.idea/ +*.swp +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4408e6 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# Piscine Graph Tweaks + +Firefox extension for the EPITA piscine exercise graph (`exercises_c`, +`exercises_shell`, …) on `intra.forge.epita.fr`. Two things: + +**A progress panel**, bottom right of the page: + +- **validated / total** with the percentage and a segmented bar; +- a separate **required** count, which is the one that actually matters; +- breakdown: validated, required left, bonus left, locked. + +**A full screen view of the graph**, because the page crams a ~5000px-wide +diagram into a 200px-tall box: + +- opens at a readable zoom instead of shrinking everything to fit; +- **search** — dims everything but the matches, and recentres when there is only one; +- **minimap** bottom right, coloured by status; click or drag it to jump anywhere; +- **arrow keys** to pan (Shift for 3×), wheel to zoom, shift+wheel to pan sideways, + drag to pan, double-click to zoom in, click a node to open the exercise; +- `/` search · `Home` readable view · `0` fit everything · `+` / `−` zoom · `Esc` close. + +No network request is made: everything is read from the page itself. + +## How it reads the graph + +The graph is a mermaid `stateDiagram` rendered as inline SVG, and each node keeps +its whole state in its `id`: + +``` +state-"_required=true/_validated=true/_accessible=true/…/exercises_c/clang~format"-0 +``` + +So the three booleans are parsed straight from the id — no color guessing — and +`~` is mermaid's escape for `-` in the path. Status mapping: + +| `_validated` | `_accessible` | `_required` | status | +|--------------|---------------|-------------|---------------| +| `true` | – | – | validated | +| `false` | `false` | – | locked | +| `false` | `true` | `true` | required left | +| `false` | `true` | `false` | bonus left | + +Verified against the live page: 91 nodes parsed, the only skipped element being +mermaid's `state-root_start-1` marker. + +## Why there is no "started but unfinished" state + +There is none in the page. Each node carries exactly those three booleans, and +`_validated` only flips once everything passes. An exercise started this morning +and one never opened produce **byte-for-byte identical markup** — compare +`gdb_wristwatch` and `test_a_bit`, both `_required=true/_validated=false/ +_accessible=true`. So per-exercise completion cannot come from this page, and the +extension does not pretend otherwise. + +If you ever want real "started" detection, it has to come from the GitLab API +rather than the graph — one call listing the exercise projects and their +`last_activity_at` would say which ones you have pushed to: + +``` +/api/v4/groups//projects?per_page=100&order_by=last_activity_at +``` + +That is one request for the whole graph, not one per exercise. Not wired in: it +needs checking against the real API response first. + +## How the full screen view works + +It **moves** the real `` into the overlay rather than cloning it (a +placeholder holds its spot in the page), so mermaid's own `clang-format Tutorial + * + * So the status is read from the id (no color guessing), and `~` in the id path is + * mermaid's escape for `-`. + * + * Note on "in progress": the page has no such state. An exercise that is started + * but unfinished is byte-for-byte identical to one never opened — only + * _validated flips, and only once everything passes. What we CAN single out is + * the actionable queue: required, unlocked, not yet validated. + */ +(() => { + 'use strict'; + + if (window.__pgtLoaded) return; + window.__pgtLoaded = true; + + // state-"_required=…/_validated=…/_accessible=…/"- + const RE_STATE_ID = /_required=(true|false)\/_validated=(true|false)\/_accessible=(true|false)\/([^"]+)"-\d+$/; + const RESCAN_DEBOUNCE_MS = 500; + + const STATUS = { + done: 'done', // validated, whatever the requirement + required: 'required',// required, unlocked, not validated -> the actual TODO + bonus: 'bonus', // optional, unlocked, not validated + locked: 'locked', // not accessible yet + }; + + // ------------------------------------------------------------------ parsing + + function parseNode(g) { + const id = g.getAttribute('id') || ''; + const m = id.match(RE_STATE_ID); + if (!m) return null; // start/end markers and anything unexpected + + const [, required, validated, accessible, path] = m; + const isRequired = required === 'true'; + const isValidated = validated === 'true'; + const isAccessible = accessible === 'true'; + + const segments = path.split('/'); + const slug = (segments[segments.length - 1] || '').replace(/~/g, '-'); + const project = segments[segments.length - 2] || ''; + const link = g.querySelector('a'); + const labelEl = g.querySelector('.nodeLabel'); + + return { + el: g, + shape: g.querySelector('rect.label-container') || g.querySelector('rect'), + slug, + project, + label: (labelEl ? labelEl.textContent : slug).trim(), + href: link ? link.getAttribute('href') || '' : '', + required: isRequired, + validated: isValidated, + accessible: isAccessible, + status: isValidated ? STATUS.done + : !isAccessible ? STATUS.locked + : isRequired ? STATUS.required + : STATUS.bonus, + }; + } + + function collect() { + const seen = new Set(); + const nodes = []; + document.querySelectorAll('svg g.node').forEach((g) => { + const node = parseNode(g); + if (!node || seen.has(node.slug)) return; + seen.add(node.slug); + nodes.push(node); + }); + return nodes; + } + + // ----------------------------------------------------------------- marking + + /* Marking happens on the SVG itself rather than with overlay badges: the graph + lives inside svg-pan-zoom, so anything positioned in page coordinates would + drift on every pan and zoom. */ + function mark(nodes) { + for (const n of nodes) { + n.el.classList.toggle('pgt-next', n.status === STATUS.required); + n.el.classList.toggle('pgt-bonus', n.status === STATUS.bonus); + } + } + + // ------------------------------------------------------------------- panel + + let panel = null; + function ensurePanel() { + if (panel && panel.isConnected) return panel; + panel = document.createElement('div'); + panel.className = 'pgt-panel'; + if (localStorage.getItem('pgt.collapsed') === '1') panel.classList.add('pgt-collapsed'); + document.body.appendChild(panel); + return panel; + } + + function pct(a, b) { + return b ? Math.round((a / b) * 1000) / 10 : 0; + } + + function projectName(nodes) { + const raw = (nodes[0] && nodes[0].project) || ''; + const m = raw.match(/^exercises_(.+)$/); + if (!m) return 'Piscine'; + return 'Piscine ' + (m[1] === 'c' ? 'C' : m[1]); + } + + function render(nodes) { + const p = ensurePanel(); + const total = nodes.length; + const by = (s) => nodes.filter((n) => n.status === s); + const done = by(STATUS.done); + const todo = by(STATUS.required); + const bonus = by(STATUS.bonus); + const locked = by(STATUS.locked); + + const required = nodes.filter((n) => n.required); + const requiredDone = required.filter((n) => n.validated); + + p.innerHTML = ` +
+ ${escapeHtml(projectName(nodes))} + +
+
+
+ ${done.length}/${total} + ${pct(done.length, total)}% +
+
+
+
+
+
+
+ +
+ Required ${requiredDone.length}/${required.length} + ${pct(requiredDone.length, required.length)}% +
+ +
    +
  • Validated${done.length}
  • +
  • Required left${todo.length}
  • +
  • Bonus left${bonus.length}
  • + ${locked.length ? `
  • Locked${locked.length}
  • ` : ''} +
+ + +
`; + + p.querySelector('.pgt-toggle').addEventListener('click', () => { + p.classList.toggle('pgt-collapsed'); + localStorage.setItem('pgt.collapsed', p.classList.contains('pgt-collapsed') ? '1' : '0'); + }); + p.querySelector('.pgt-big').addEventListener('click', () => { + if (window.__pgtViewer) window.__pgtViewer.toggle(nodes); + }); + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } + + // -------------------------------------------------------------------- scan + + let lastSignature = ''; + function scan(force = false) { + const nodes = collect(); + if (!nodes.length) return; + const signature = nodes.map((n) => n.slug + n.status).join('|'); + if (!force && signature === lastSignature) return; + lastSignature = signature; + render(nodes); + mark(nodes); + window.__pgtNodes = nodes; // handy from the console + } + + let timer = null; + new MutationObserver((muts) => { + // Ignore the mutations we cause ourselves, otherwise we loop forever. + if (muts.every((m) => m.target.closest && m.target.closest('.pgt-panel'))) return; + clearTimeout(timer); + timer = setTimeout(() => scan(), RESCAN_DEBOUNCE_MS); + }).observe(document.body, { childList: true, subtree: true }); + + scan(); + + // ------------------------------------------------------------ console help + + /** Raw dump, for tuning the parser if the graph generator ever changes. */ + window.__pgtDump = () => { + const nodes = collect(); + const report = { + url: location.href, + total: nodes.length, + counts: Object.values(STATUS).reduce((acc, s) => { + acc[s] = nodes.filter((n) => n.status === s).length; + return acc; + }, {}), + nodes: nodes.map(({ el, shape, ...rest }) => rest), + sampleHtml: nodes.length ? nodes[0].el.outerHTML.slice(0, 800) : '', + }; + console.log(report); + return report; + }; + + /** What the page fetched (see net-hook.js) — is there richer data anywhere? */ + window.__pgtNet = () => { + const log = window.__pgtNetLog || []; + console.table(log.map(({ kind, url, status, contentType, length }) => + ({ kind, url, status, contentType, length }))); + return log; + }; +})(); diff --git a/src/icon.svg b/src/icon.svg new file mode 100644 index 0000000..540ee4e --- /dev/null +++ b/src/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/inject.js b/src/inject.js new file mode 100644 index 0000000..23b84d7 --- /dev/null +++ b/src/inject.js @@ -0,0 +1,27 @@ +/* Piscine Graph Tweaks — bridge between the page world and the content script. + * + * Content scripts run in an isolated world, so they cannot patch the page's own + * window.fetch. We inject net-hook.js into the page and collect what it reports + * here; content.js reads window.__pgtNetLog from the same sandbox. + */ +(() => { + 'use strict'; + + const api = globalThis.browser || globalThis.chrome; + const log = (window.__pgtNetLog = window.__pgtNetLog || []); + const MAX_ENTRIES = 120; + + window.addEventListener('message', (e) => { + if (e.source !== window) return; + const data = e.data; + if (!data || data.__pgt !== 'net' || !data.entry) return; + log.push(data.entry); + if (log.length > MAX_ENTRIES) log.shift(); + }); + + const s = document.createElement('script'); + s.src = api.runtime.getURL('src/net-hook.js'); + s.async = false; // run before the page starts fetching + (document.head || document.documentElement).appendChild(s); + s.addEventListener('load', () => s.remove()); +})(); diff --git a/src/net-hook.js b/src/net-hook.js new file mode 100644 index 0000000..b9c5fc0 --- /dev/null +++ b/src/net-hook.js @@ -0,0 +1,107 @@ +/* Piscine Graph Tweaks — network hook (runs in the PAGE world). + * + * The graph DOM most likely encodes only "green vs not green": an exercise that + * is started but unfinished looks exactly like one never opened. The per-exercise + * completion, if it exists at all, lives in whatever data the page fetches to + * build the graph. This hook wraps fetch() and XMLHttpRequest so we can see those + * responses and find out where that data is. + * + * Everything stays local to the browser: captured bodies are handed to the + * extension's content script and never leave the machine. + */ +(() => { + 'use strict'; + + if (window.__pgtNetHook) return; + window.__pgtNetHook = true; + + const MAX_BODY = 8000; // truncate captured payloads + const KEEP_TYPES = /json|javascript|svg|text\/plain|graphql/i; + + function send(entry) { + try { + window.postMessage({ __pgt: 'net', entry }, window.location.origin); + } catch (_) { /* ignore */ } + } + + function capture(kind, url, method, status, contentType, body) { + if (!KEEP_TYPES.test(contentType || '')) return; + send({ + kind, + url: String(url), + method: method || 'GET', + status, + contentType, + length: body ? body.length : 0, + body: body ? body.slice(0, MAX_BODY) : '', + truncated: !!body && body.length > MAX_BODY, + at: Date.now(), + }); + } + + // ---- fetch --------------------------------------------------------------- + const origFetch = window.fetch; + if (typeof origFetch === 'function') { + window.fetch = function (...args) { + const input = args[0]; + const url = typeof input === 'string' ? input : (input && input.url) || ''; + const method = (args[1] && args[1].method) || (input && input.method) || 'GET'; + return origFetch.apply(this, args).then((res) => { + try { + const ct = res.headers.get('content-type') || ''; + if (KEEP_TYPES.test(ct)) { + // clone() so the page still gets to read its own body + res.clone().text().then( + (body) => capture('fetch', url, method, res.status, ct, body), + () => {} + ); + } + } catch (_) { /* ignore */ } + return res; + }); + }; + } + + // ---- XMLHttpRequest ------------------------------------------------------ + const XHR = window.XMLHttpRequest; + if (XHR && XHR.prototype) { + const origOpen = XHR.prototype.open; + const origSend = XHR.prototype.send; + + XHR.prototype.open = function (method, url, ...rest) { + this.__pgtMethod = method; + this.__pgtUrl = url; + return origOpen.call(this, method, url, ...rest); + }; + + XHR.prototype.send = function (...args) { + this.addEventListener('load', () => { + try { + const ct = this.getResponseHeader('content-type') || ''; + const body = this.responseType === '' || this.responseType === 'text' + ? this.responseText + : (this.responseType === 'json' ? JSON.stringify(this.response) : ''); + capture('xhr', this.__pgtUrl, this.__pgtMethod, this.status, ct, body); + } catch (_) { /* ignore */ } + }); + return origSend.apply(this, args); + }; + } + + // ---- inline data already in the page ------------------------------------- + // Some pages ship the graph data in a + + +`; + +fs.writeFileSync(path.join(__dirname, 'harness.html'), html); +console.log('tests/harness.html written (' + html.length + ' bytes)'); diff --git a/tests/shot.sh b/tests/shot.sh new file mode 100755 index 0000000..bf9ea34 --- /dev/null +++ b/tests/shot.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Screenshot the harness with headless Firefox. +# tests/shot.sh [output.png] [width,height] [#hash] +# A dedicated profile is used so it never collides with the running browser. +set -eu + +cd "$(dirname "$0")" +out=${1:-shot.png} +size=${2:-1600,1000} +hash=${3:-} + +profile=${TMPDIR:-/tmp}/pgt-ff-profile +mkdir -p "$profile" + +timeout 120 firefox --headless --no-remote --profile "$profile" \ + --screenshot "$PWD/$out" --window-size "$size" \ + "file://$PWD/harness.html$hash" >/dev/null 2>&1 + +echo "$PWD/$out" diff --git a/tools/dump-graph.js b/tools/dump-graph.js new file mode 100644 index 0000000..99a9baa --- /dev/null +++ b/tools/dump-graph.js @@ -0,0 +1,52 @@ +/* Paste into the Firefox console (F12) on the graph page, WITHOUT the extension. + * Prints and copies a summary of the graph, which is what is needed to adjust the + * parser if the graph generator ever changes shape. + * + * Firefox may require typing "allow pasting" in the console first. + */ +(() => { + const RE = /_required=(true|false)\/_validated=(true|false)\/_accessible=(true|false)\/([^"]+)"-\d+$/; + + const nodes = []; + const skipped = []; + document.querySelectorAll('svg g.node').forEach((g) => { + const id = g.getAttribute('id') || ''; + const m = id.match(RE); + if (!m) { skipped.push(id); return; } + const [, required, validated, accessible, path] = m; + const seg = path.split('/'); + const link = g.querySelector('a'); + const label = g.querySelector('.nodeLabel'); + nodes.push({ + slug: seg[seg.length - 1].replace(/~/g, '-'), + project: seg[seg.length - 2], + label: label ? label.textContent.trim() : '', + href: link ? link.getAttribute('href') || '' : '', + required: required === 'true', + validated: validated === 'true', + accessible: accessible === 'true', + }); + }); + + const count = (fn) => nodes.filter(fn).length; + const report = { + url: location.href, + total: nodes.length, + counts: { + validated: count((n) => n.validated), + requiredLeft: count((n) => !n.validated && n.accessible && n.required), + bonusLeft: count((n) => !n.validated && n.accessible && !n.required), + locked: count((n) => !n.validated && !n.accessible), + }, + skippedIds: skipped, // start/end markers land here, that is expected + nodes, + sampleHtml: document.querySelector('svg g.node')?.outerHTML.slice(0, 900) || '', + }; + + console.log(report); + (navigator.clipboard?.writeText(JSON.stringify(report, null, 2)) || Promise.reject()).then( + () => console.log('%c copied to clipboard', 'color:#22c55e'), + () => console.log('%c clipboard refused — right-click the object above > Copy', 'color:#f59e0b') + ); + return report; +})(); diff --git a/tools/make-fixture.js b/tools/make-fixture.js new file mode 100644 index 0000000..be017ae --- /dev/null +++ b/tools/make-fixture.js @@ -0,0 +1,40 @@ +/* Paste into the Firefox console (F12) on the graph page, then save the result: + * + * wl-paste > tests/fixture.json + * + * It captures the real graph SVG plus the CSS custom properties it depends on + * (--required-validated and friends), which are defined outside the SVG and + * would otherwise be lost. That pair is everything needed to rebuild the page + * offline and test the extension against it. + * + * Firefox may require typing "allow pasting" in the console first. + */ +(() => { + const svg = document.querySelector('svg.statediagram') || document.querySelector('svg'); + if (!svg) return console.error('no graph SVG found on this page'); + + // Custom properties are inherited, so resolve them while the SVG is in place. + const names = new Set(); + svg.querySelectorAll('[style*="var(--"]').forEach((el) => { + for (const m of (el.getAttribute('style') || '').matchAll(/var\((--[\w-]+)/g)) names.add(m[1]); + }); + const computed = getComputedStyle(svg); + const vars = {}; + for (const n of names) vars[n] = computed.getPropertyValue(n).trim(); + + const fixture = { + url: location.href, + capturedAt: new Date().toISOString(), + vars, + bodyBackground: getComputedStyle(document.body).backgroundColor, + svg: svg.outerHTML, + }; + + const json = JSON.stringify(fixture, null, 2); + console.log('vars:', vars, '| svg length:', fixture.svg.length); + (navigator.clipboard?.writeText(json) || Promise.reject()).then( + () => console.log('%c copied — now run: wl-paste > tests/fixture.json', 'color:#22c55e'), + () => console.log('%c clipboard refused — right-click the object above > Copy', 'color:#f59e0b') + ); + return fixture; +})();