Generator for an ichiju-sansai meal with recipes from Mealie.
  • PHP 55.8%
  • JavaScript 15.8%
  • CSS 15.6%
  • Python 12.8%
Find a file
nhendriks 892b188ef2
All checks were successful
Deploy / deploy (push) Successful in 5s
Note that poolSize is counted after the once-per-tray filter
2026-09-01 22:26:49 +02:00
.forgejo/workflows Limit a tray to one grilled and one ohitashi dish 2026-08-31 10:16:48 +02:00
lib Put the choices behind a menu on the phone, with pictures in the search 2026-09-01 18:59:08 +02:00
public Fix the sideways scroll in the search list and give the question its own dialog 2026-09-01 20:40:33 +02:00
tools Put the choices behind a menu on the phone, with pictures in the search 2026-09-01 18:59:08 +02:00
.gitignore Gate the calendar write behind a Nextcloud login and write over CalDAV 2026-08-30 17:53:45 +02:00
config.example.php Keep the login for 30 days and refresh the Nextcloud token 2026-08-30 23:54:49 +02:00
pantry.yaml Split pantry staples out of the shopping list and keep phones light 2026-08-30 23:47:54 +02:00
README.md Note that poolSize is counted after the once-per-tray filter 2026-09-01 22:26:49 +02:00

一汁三菜 · ichiju.exped.nu

Stelt een washoku-maaltijd samen volgens het ichiju-sansai principe ("een soep, drie gerechten") uit de receptenverzameling op recipes.exped.nu.

Live op https://ichiju.exped.nu/

Wat het doet

Het kiest per gang een recept en zet ze neer zoals ze op een 膳 (zen, dienblad) staan in een gewone rechtshandige dekking:

achter:   副菜 fukusai      副々菜 fuku-fukusai   主菜 shusai
voor:     御飯 gohan        香の物 kō no mono     汁物 shirumono

De twee bijgerechten zijn 副菜 (het grotere) en 副々菜 (het kleinere). Dat laatste wordt vaak in een 小鉢 (kobachi) opgediend, maar dat is de kom, niet de gang.

Klik op het bolletje boven een kom om dat gerecht vast te houden; bij "Stel opnieuw samen" blijft het staan en wisselt de rest.

Het dienblad staat gecentreerd; de knoppen staan er rechts naast in een kolom, die meeloopt als je scrollt. Onder 64 rem past die kolom er niet meer naast en gaan de knoppen er weer boven staan, liggend in plaats van staand.

Verder kun je vanaf hier vier dingen: een dieetfilter kiezen, één gerecht opzoeken en de rest daaromheen laten samenstellen, de maaltijd in je agenda zetten, en de boodschappenlijst opvragen. Elk daarvan heeft zijn eigen kopje hieronder; kort samengevat is de pagina openbaar en alleen-lezen, op het wegschrijven naar de agenda na.

Een samengestelde maaltijd past in één parameter: ?m= met de slugs in dienbladvolgorde, gescheiden door een tilde, en een leeg segment voor een gang die niet gevuld raakte. Slugs bestaan alleen uit [a-z0-9-], dus dat scheidingsteken hoeft nooit ge-escaped te worden en de link blijft leesbaar.

https://ichiju.exped.nu/?m=simmered-koyadofu~gomaae~teriyaki-tofu~japanse-rijst-rijstkoker~...

De pagina zet die maaltijd neer bij het openen, maar zet de gerechten niet vast. Klik je op "Stel opnieuw samen", dan krijg je gewoon een nieuwe maaltijd; anders zou de pagina uit een agenda-afspraak voorgoed dezelfde zes gerechten blijven tonen.

Zoeken naar één gerecht

Andersom beginnen: je weet al dat je vanavond onsen tamago wilt, en de rest mag eromheen. Het veld "Ik wil in elk geval" zoekt in de verzameling; kies je een resultaat, dan wordt dat gerecht vastgehouden in zijn gang (hetzelfde slotje als handmatig) en worden de andere vijf eromheen samengesteld.

search.php?q= doet dat werk, via Zen::candidates(). Wat je moet weten:

  • Elk resultaat heeft zijn foto erbij. De recepten houden hun Japanse naam, dus voor wie de keuken nog aan het leren is, is het plaatje vaak sneller te lezen dan de regel ernaast. Het is Mealie's eigen tiny-original.webp, een tiende van de foto op het dienblad.
  • Een gerecht met twee roltags komt twee keer terug, één keer per gang. Welke van de twee het moet worden is een keuze van de lezer, niet van de code.
  • Het dieetfilter wordt gemeld, niet toegepast. Een gerecht dat buiten het gekozen dieet valt staat er gewoon bij, met de reden eronder in vermiljoen, en je kunt het gewoon kiezen. Het weglaten zou lijken alsof het niet in de verzameling zit, en het weigeren zou een keuze zijn die de pagina niet toekomt. Zie "Een vastgehouden gerecht wint van het dieet".
  • Vervangt je keuze iets dat je al vasthoudt, dan wordt het eerst gevraagd. Een gerecht dat je zelf met het slotje hebt vastgezet raak je niet kwijt aan een zoekopdracht zonder dat je het ziet. Vult je keuze een gang die alleen maar toevallig gevuld was, dan gebeurt dat zonder vraag: die stond er niet op jouw verzoek.

Die vraag heeft een eigen dialoog, op hetzelfde papier en met dezelfde lijn als de boodschappenlijst. Niet confirm() van de browser: die kan de twee gerechten niet noemen zonder ze in een systeemlettertype te schreeuwen, en hij ziet eruit als een foutmelding terwijl het een vraag is. Het veilige antwoord (Laat staan) staat links, waar een duim landt; de achtergrond aantikken en Escape betekenen hetzelfde.

Een dwarse schuifbalk in de resultatenlijst

De lijst gaf een horizontale schuifbalk, en de oorzaak is een CSS-regel die je maar één keer hoeft te leren: zodra één as niet visible is, wordt de andere auto. De lijst vroeg alleen om overflow-y: auto voor de verticale kant en kreeg de horizontale er gratis bij. Nu staat overflow-x: hidden er met zoveel woorden bij.

Daar hoorden nog twee dingen bij, want anders was er echt iets te breed:

  • min-width: 0 op alles tussen de kolom en de tekst (.controls, .menu, .find, .find-pick, .find-text). Een flex- of grid-item krimpt standaard niet onder zijn inhoud, dus één lange receptnaam maakte de hele kolom breder in plaats van af te breken.
  • overflow-wrap: anywhere op de naam en de rolregel, zodat "Chawanmushi (Japanese Savory Steamed Egg Custard)" desnoods midden in een woord mag breken.
  • Accenten worden weggevouwen, want niemand typt een é in een supermarkt.
  • De beschrijving wordt pas doorzocht als geen enkele naam past. De recepten houden hun Japanse of Engelse naam (zie MEALIE.md), maar de beschrijvingen zijn Nederlands, dus zonder die terugval vindt "misosoep" niets terwijl er vijf soepen op antwoorden. Allebei tegelijk doorzoeken is geprobeerd en is slechter: beschrijvingen noemen waar een gerecht bij past, dus "rijst" kwam terug met elf gerechten die rijst alleen maar noemen, en "onigiri" met een zalm.
  • Alleen wat in een gang past komt terug. Onigiri draagt Gohan maar staat niet in de categorie Ichiju-Sansai, dus de zoekfunctie biedt hem niet aan: dan zou je iets kunnen kiezen dat de samensteller vervolgens weigert neer te zetten.

Enter neemt het eerste bruikbare resultaat. Dat is wat de zoekknop van een telefoonkeyboard bedoelt, en het scheelt met een duim op een lijstje mikken.

Aanmelden

De pagina is openbaar en blijft dat: lezen en samenstellen vraagt niets. Aanmelden met het Nextcloud-account op citadel levert twee dingen op, en verder niets:

  • de receptlinks wijzen standaard naar Mealie in plaats van naar de oorspronkelijke bron. Zie "Waar een recept opengaat" hieronder: die keuze staat sinds kort ook los van het aanmelden.
  • "Zet in agenda" mag schrijven. Dat is het enige endpoint met een slot erop.

Een vastgehouden gerecht wint van het dieet

Wat je zelf hebt vastgezet, met het slotje of via de zoekfunctie, komt op het dienblad wat het dieetfilter ook zegt. Zen::compose() zoekt een pin daarom op in de hele verzameling en niet in de gefilterde pool; de gang moet nog wel kloppen, dus er belandt nooit rijst in de soepkom.

Dat is dezelfde regel als bij AT_MOST_ONE, waar twee met opzet vastgezette gegrilde gerechten allebei blijven staan: een uitdrukkelijke keuze gaat voor een regel die er alleen is om je te helpen. De vijf gangen die de generator er zelf omheen trekt houden zich gewoon aan het filter.

De pagina zegt wel wat er gebeurt. De API zet outsideDiet op zo'n gerecht en de statusregel leest dan bijvoorbeeld "Onsen Tamago valt buiten dit dieet, maar je houdt hem vast." Om dezelfde reden wordt bij het wisselen van dieet niets meer losgelaten: dat wiste eerder je eigen keuzes bij een druk op de knop ergens anders.

Waar een recept opengaat

Een klein schakelaartje in het menu: "Recept openen in: de bron / Mealie". Alleen op de telefoon.

Zonder aanmelding gaan de receptlinks naar de site waar het recept vandaan komt, want recipes.exped.nu wil zelf een login en een vreemde daarheen sturen is hem tegen een muur sturen. Nicky's eigen recepten hebben geen bron, die houden de Mealie-link sowieso.

Op de telefoon klopte dat niet meer: het aanmelden staat daar uit (zie hieronder), terwijl de telefoon meestal juist wél al bij Mealie is ingelogd. Vandaar deze schakelaar. Op een bureau doet aanmelden dat werk al, dus daar staat hij niet: twee manieren om hetzelfde te zeggen is er een te veel.

  • De keuze wordt per apparaat onthouden in localStorage, want het antwoord verschilt per apparaat.
  • Is er nog niets gekozen, dan geldt het oude gedrag: aangemeld is Mealie, anders de bron.
  • Omzetten stelt geen nieuwe maaltijd samen. Alleen de links veranderen, dus het dienblad wordt opnieuw getekend; je gerechten kwijtraken omdat je koos waar ze opengaan zou een rare prijs zijn.

In de knoppenkolom staat er altijd maar één van de twee: niet aangemeld zie je alleen "Aanmelden", aangemeld alleen "Zet in agenda", met eronder wie je bent. Allebei tegelijk stelt dezelfde vraag twee keer.

Hoe lang je aangemeld blijft

30 dagen, en dat vroeg twee losse dingen, want er lopen twee klokken.

De eerste is de PHP-sessie. Die stond op 24 minuten, en niet omdat dit ding dat zo instelde: Debian's phpsessionclean.timer veegt elk half uur door de gedeelde /var/lib/php/sessions en gooit alles weg dat ouder is dan de hoogste session.gc_maxlifetime uit de php.ini's, en dat is 1440 seconden. Je eigen levensduur verhogen helpt daar niets: de opruimer pakt je sessie toch. De oplossing is die map verlaten. Vandaar session_path in config.php, en PHP ruimt die eigen map zelf op.

De tweede is het access token van Nextcloud, dat een uur meegaat. De eerste versie hiervan bewaarde alleen dat token en gooide het refresh token weg, waardoor een sessie zijn eigen inloggegevens kon overleven: je bleef "aangemeld" en na een uur faalde het wegschrijven naar de agenda zonder dat je er iets aan kon doen. Nu wordt het refresh token bewaard en op tijd ingewisseld, en bij een 401 volgt nog één poging voordat je opnieuw wordt gevraagd aan te melden. Nextcloud roteert dat refresh token, dus de nieuwe vervangt telkens de oude.

