Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions parro/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,21 @@ 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
allowlist/leden-deel is identiek aan boodschappen en idempotent) en
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

Expand All @@ -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

Expand Down
14 changes: 8 additions & 6 deletions parro/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<guardian>/ (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)
Expand Down Expand Up @@ -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
```

Expand All @@ -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/<guardian>/` 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. |

Expand Down
46 changes: 46 additions & 0 deletions parro/agent/db.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
164 changes: 164 additions & 0 deletions parro/agent/fotos.mjs
Original file line number Diff line number Diff line change
@@ -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/<guardian>/<msgId>_<bestandsnaam>. 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/<guardian>/).
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 <msgId>_<bestandsnaam>; als de naam onbekend is
// pakken we het n-de bestand dat met "<msgId>_" 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` : ""),
);
1 change: 1 addition & 0 deletions parro/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"scripts": {
"sync": "node sync.mjs",
"fotos": "node fotos.mjs",
"enrich": "node enrich.mjs",
"week": "node week.mjs",
"run": "./run.sh"
Expand Down
3 changes: 2 additions & 1 deletion parro/agent/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading