- PHP 55.8%
- JavaScript 15.8%
- CSS 15.6%
- Python 12.8%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| lib | ||
| public | ||
| tools | ||
| .gitignore | ||
| config.example.php | ||
| pantry.yaml | ||
| README.md | ||
一汁三菜 · 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 maaltijd in een link
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: 0op 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: anywhereop 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
Gohanmaar staat niet in de categorieIchiju-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.nunaarboromir.ams1.nl.exped.systems:80met 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.