Alleen het eerste ophogen levert een sessie op die zijn eigen token overleeft; alleen het tweede laat je nog steeds na 24 minuten uitloggen. Het moest allebei.

Het is OAuth 2.0 tegen Nextcloud's eigen oauth2-app. Wat er in de sessie komt te staan is het gebruikersnaam en het access token; dat token wordt één keer gebruikt, om de afspraak als die gebruiker weg te schrijven, en verlaat de server nooit. Er staat dus geen langlopend wachtwoord voor de agenda op boromir.

De client moet je in Nextcloud registreren onder Instellingen > Beheer > Beveiliging > OAuth 2.0-clients, met als redirect-URI exact https://ichiju.exped.nu/auth.php. Client-id en agenda staan in config.php; het secret in oauth-client-secret, naast mealie-token en op dezelfde manier afgeschermd. Zolang de client-id leeg is, verbergt de pagina de aanmeldlink en de agendaknop, in plaats van een knop aan te bieden die nergens heen gaat.

Boodschappenlijst

"Boodschappenlijst" opent een venster met alles wat je voor deze maaltijd moet kopen. Bedoeld voor het geval waar het om begonnen is: na het werk in de supermarkt staan zonder idee wat je gaat eten.

De ingrediënten van de zes gerechten worden bij elkaar opgeteld. Omdat alles in deze verzameling geparseerd is naar hoeveelheid, eenheid en product, wordt drie keer mirin één regel in plaats van drie. Optellen gebeurt op product en eenheid: 2 stengels sereh en 10 gram sereh blijven twee regels, want die kun je niet bij elkaar optellen en doen alsof dat wel kan is erger dan twee regels.

Tik een regel aan om hem af te strepen, of kopieer de hele lijst naar het klembord om hem in de app van de supermarkt te plakken.

Water staat er nooit op. Vier recepten die er allemaal wat van willen leverde een regel op met "3080 ml water", en dat is geen boodschap.

Kastvoorraad staat er wel op, maar apart onderaan. Je kunt net zo goed door je sojasaus heen zijn als door je komkommers; je loopt er alleen de winkel niet voor rond. Welke producten dat zijn staat in pantry.yaml in de hoofdmap: bijgehouden in git, met de hand aan te passen, en geen config, dus niet in config.php. Staat iets er niet bij, dan komt het gewoon bij de verse boodschappen; dat is de veilige kant om op te vallen.

Mealie heeft zelf een on_hand-vlag op producten die hier beter voor was geweest, maar die zit niet in de API-uitvoer en er is niets mee gemarkeerd.

Het lezen van dat bestand vraagt php-yaml, geïnstalleerd op boromir op 2026-08-30.

Er wordt niets weggeschreven. De lijst wordt getoond, niet opgeslagen, dus dit draait op hetzelfde alleen-lezen token als de rest van de pagina en vraagt geen aanmelding. Het ingrediëntenoverzicht zit niet in de lijst-API van Mealie, dus hiervoor wordt per recept een losse aanroep gedaan; die worden apart gecachet, want de ingrediënten van een recept veranderen veel minder vaak dan de verzameling zelf.

Op de telefoon

Onder 46 rem verdwijnen "Zet in agenda", "Aanmelden" en de datumkiezer helemaal. Op een telefoon is deze pagina er om te beslissen wat je eet en wat je moet halen; iets in je agenda zetten is werk voor achter een bureau, en een knop die je daar toch niet gebruikt is alleen maar in de weg.

Het menu

Onder 46 rem staan de knoppen boven het dienblad, en drie kolommen aan bedieningselementen duwen de maaltijd van het scherm af. Daarom gaan het dieetfilter, "Ik wil in elk geval" en de "openen in"-schakelaar achter één knop linksboven. Tikken opent een paneel over de pagina heen, met het zoekveld al scherpgesteld; kiezen sluit het weer, net als Escape of een tik naast het paneel.

