diff --git a/parro/CLAUDE.md b/parro/CLAUDE.md index 007b246..18a901e 100644 --- a/parro/CLAUDE.md +++ b/parro/CLAUDE.md @@ -19,9 +19,10 @@ GitHub Pages (index.html) ── supabase-js ──▶ Supabase (Auth + Postgres - **Frontend** (`index.html`): statische single-file app, zelfde patroon als `boodschappen/` — Alpine.js + Supabase JS via CDN, Google-login via - Supabase Auth, allowlist (`allowed_emails`) + RLS. Drie tabs: Agenda + Supabase Auth, allowlist (`allowed_emails`) + RLS. Vier tabs: Agenda (met afvinkbare acties en kind-van-de-week-banner), Weekoverzicht, - Meldingen. + Meldingen en Foto's (galerij met lightbox + download; de bestanden komen + via tijdelijke signed URLs uit een private storage-bucket). - **`config.js`**: publieke Supabase-URL + publishable key. Standaard hetzelfde gezinsproject als boodschappen (gedeelde login/allowlist). - **`supabase/`**: `schema.sql` (parro_*-tabellen + RLS; het @@ -29,8 +30,10 @@ GitHub Pages (index.html) ── supabase-js ──▶ Supabase (Auth + Postgres setup-README. - **`agent/`**: lokale cron-scripts op Lukas' machine. `sync.mjs` leest de SQLite van de [gwillem/parro](https://github.com/gwillem/parro) CLI, - `enrich.mjs` laat Claude (structured output) agenda-items/acties/vlaggen - extraheren, `week.mjs` schrijft weeksamenvattingen. Zie `agent/README.md`. + `fotos.mjs` uploadt de door die CLI gedownloade bijlagen (uit + `~/.cache/parro/`) naar de storage-bucket, `enrich.mjs` laat Claude + (structured output) agenda-items/acties/vlaggen extraheren, `week.mjs` + schrijft weeksamenvattingen. Zie `agent/README.md`. ## Datamodel @@ -40,6 +43,9 @@ GitHub Pages (index.html) ── supabase-js ──▶ Supabase (Auth + Postgres - `parro_acties` — afvinkbare acties per agenda-item (leden mogen updaten, de rest is read-only; schrijven doet de agent via service-role). - `parro_weekoverzicht` — markdown-samenvatting per week (pk = maandag). +- `parro_fotos` — foto's/video's uit berichten: metadata + het pad in de + private storage-bucket `parro-fotos`. De bestanden zelf staan in Storage, + niet in git; de frontend maakt er per sessie signed URLs voor aan. ## Werkregels diff --git a/parro/agent/README.md b/parro/agent/README.md index bb53403..f8d04cf 100644 --- a/parro/agent/README.md +++ b/parro/agent/README.md @@ -4,11 +4,12 @@ Draait op **jouw eigen machine** (niet op GitHub Pages) en houdt Supabase bij: ``` Parro API ──(gwillem/parro)──▶ ~/.local/share/parro/*.db (SQLite) - │ sync.mjs - ▼ - Supabase (parro_items) - │ enrich.mjs (Claude API) - ▼ + + ~/.cache/parro// (bijlagen) + │ sync.mjs │ fotos.mjs + ▼ ▼ + Supabase (parro_items) Supabase Storage + │ enrich.mjs (bucket parro-fotos) + ▼ + parro_fotos parro_agenda + parro_acties + vlaggen op parro_items │ week.mjs (wekelijks, Claude API) ▼ @@ -75,7 +76,7 @@ gekozen in `llm.mjs`: ## Draaien ```bash -./run.sh # parro check → sync.mjs → enrich.mjs +./run.sh # parro check → sync.mjs → fotos.mjs → enrich.mjs node week.mjs # weeksamenvatting van de lopende week ``` @@ -99,6 +100,7 @@ stilletjes breken. | Script | Doet | |---|---| | `sync.mjs` | Leest `events` + `chat_messages` uit de gwillem/parro-SQLite en upsert ze als ruwe `parro_items` (bestaande rijen blijven onaangeroerd). | +| `fotos.mjs` | Leest de bijlagen (`attachments`) uit de ruwe JSON van de items, zoekt het door `parro check` gedownloade bestand in `~/.cache/parro//` en uploadt het naar de private bucket `parro-fotos` (+ rij in `parro_fotos`). Al geüploade foto's worden overgeslagen. Zet `PARRO_CACHE` als de cache elders staat. | | `enrich.mjs` | Stuurt onverwerkte items naar Claude (structured output): agenda-items met datum/kind/acties, kind-van-de-week, belangrijk-vlag. Mislukte items blijven onverwerkt en gaan de volgende run opnieuw. | | `week.mjs` | Vat één week Parro-verkeer samen in markdown → `parro_weekoverzicht`. Draai met een datum-argument om een oude week (opnieuw) te genereren. | diff --git a/parro/agent/db.mjs b/parro/agent/db.mjs index dab38da..1c4ff42 100644 --- a/parro/agent/db.mjs +++ b/parro/agent/db.mjs @@ -35,6 +35,52 @@ export async function insertNieuweItems(items) { return nieuw; } +// Naam van de private storage-bucket met de Parro-foto's/video's. +export const FOTO_BUCKET = "parro-fotos"; + +// Recente items mét hun ruwe JSON (waar de bijlagen in zitten). Voor fotos.mjs. +export async function getItemsMetRaw(limit = 500) { + const { data, error } = await db() + .from("parro_items") + .select("id, soort, datum, raw") + .not("raw", "is", null) + .order("datum", { ascending: false }) + .limit(limit); + if (error) throw new Error(`parro_items raw lezen: ${error.message}`); + return data || []; +} + +// Al bekende foto-id's (om dubbel uploaden over te slaan). +export async function bestaandeFotoIds() { + const ids = new Set(); + const stap = 1000; + for (let van = 0; ; van += stap) { + const { data, error } = await db() + .from("parro_fotos") + .select("id") + .range(van, van + stap - 1); + if (error) throw new Error(`parro_fotos lezen: ${error.message}`); + for (const r of data || []) ids.add(r.id); + if (!data || data.length < stap) break; + } + return ids; +} + +// Eén bestand naar de storage-bucket zetten (service-role, omzeilt RLS). +export async function uploadFoto(pad, data, contentType) { + const { error } = await db() + .storage.from(FOTO_BUCKET) + .upload(pad, data, { contentType: contentType || undefined, upsert: true }); + if (error) throw new Error(`upload ${pad}: ${error.message}`); +} + +export async function insertFoto(rij) { + const { error } = await db() + .from("parro_fotos") + .upsert(rij, { onConflict: "id", ignoreDuplicates: true }); + if (error) throw new Error(`parro_fotos insert: ${error.message}`); +} + export async function getOnverwerkteItems(limit = 25) { const { data, error } = await db() .from("parro_items") diff --git a/parro/agent/fotos.mjs b/parro/agent/fotos.mjs new file mode 100644 index 0000000..280c533 --- /dev/null +++ b/parro/agent/fotos.mjs @@ -0,0 +1,164 @@ +// fotos.mjs — haalt de foto's/video's uit Parro-berichten en zet ze in de +// private Supabase-bucket 'parro-fotos', met metadata in parro_fotos. +// +// De bestanden zelf staan al lokaal: gwillem/parro downloadt elke bijlage bij +// `parro check` naar ~/.cache/parro//_. Wij +// lezen de `attachments` uit de ruwe JSON die al in parro_items staat, zoeken +// het bijbehorende bestand in die cache en uploaden het naar Supabase Storage. +// (De directe Parro-URL's vereisen het OAuth-token van de CLI, dus we gaan via +// de cache — dan hoeft alleen gwillem/parro de onofficiële API te kennen.) +// +// node fotos.mjs +// PARRO_CACHE=/pad/naar/cache node fotos.mjs # als autodetectie faalt +// +// Draai na `parro check` + `node sync.mjs` (zie run.sh). + +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, extname } from "node:path"; +import "dotenv/config"; +import { + getItemsMetRaw, + bestaandeFotoIds, + uploadFoto, + insertFoto, +} from "./db.mjs"; + +const cacheRoot = + process.env.PARRO_CACHE || join(homedir(), ".cache", "parro"); + +// content-type raden uit de extensie als Parro er geen meegeeft. +const TYPE_PER_EXT = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".gif": "image/gif", ".webp": "image/webp", ".heic": "image/heic", + ".mp4": "video/mp4", ".mov": "video/quicktime", ".m4v": "video/x-m4v", +}; + +// De bijlagen uit een ruw item halen. Mededelingen hebben `attachments` (array), +// chatberichten `attachment` (enkel). We pakken de SOURCE-entry (volle resolutie). +function bijlagenVan(item) { + const raw = item.raw || {}; + const lijst = Array.isArray(raw.attachments) + ? raw.attachments + : raw.attachment + ? [raw.attachment] + : []; + const uit = []; + lijst.forEach((att, index) => { + const entries = Array.isArray(att?.entries) ? att.entries : []; + const src = entries.find((e) => e.type === "SOURCE") || entries[0]; + if (!src?.url) return; + uit.push({ + index, + soort: att.attachmentType || null, // IMAGE / VIDEO + bestandsnaam: src.filename || basenaamUitUrl(src.url), + contentType: src.contentType || null, + }); + }); + return uit; +} + +function basenaamUitUrl(url) { + try { + const p = new URL(url).pathname; + const b = p.slice(p.lastIndexOf("/") + 1); + return b || "bijlage"; + } catch { + return "bijlage"; + } +} + +// Alle guardian-mappen in de cache (~/.cache/parro//). +function guardianMappen() { + try { + return readdirSync(cacheRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => join(cacheRoot, d.name)); + } catch { + return []; + } +} + +// Het lokale bestand voor bijlage `index` van bericht `msgId` vinden. +// gwillem/parro schrijft naar _; als de naam onbekend is +// pakken we het n-de bestand dat met "_" begint. +function vindBestand(msgId, bestandsnaam, index) { + const mappen = guardianMappen(); + if (bestandsnaam) { + for (const dir of mappen) { + const exact = join(dir, `${msgId}_${bestandsnaam}`); + if (existsSync(exact)) return exact; + } + } + const treffers = []; + for (const dir of mappen) { + let files; + try { + files = readdirSync(dir); + } catch { + continue; + } + for (const f of files) { + if (f.startsWith(`${msgId}_`)) treffers.push(join(dir, f)); + } + } + treffers.sort(); + return treffers[index] || treffers[0] || null; +} + +// Pad in de storage-bucket, afgeleid van het item-id (":" mag daar niet in). +function opslagPad(itemId, index, bestandsnaam) { + const map = itemId.replace(/[^a-zA-Z0-9_-]/g, "_"); + const naam = (bestandsnaam || "bijlage").replace(/[^a-zA-Z0-9._-]/g, "_"); + return `${map}/${index}_${naam}`; +} + +const items = await getItemsMetRaw(); +const bekend = await bestaandeFotoIds(); + +let nieuw = 0; +let gemist = 0; + +for (const item of items) { + const msgId = item.id.split(":")[1]; + for (const bijlage of bijlagenVan(item)) { + const fotoId = `${item.id}#${bijlage.index}`; + if (bekend.has(fotoId)) continue; + + const bestand = vindBestand(msgId, bijlage.bestandsnaam, bijlage.index); + if (!bestand) { + gemist++; + console.warn( + `[fotos] ${fotoId}: geen lokaal bestand voor ${bijlage.bestandsnaam} — draai eerst 'parro check'`, + ); + continue; + } + + const pad = opslagPad(item.id, bijlage.index, bijlage.bestandsnaam); + const contentType = + bijlage.contentType || TYPE_PER_EXT[extname(bestand).toLowerCase()] || null; + + try { + await uploadFoto(pad, readFileSync(bestand), contentType); + await insertFoto({ + id: fotoId, + item_id: item.id, + pad, + bestandsnaam: bijlage.bestandsnaam, + content_type: contentType, + soort: bijlage.soort, + datum: item.datum, + }); + bekend.add(fotoId); + nieuw++; + console.log(`[fotos] ${fotoId} → ${pad}`); + } catch (err) { + console.error(`[fotos] ${fotoId} mislukt: ${err.message}`); + } + } +} + +console.log( + `[fotos] klaar: ${nieuw} nieuw geüpload` + + (gemist ? `, ${gemist} zonder lokaal bestand overgeslagen` : ""), +); diff --git a/parro/agent/package.json b/parro/agent/package.json index 989c983..19a39e5 100644 --- a/parro/agent/package.json +++ b/parro/agent/package.json @@ -8,6 +8,7 @@ }, "scripts": { "sync": "node sync.mjs", + "fotos": "node fotos.mjs", "enrich": "node enrich.mjs", "week": "node week.mjs", "run": "./run.sh" diff --git a/parro/agent/run.sh b/parro/agent/run.sh index a5e0bdd..45340e9 100755 --- a/parro/agent/run.sh +++ b/parro/agent/run.sh @@ -4,6 +4,7 @@ set -euo pipefail cd "$(dirname "$0")" -parro check # gwillem/parro: haal nieuwe Parro-items op +parro check # gwillem/parro: haal nieuwe Parro-items op (+ bijlagen) node sync.mjs # SQLite → Supabase (parro_items) +node fotos.mjs # bijlagen uit de cache → Supabase Storage (parro_fotos) node enrich.mjs # Claude: agenda + acties + belangrijk-vlaggen diff --git a/parro/index.html b/parro/index.html index 80a08ce..d659ddb 100644 --- a/parro/index.html +++ b/parro/index.html @@ -90,6 +90,18 @@ .soort-tag { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; padding: 2px 7px; border-radius: 999px; background: var(--light); color: var(--mid); } .soort-tag.belangrijk { background: #fdecec; color: #b23030; } + /* foto's */ + .foto-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; margin-top: 4px; } + .foto-tegel { position: relative; aspect-ratio: 1; border: none; padding: 0; margin: 0; background: var(--light); border-radius: 12px; overflow: hidden; cursor: pointer; } + .foto-tegel img, .foto-tegel video { width: 100%; height: 100%; object-fit: cover; display: block; } + .foto-tegel .speel { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; color: #fff; text-shadow: 0 2px 8px rgba(0,0,0,.6); pointer-events: none; } + + .lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 50; } + .lightbox .doos { max-width: 92vw; max-height: 92vh; display: flex; flex-direction: column; gap: 12px; align-items: center; } + .lightbox img, .lightbox video { max-width: 92vw; max-height: 78vh; border-radius: 12px; box-shadow: 0 8px 40px rgba(0,0,0,.5); } + .lightbox .balk { display: flex; gap: 10px; align-items: center; } + .lightbox .knop { border-color: transparent; } + .leeg { color: var(--mid); font-size: 14px; padding: 8px 0; } .midden { text-align: center; padding: 40px 0; } .midden .groot { font-size: 60px; margin-bottom: 16px; } @@ -110,7 +122,7 @@
lukasvan3l · parro

Parro-dashboard

-

Wat er op school speelt: de agenda met wat we moeten regelen, een weekoverzicht en belangrijke meldingen.

+

Wat er op school speelt: de agenda met wat we moeten regelen, een weekoverzicht, belangrijke meldingen en de foto's uit Parro.

@@ -156,6 +168,7 @@

Parro-dashboard

+ @@ -242,6 +255,50 @@

Laatste berichten

Nog geen berichten — draai de agent (zie agent/README.md).

+ + +
+
+

Foto's uit Parro

+ +

Nog geen foto's — die verschijnen zodra de agent Parro-bijlagen synct (zie agent/README.md).

+
+
+ + + @@ -295,6 +352,8 @@

Laatste berichten

agenda: [], // parro_agenda incl. geneste parro_acties weken: [], // parro_weekoverzicht items: [], // parro_items (zonder raw) + fotos: [], // parro_fotos incl. signed url + item-info + lightbox: null, // geopende foto/video open: {}, // uitgeklapte meldingen _channel: null, _zetSessieLopend: null, @@ -345,6 +404,30 @@

Laatste berichten

this.agenda = ag.data || []; this.weken = wk.data || []; this.items = it.data || []; + await this.laadFotos(); + }, + + async laadFotos() { + const { data } = await sb + .from("parro_fotos") + .select("*, parro_items(titel, groep, afzender)") + .order("datum", { ascending: false }) + .limit(300); + const rijen = data || []; + // Private bucket: per bestand een tijdelijke signed URL maken (mag alleen + // als je lid bent, zie de storage-policy). In batch voor snelheid. + const urls = {}; + if (rijen.length) { + const { data: signed } = await sb.storage + .from("parro-fotos") + .createSignedUrls(rijen.map((r) => r.pad), 3600); + for (const s of signed || []) if (s.signedUrl && !s.error) urls[s.path] = s.signedUrl; + } + this.fotos = rijen.map((r) => ({ + ...r, + url: urls[r.pad] || null, + isVideo: (r.content_type || "").startsWith("video") || r.soort === "VIDEO", + })).filter((r) => r.url); }, abonneer() { @@ -352,6 +435,7 @@

Laatste berichten

this._channel = sb.channel("parro") .on("postgres_changes", { event: "*", schema: "public", table: "parro_acties" }, () => this.laadAlles()) .on("postgres_changes", { event: "*", schema: "public", table: "parro_agenda" }, () => this.laadAlles()) + .on("postgres_changes", { event: "*", schema: "public", table: "parro_fotos" }, () => this.laadFotos()) .subscribe(); }, stopAbonnement() { @@ -387,6 +471,17 @@

Laatste berichten

}, get belangrijkeMeldingen() { return this.items.filter((m) => m.belangrijk); }, get meldingen() { return this.items.filter((m) => m.tekst || m.titel); }, + get fotosPerDag() { + const per = new Map(); + for (const f of this.fotos) { + const dag = f.datum ? iso(new Date(f.datum)) : ""; + if (!per.has(dag)) per.set(dag, []); + per.get(dag).push(f); + } + return [...per.entries()] + .sort((x, y) => y[0].localeCompare(x[0])) + .map(([datum, items]) => ({ datum, items })); + }, get laatsteSync() { const t = this.items[0]?.datum; return t ? new Date(t).toLocaleString("nl-NL", { dateStyle: "medium", timeStyle: "short" }) : ""; @@ -400,6 +495,13 @@

Laatste berichten

await this.laadAlles(); }, + /* ── foto's ── */ + downloadUrl(f) { + if (!f?.url) return "#"; + // '&download=' laat Supabase Storage het bestand als download serveren. + return f.url + "&download=" + encodeURIComponent(f.bestandsnaam || "foto"); + }, + /* ── weergave ── */ md: mdNaarHtml, dagLabel(d) { diff --git a/parro/supabase/README.md b/parro/supabase/README.md index 63fbfdc..70eea4c 100644 --- a/parro/supabase/README.md +++ b/parro/supabase/README.md @@ -8,7 +8,9 @@ alleen stap 1 en 2 te doen. SQL Editor → New query → plak [`schema.sql`](./schema.sql) → **Run**. Het allowlist/leden-deel is identiek aan dat van boodschappen en idempotent; -dubbel draaien kan geen kwaad. +dubbel draaien kan geen kwaad. Dit maakt meteen de private storage-bucket +`parro-fotos` aan (voor de foto's uit Parro) met een leespolicy: alleen leden +kunnen er signed URLs voor maken, de bestanden zijn niet publiek opvraagbaar. ## 2. Redirect-URL toevoegen diff --git a/parro/supabase/schema.sql b/parro/supabase/schema.sql index 4c03933..e11b56f 100644 --- a/parro/supabase/schema.sql +++ b/parro/supabase/schema.sql @@ -115,8 +115,22 @@ create table if not exists parro_weekoverzicht ( created_at timestamptz default now() ); +-- foto's/video's uit Parro-berichten. De bestanden zelf staan in de private +-- storage-bucket 'parro-fotos'; hier alleen de metadata + het pad erin. +create table if not exists parro_fotos ( + id text primary key, -- '#', bv 'event:123#0' + item_id text references parro_items(id) on delete cascade, + pad text not null, -- pad in de 'parro-fotos'-bucket + bestandsnaam text, + content_type text, + soort text, -- 'IMAGE' of 'VIDEO' (uit Parro) + datum timestamptz, -- overgenomen van het bijbehorende item + created_at timestamptz default now() +); + create index if not exists parro_items_datum_idx on parro_items (datum desc); create index if not exists parro_agenda_datum_idx on parro_agenda (datum); +create index if not exists parro_fotos_datum_idx on parro_fotos (datum desc); -- ── rechten (Postgres GRANT, los van RLS) ─────────────────────────────────── @@ -128,6 +142,7 @@ grant all on table public.parro_items to authenticated, service_role; grant all on table public.parro_agenda to authenticated, service_role; grant all on table public.parro_acties to authenticated, service_role; grant all on table public.parro_weekoverzicht to authenticated, service_role; +grant all on table public.parro_fotos to authenticated, service_role; grant execute on function public.is_member() to anon, authenticated, service_role; grant execute on function public.handle_new_user() to service_role; @@ -142,6 +157,7 @@ alter table parro_items enable row level security; alter table parro_agenda enable row level security; alter table parro_acties enable row level security; alter table parro_weekoverzicht enable row level security; +alter table parro_fotos enable row level security; drop policy if exists "eigen lid" on members; create policy "eigen lid" on members for select using (user_id = auth.uid()); @@ -156,6 +172,9 @@ create policy "leden lezen agenda" on parro_agenda for select using (is_member() drop policy if exists "leden lezen weekoverzicht" on parro_weekoverzicht; create policy "leden lezen weekoverzicht" on parro_weekoverzicht for select using (is_member()); +drop policy if exists "leden lezen fotos" on parro_fotos; +create policy "leden lezen fotos" on parro_fotos for select using (is_member()); + -- acties: leden mogen lezen én afvinken drop policy if exists "leden lezen acties" on parro_acties; create policy "leden lezen acties" on parro_acties for select using (is_member()); @@ -173,6 +192,22 @@ do $$ begin alter publication supabase_realtime add table parro_agenda; exception when duplicate_object then null; end $$; +do $$ begin + alter publication supabase_realtime add table parro_fotos; +exception when duplicate_object then null; end $$; + +-- ── storage-bucket voor Parro-foto's ──────────────────────────────────────── +-- Private bucket: de bestanden zijn NIET publiek opvraagbaar. De frontend +-- maakt per sessie signed URLs aan (mag alleen als je lid bent, zie policy); +-- de agent uploadt met de service-role key (omzeilt RLS). +insert into storage.buckets (id, name, public) +values ('parro-fotos', 'parro-fotos', false) +on conflict (id) do nothing; + +drop policy if exists "leden lezen parro-fotos" on storage.objects; +create policy "leden lezen parro-fotos" on storage.objects for select + to authenticated using (bucket_id = 'parro-fotos' and is_member()); + -- ── VUL JE ALLOWLIST (overslaan als het boodschappen-project al gevuld is) ── -- insert into allowed_emails (email) values -- ('lukas@q42.nl'),