From 83d71850bbc3b5e288b5124fdb38a5278d760c42 Mon Sep 17 00:00:00 2001 From: electron-rare <108685187+electron-rare@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:21:41 +0200 Subject: [PATCH] feat: bff moodle client with swr cache --- src/config/courses.ts | 18 +++++++++ src/lib/cache.test.ts | 28 ++++++++++++++ src/lib/cache.ts | 23 ++++++++++++ src/lib/moodle.test.ts | 55 +++++++++++++++++++++++++++ src/lib/moodle.ts | 84 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+) create mode 100644 src/config/courses.ts create mode 100644 src/lib/cache.test.ts create mode 100644 src/lib/cache.ts create mode 100644 src/lib/moodle.test.ts create mode 100644 src/lib/moodle.ts diff --git a/src/config/courses.ts b/src/config/courses.ts new file mode 100644 index 0000000..bb54d36 --- /dev/null +++ b/src/config/courses.ts @@ -0,0 +1,18 @@ +export interface CourseDef { + id: number; // Moodle course id + slug: string; + title: string; + blurb: string; + published: boolean; +} + +export const COURSES: CourseDef[] = [ + { id: 2, slug: 'esp32-ia', title: 'ESP32 + IA Embarquée', blurb: "Capteurs, TinyML et LLM connectés sur ESP32-S3.", published: true }, + { id: 3, slug: 'kicad-makers', title: 'KiCad pour Makers', blurb: "Du schéma au PCB fabriqué, projet fil rouge ESP32-Breakout.", published: true }, + { id: 5, slug: 'freertos', title: "FreeRTOS pour l'embarqué", blurb: "Tâches, files, priorités et patterns temps réel.", published: true }, + { id: 4, slug: 'llm-locaux', title: 'Déployer des LLM locaux', blurb: "Choisir, servir et intégrer des modèles open-weights.", published: false }, + { id: 6, slug: 'iot-az', title: 'IoT de A à Z', blurb: "Du capteur connecté au dashboard Grafana en production.", published: false }, + { id: 7, slug: 'docker-selfhosting', title: 'Docker et Self-Hosting', blurb: "Conteneurs, Compose, Traefik et mise en production.", published: false }, +]; + +export const bySlug = (slug: string) => COURSES.find((c) => c.slug === slug); diff --git a/src/lib/cache.test.ts b/src/lib/cache.test.ts new file mode 100644 index 0000000..d2a9e5a --- /dev/null +++ b/src/lib/cache.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest'; +import { swrCache } from './cache'; + +describe('swrCache', () => { + it('caches within ttl', async () => { + const fn = vi.fn().mockResolvedValue('a'); + const get = swrCache(fn, 1000); + expect(await get('k')).toBe('a'); + expect(await get('k')).toBe('a'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('serves stale on refresh failure', async () => { + vi.useFakeTimers(); + const fn = vi.fn().mockResolvedValueOnce('fresh').mockRejectedValue(new Error('down')); + const get = swrCache(fn, 1000); + expect(await get('k')).toBe('fresh'); + vi.advanceTimersByTime(2000); + expect(await get('k')).toBe('fresh'); + vi.useRealTimers(); + }); + + it('propagates error when cache is cold', async () => { + const fn = vi.fn().mockRejectedValue(new Error('down')); + const get = swrCache(fn, 1000); + await expect(get('k')).rejects.toThrow('down'); + }); +}); diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..3796c63 --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,23 @@ +type Entry = { value: T; at: number }; + +/** Stale-while-revalidate in-memory cache: within ttl → cached; past ttl → + * try refresh, fall back to stale value if refresh fails; cold → propagate. */ +export function swrCache(fetcher: (key: string) => Promise, ttlMs: number) { + const store = new Map>(); + return async (key: string): Promise => { + const hit = store.get(key); + const now = Date.now(); + if (hit && now - hit.at < ttlMs) return hit.value; + try { + const value = await fetcher(key); + store.set(key, { value, at: now }); + return value; + } catch (err) { + if (hit) { + console.error(`[cache] refresh failed for ${key}, serving stale:`, err); + return hit.value; + } + throw err; + } + }; +} diff --git a/src/lib/moodle.test.ts b/src/lib/moodle.test.ts new file mode 100644 index 0000000..59360f7 --- /dev/null +++ b/src/lib/moodle.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { parseCourseContents } from './moodle'; + +const fixture = JSON.parse(readFileSync('fixtures/course-contents-3.json', 'utf8')); + +describe('parseCourseContents', () => { + it('extracts 4 book modules in order', () => { + const mods = parseCourseContents(fixture); + expect(mods).toHaveLength(4); + expect(mods.map((m) => m.id)).toEqual([27, 28, 29, 30]); + expect(mods[0].title).toBe('Module 1 : Découverte de KiCad'); + expect(mods[1].title).toBe('Module 2 : Schématique'); + expect(mods[2].title).toBe('Module 3 : Du schéma au PCB'); + expect(mods[3].title).toBe('Module 4 : Fabrication'); + }); + + it('extracts chapters with real titles, in structure order', () => { + const mods = parseCourseContents(fixture); + expect(mods.map((m) => m.chapters.length)).toEqual([13, 16, 18, 19]); + expect(mods[0].chapters[0].title).toBe('Introduction'); + expect(mods[0].chapters[1].title).toBe('1.1 Introduction et installation'); + // subitems are flattened depth-first: 1.1's first child comes right after it + expect(mods[0].chapters[2].title).toBe( + "Pourquoi KiCad plutôt qu'Eagle, EasyEDA ou Altium ?", + ); + expect(mods[0].chapters[12].title).toBe('Récapitulatif'); + for (const m of mods) { + for (const ch of m.chapters) { + expect(ch.title).toBeTruthy(); + expect(ch.fileUrl).toContain('pluginfile.php'); + } + } + }); + + it('derives order from the structure TOC, not file-entry order', () => { + const shuffled = JSON.parse(JSON.stringify(fixture)); + for (const section of shuffled) { + for (const mod of section.modules ?? []) { + if (mod.modname !== 'book') continue; + const files = mod.contents.filter( + (c: { type: string }) => c.type === 'file', + ); + const rest = mod.contents.filter( + (c: { type: string }) => c.type !== 'file', + ); + mod.contents = [...rest, ...files.reverse()]; + } + } + const mods = parseCourseContents(shuffled); + expect(mods[0].chapters[0].title).toBe('Introduction'); + expect(mods[0].chapters[1].title).toBe('1.1 Introduction et installation'); + expect(mods[0].chapters[12].title).toBe('Récapitulatif'); + }); +}); diff --git a/src/lib/moodle.ts b/src/lib/moodle.ts new file mode 100644 index 0000000..bcddb1d --- /dev/null +++ b/src/lib/moodle.ts @@ -0,0 +1,84 @@ +import { swrCache } from './cache'; + +export interface Chapter { title: string; fileUrl: string; } +export interface BookModule { id: number; title: string; chapters: Chapter[]; } + +const BASE = process.env.MOODLE_BASE_URL || 'https://moodle.saillant.cc'; +const TOKEN = process.env.MOODLE_WS_TOKEN || ''; +const TTL = Number(process.env.MOODLE_CACHE_TTL_MS || 5 * 60_000); + +async function ws(fn: string, params: Record): Promise { + const body = new URLSearchParams({ + wstoken: TOKEN, wsfunction: fn, moodlewsrestformat: 'json', ...params, + }); + const res = await fetch(`${BASE}/webservice/rest/server.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, signal: AbortSignal.timeout(8000), + }); + if (!res.ok) throw new Error(`moodle ws ${fn}: HTTP ${res.status}`); + const data = await res.json(); + if (data && typeof data === 'object' && 'exception' in data) + throw new Error(`moodle ws ${fn}: ${(data as { message?: string }).message}`); + return data; +} + +interface StructureItem { title: string; href: string; level: number; subitems?: StructureItem[]; } + +interface ModContent { + type?: string; filename?: string; filepath?: string; + fileurl?: string; content?: string; +} +interface Mod { id: number; modname?: string; name: string; contents?: ModContent[]; } +interface Section { modules?: Mod[]; } + +/** Shape source of truth: fixtures/course-contents-3.json (real instance). + * Order comes from the book's structure TOC (depth-first through subitems); + * titles from per-chapter file entries (fallback: structure title). */ +export function parseCourseContents(sections: Section[]): BookModule[] { + const books: BookModule[] = []; + for (const section of sections) { + for (const mod of section.modules ?? []) { + if (mod.modname !== 'book') continue; + const files = new Map(); + let structure: StructureItem[] = []; + for (const c of mod.contents ?? []) { + if (c.type === 'content' && c.filename === 'structure') { + try { structure = JSON.parse(c.content ?? '[]'); } catch { structure = []; } + } else if (c.type === 'file' && c.filename === 'index.html' && c.fileurl) { + const id = (c.filepath ?? '').replaceAll('/', ''); + files.set(id, { title: c.content || 'Chapitre', fileUrl: c.fileurl }); + } + } + const ordered: Chapter[] = []; + const walk = (items: StructureItem[]) => { + for (const it of items) { + const id = it.href.split('/')[0] ?? ''; + const f = files.get(id); + if (f) { ordered.push({ title: f.title || it.title, fileUrl: f.fileUrl }); files.delete(id); } + if (it.subitems?.length) walk(it.subitems); + } + }; + walk(structure); + for (const f of files.values()) ordered.push({ title: f.title, fileUrl: f.fileUrl }); // leftovers + books.push({ id: mod.id, title: mod.name, chapters: ordered }); + } + } + return books; +} + +const contentsCached = swrCache(async (courseId: string) => { + const sections = (await ws('core_course_get_contents', { courseid: courseId })) as Section[]; + return parseCourseContents(sections); +}, TTL); + +export const getCourseBooks = (courseId: number) => contentsCached(String(courseId)); + +const chapterCached = swrCache(async (fileUrl: string) => { + const sep = fileUrl.includes('?') ? '&' : '?'; + const res = await fetch(`${fileUrl}${sep}token=${TOKEN}`, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) throw new Error(`chapter fetch: HTTP ${res.status}`); + return res.text(); +}, TTL); + +export const getChapterHtml = (fileUrl: string) => chapterCached(fileUrl);