Wat er met opzet níet in zit: "Stel opnieuw samen" en de boodschappenlijst. Dat zijn de twee die je zonder nadenken aantikt, en een menu ervoor is een tik die je elke keer betaalt. Die blijven dus gewoon onder het dienblad staan.

Op een bureau is het menu geen menu: dezelfde drie blokken stapelen daar in de knoppenkolom zoals altijd. Alles wat er een paneel van maakt zit in het 46 rem-blok onderaan style.css, dus er hoeft niets ongedaan gemaakt te worden voor de brede weergave.

De boodschappenlijst gaat op die maat schermvullend, met grotere raakvlakken en 100dvh zodat de adresbalk van de browser hem niet afsnijdt.

Op de telefoon is de pagina altijd licht, ook als het toestel op donker staat. Het donkere palet zit achter (prefers-color-scheme: dark) and (min-width: 46.01rem). Onder de tl-verlichting van een supermarkt is een donkere pagina namelijk juist het slechtst leesbare: het scherm is donkerder dan alles eromheen en elke weerspiegeling komt boven op de tekst terecht.

In de agenda

"Zet in agenda" schrijft de maaltijd rechtstreeks in de Nextcloud-agenda, met een PUT naar de CalDAV-collectie uit config.php. Eén klik, geen dialoog: Thunderbird trok voor een gedownload .ics de hele importwizard open, en dat was voor een knop die je even indrukt te veel.

Klikken zet op de plek van de knop een datumveld neer en opent meteen de datumkiezer van de browser zelf (showPicker()). Een dag kiezen is het hele antwoord: er valt niets meer te bevestigen, de afspraak wordt op dat moment weggeschreven. Enter doet hetzelfde, Escape sluit het weer. Waar showPicker() niet bestaat of geweigerd wordt, staat het veld er gewoon om zelf aan te klikken.

Verloopt de aanmelding onderweg, dan reist de gekozen dag mee in de terugkeer-URL, zodat de maaltijd niet stiekem op vandaag belandt.

De afspraak is dagvullend, somt de zes gangen op en zet er één link bij, terug naar deze pagina met precies die maaltijd. Zes losse receptlinks maakten hem onleesbaar, en de pagina toont elk gerecht toch al met zijn eigen link. De UID is afgeleid van de maaltijd en de datum, dus twee keer dezelfde maaltijd op dezelfde dag werkt dezelfde afspraak bij in plaats van er een tweede naast te zetten.

ics.php bestaat nog en levert precies hetzelfde bestand als download, voor een agenda die geen CalDAV spreekt. Beide bouwen de afspraak met lib/Event.php, dus ze kunnen niet uit elkaar gaan lopen, en de deploy-check die de één controleert zegt ook iets over de ander.

TRANSP:TRANSPARENT zorgt dat de afspraak je dag niet als bezet markeert.

Hoe de gerechten worden gekozen

Elke gang komt uit een Mealie-tag. Die tags zijn tijdens het receptenwerk bewust aangebracht, zie MEALIE.md in dev_exped.

Gang Tag
汁物 soep Ichiju
主菜 hoofdgerecht Shusai
副菜 bijgerecht Fukusai
副々菜 klein bijgerecht Fuku-fukusai
香の物 ingelegd Tsukemono
御飯 rijst Gohan én categorie Ichiju-Sansai

Een recept mag meer dan één rol dragen. Eiwitrijke bijgerechten zijn het hoofdgerecht als er geen andere eiwitbron op tafel staat, en een bijgerecht als die er wel is; sojaeieren, chawanmushi, tamagoyaki en hiyayakko staan daarom als Shusai én Fukusai. Ze zijn nooit Fuku-fukusai, daarvoor zijn ze te stevig. Op het recept zelf staat een notitie "Rol in een ichiju-sansai" die uitlegt hoeveel je per rol rekent. De generator zet nooit hetzelfde recept in twee gangen, dus welke rol hij ook krijgt, de andere gang wordt door iets anders gevuld.

Hoogstens één keer per dienblad

Sommige tags mogen maar één keer op een dienblad staan. Ze staan in Zen::AT_MOST_ONE:

Tag Waarom Aantal recepten
Yakimono 焼き物 de gegrilde gang 12
Ohitashi groente getrokken in dezelfde gekruide dashi 7

De drie bijgerechten horen elk een andere bereiding te gebruiken, zodat smaak, textuur en kleur elkaar aanvullen in plaats van herhalen. Twee gerechten van dezelfde grill, of twee kommetjes groente uit dezelfde dashi, is precies de herhaling waar die opzet voor bedoeld is.

poolSize in het antwoord van api.php is de pool ná deze regel, niet ervoor. Dat is verwarrend als je hem gebruikt om te tellen hoeveel recepten een gang heeft: trekt de fukusai een gegrild gerecht, dan zakt de pool van het hoofdgerecht meteen met alle Yakimono die daar ook in zitten. Op 2026-09-01 stond er 19 terwijl 24 recepten de tag Shusai droegen, precies het verschil van de vijf gegrilde hoofdgerechten. Wil je weten hoeveel er echt zijn, tel dan de tag in Mealie en niet dit getal.

Dat is geen theorie. Van de negen Yakimono zijn er drie Shusai (saba shioyaki, shiozake en miso-zalm), dus zonder deze regel zette de generator geregeld gegrilde zalm naast gegrilde aubergine. En alle zeven ohitashi dragen zowel Fukusai als Fuku-fukusai, twee gangen die naast elkaar staan: dat botste van alles het vaakst.

De regel bepaalt alleen wat er getrokken wordt. Zet je zelf twee gegrilde gerechten vast met het slotje, dan krijg je ze allebei: een keuze van jou gaat voor. Omdat de gangen in dienbladvolgorde worden gevuld en Fukusai dus vóór Shusai komt, worden vastgezette gerechten geteld vóórdat er ook maar één gang wordt getrokken.

Wil je er later een tag bij, zet hem dan in Zen::AT_MOST_ONE. Verder hoeft er niets te veranderen: zowel de test als de healthcheck lezen die lijst en niet de losse namen.

tools/zen_test.php bewaakt dit. Het draait bij elke deploy, vóór de healthcheck, tegen een verzonnen collectie waarin bijna elk gerecht zo'n tag draagt: in de echte collectie zou een kapotte regel te vaak per ongeluk goed uitpakken. Dat is nagelopen door de filterregel expres uit te zetten, waarna de test hem inderdaad betrapte op vier gegrilde gerechten op één blad.

Dieet

Knop Tags die meetellen
Alles alles
Geen vlees Vis, Vegetarisch, Vegan
Vegetarisch Vegetarisch, Vegan
Vegan Vegan

De dieettags beschrijven wat een gerecht kan zijn, niet alleen wat er letterlijk staat. Een soep die vegetarisch wordt door vegan dashi te gebruiken telt als Vegetarisch, want het recept zegt zelf hoe. Dat is nodig omdat dashi en katsuobushi anders bijna elk groentegerecht uit deze keuken niet-vegetarisch zouden maken.

Dat betekent ook dat een gerecht meerdere dieettags kan hebben: Chikuzenni is Vlees én Vegetarisch, omdat de kip weggelaten kan worden zonder dat het gerecht iets anders wordt. Waar het vlees of de vis juist het gerecht ís (karaage, misozalm, tonjiru) staat alleen Vlees of Vis.

De rijst is het enige geval met een extra voorwaarde, en dat is met opzet. Meerdere gerechten dragen Gohan (onigiri, takikomi gohan, tamago kake gohan), maar dat zijn gerechten van rijst en dus een gerecht op zichzelf. Alleen gewone witte rijst hoort in deze plek van de maaltijd, en die staat als enige ook in de categorie Ichiju-Sansai.

Het filter geldt voor wat de generator zelf trekt, niet voor wat jij vasthoudt. Zie "Een vastgehouden gerecht wint van het dieet" hierboven. Van dieet wisselen laat daarom ook niets los: dat wiste eerder je eigen keuzes met een druk op een knop ergens anders.

Draait waar

  • boromir.ams1 (Ubuntu, Apache 2.4, PHP 8.3), docroot /var/www/domains/exped.nu/ichiju.exped.nu/public
  • Vhost staat in /etc/apache2/sites-available/exped.nu.conf, naast clock, weather, hours en infra. Apache luistert alleen op poort 80; TLS eindigt bij NPM.
  • NPM op mithrandir.ams1 proxyt ichiju.exped.nu naar boromir.ams1.nl.exped.systems:80 met het wildcardcertificaat *.exped.nu.
  • DNS op strider.ams1: ichiju.exped.nu CNAME exped.nu. plus een TLSA-record, zoals de andere subdomeinen.

Uitrollen

Een push naar master rolt zichzelf uit. .forgejo/workflows/deploy.yml draait op de Forgejo-runner die al op boromir staat (runs-on: boromir.ams1.nl.exped.systems, systemd-unit forgejo-runner, draait als gebruiker forgejo met sudo): hij doet een git pull --ff-only in de webroot, zet de rechten terug, gooit de receptencache weg en draait dan tools/healthcheck.py. Dat laatste staat er met opzet in: een exit 0 van git pull is geen bewijs dat de site het nog doet. De check haalt de pagina op, controleert dat alle zes gangen gevuld zijn, en trekt er een .ics uit die geldig moet zijn: één afspraak, dagvullend, geen regel langer dan 75 octetten, en elk gerecht erin genoemd.

Apache wordt bewust niet herladen. PHP pakt de nieuwe bestanden vanzelf op, en een reload raakt alle andere sites op boromir.

config.php en mealie-token staan in .gitignore en blijven dus staan bij een pull. De workflow zet hun eigenaar en rechten na afloop wel terug, want de chown -R ervoor haalt ze anders onderuit.

Root op boromir heeft safe.directory nodig voor deze map, net als voor de andere sites daar:

git config --global --add safe.directory /var/www/domains/exped.nu/ichiju.exped.nu

Installeren

# php-yaml is nodig voor pantry.yaml; php-curl en php-mbstring voor de rest
apt-get install -y php-yaml

git clone ssh://forgejo@forge.exped.nu:222/Expeditioner/ichiju.exped.nu.git \
    /var/www/domains/exped.nu/ichiju.exped.nu
cd /var/www/domains/exped.nu/ichiju.exped.nu

cp config.example.php config.php
printf '%s' 'HET-MEALIE-TOKEN'   > mealie-token
printf '%s' 'HET-OAUTH-SECRET'   > oauth-client-secret
chown root:www-data mealie-token oauth-client-secret config.php
chmod 640 mealie-token oauth-client-secret config.php

# receptencache
install -d -o www-data -g www-data /var/cache/ichiju

# eigen sessiemap, anders duurt aanmelden 24 minuten in plaats van 30 dagen
install -d -o www-data -g www-data -m 700 /var/lib/php/ichiju-sessions

Daarna in Nextcloud de OAuth 2.0-client registreren onder Instellingen > Beheer > Beveiliging, met als redirect-URI exact <site_url>/auth.php, en de client-id in config.php zetten.

De vhost staat in /etc/apache2/sites-available/exped.nu.conf met docroot public/; alles buiten public/ wordt geweigerd, en daar rekenen mealie-token, oauth-client-secret en config.php op.

Over het token

De pagina gebruikt een eigen, langlopend Mealie API-token (ichiju-generator), geen wachtwoord. Het wordt alleen-lezen gebruikt en kan in Mealie zelf worden ingetrokken zonder dat er verder iets verandert.

Het token staat in mealie-token in de hoofdmap, niet in config.php. Dat bestand staat in .gitignore en is root:www-data 0640, dus alleen de webserver leest het. Het ligt buiten public/, en de vhost weigert alles buiten public/, dus het is ook niet op te vragen: een verzoek aan /mealie-token geeft 404. Draai je het token, dan hoeft alleen dat ene bestand vervangen te worden.

Structuur

pantry.yaml        wat er standaard in huis is, en wat nooit op de lijst hoort
lib/Mealie.php     alleen-lezen API-client, met bestandscache
lib/Zen.php        de zes gangen en hun tags; stelt de maaltijd samen
lib/Event.php      bouwt de agenda-afspraak; gedeeld door ics.php en plan.php
lib/Auth.php       aanmelden met het Nextcloud-account (OAuth 2.0)
lib/CalDav.php     schrijft één afspraak weg; het enige dat iets schrijft
public/index.php   de pagina
public/api.php     geeft een samengestelde maaltijd als JSON
public/search.php  zoekt één gerecht op naam, en zegt welke gang het kan vullen
public/shopping.php  telt de ingrediënten van de zes gerechten bij elkaar op
public/ics.php     dezelfde maaltijd als download, voor agenda's zonder CalDAV
public/plan.php    schrijft de maaltijd in de agenda; alleen aangemeld
public/signin.php  begint het aanmelden
public/auth.php    waar Nextcloud op terugkomt
tools/healthcheck.py  draait na elke deploy: pagina, api, ics, zoeken, en het slot op plan.php
tools/zen_test.php    de samensteller en de zoekfunctie tegen een verzonnen verzameling
public/assets/     stijl, gedrag en de iconen
tools/glyph.py     leest één tekenomtrek uit een TrueType-font
tools/make_icons.py  tekent de favicons

Favicon

Het teken 飯 (han), gekookte rijst, in kinari op een indigo tegel.

tools/make_icons.py tekent de iconen rechtstreeks naar PNG. Deze werkplek heeft geen rsvg-convert, geen Pillow en geen fontTools, dus tools/glyph.py leest de omtrek van het teken zelf uit het fontbestand (cmap, loca, glyf) en make_icons.py vult die met een eigen scanline-rasteriser.

Het font is Zen Kaku Gothic New Bold, dezelfde familie als de pagina gebruikt, onder de SIL Open Font License. Alleen de gerenderde pixels staan in de repo, het fontbestand niet. Opnieuw renderen:

curl -fsSLO https://github.com/google/fonts/raw/main/ofl/zenkakugothicnew/ZenKakuGothicNew-Bold.ttf
PYTHONPATH=tools python3 tools/make_icons.py ZenKakuGothicNew-Bold.ttf

BOX staat met opzet ruim: 飯 heeft twaalf streken, en op 16 px telt elke pixel inkt. Kleiner oogt beter op 180 px en wordt pap in een tabbladbalk.

Er is bewust geen SVG-versie. De PNG's zijn gerenderd en bekeken voordat ze de deur uit gingen; een met de hand geschreven SVG-pad kun je alleen in een browser controleren, en iets ongecontroleerds als hoofdicoon uitleveren is erger dan één formaat minder.

De server praat met Mealie, de browser niet: het token blijft op boromir.

Over het principe

Ichiju-sansai is de opbouw van een alledaagse maaltijd in de 和食 (washoku) keuken, in 2013 door UNESCO erkend als immaterieel cultureel erfgoed. Naast de rijst en de soep staan drie gerechten die elk een andere bereiding gebruiken, zodat smaak, textuur en kleur elkaar aanvullen in plaats van herhalen.

De pagina is met respect voor die achtergrond vormgegeven: de kleuren zijn traditionele Japanse kleurnamen (生成り kinari, 墨 sumi, 藍 ai, 朱 shu), de opstelling volgt de werkelijke plaatsing op het dienblad, en de terminologie is de gangbare Japanse.