Add portion controls and improve recipe display

This commit is contained in:
2026-08-03 15:45:34 -05:00
parent 139eb9bca2
commit f94b08a4e6
10 changed files with 276 additions and 28 deletions
+12 -12
View File
@@ -15,7 +15,7 @@
inset: 0; inset: 0;
z-index: 20; z-index: 20;
overflow: hidden; overflow: hidden;
padding: 42px 52px; padding: 50px 58px;
background: #11110f; background: #11110f;
} }
@@ -40,18 +40,18 @@
} }
.MMM-NextcloudCookbook .ncc-recipe h1 { .MMM-NextcloudCookbook .ncc-recipe h1 {
margin: 0 100px 18px 0; margin: 0 100px 24px 0;
color: #fff6d8; color: #fff6d8;
font-size: 52px; font-size: 68px;
line-height: 1.08; line-height: 1.08;
} }
.MMM-NextcloudCookbook .ncc-meta { .MMM-NextcloudCookbook .ncc-meta {
display: flex; display: flex;
gap: 8px 18px; gap: 10px 22px;
align-items: baseline; align-items: baseline;
margin: 0 0 24px; margin: 0 0 32px;
font-size: 21px; font-size: 28px;
} }
.MMM-NextcloudCookbook .ncc-meta dt { .MMM-NextcloudCookbook .ncc-meta dt {
@@ -66,25 +66,25 @@
.MMM-NextcloudCookbook .ncc-columns { .MMM-NextcloudCookbook .ncc-columns {
display: grid; display: grid;
grid-template-columns: minmax(280px, 0.8fr) minmax(430px, 1.4fr); grid-template-columns: minmax(280px, 0.8fr) minmax(430px, 1.4fr);
gap: 46px; gap: 52px;
} }
.MMM-NextcloudCookbook .ncc-columns h2 { .MMM-NextcloudCookbook .ncc-columns h2 {
margin: 0 0 12px; margin: 0 0 18px;
color: #d6b96b; color: #d6b96b;
font-size: 30px; font-size: 42px;
} }
.MMM-NextcloudCookbook .ncc-columns ul, .MMM-NextcloudCookbook .ncc-columns ul,
.MMM-NextcloudCookbook .ncc-columns ol { .MMM-NextcloudCookbook .ncc-columns ol {
margin: 0; margin: 0;
padding-left: 1.25em; padding-left: 1.25em;
font-size: 23px; font-size: 34px;
line-height: 1.3; line-height: 1.34;
} }
.MMM-NextcloudCookbook .ncc-columns li { .MMM-NextcloudCookbook .ncc-columns li {
margin-bottom: 9px; margin-bottom: 14px;
} }
.MMM-NextcloudCookbook .ncc-error { .MMM-NextcloudCookbook .ncc-error {
+3 -1
View File
@@ -3,7 +3,9 @@
Module.register("MMM-NextcloudCookbook", { Module.register("MMM-NextcloudCookbook", {
defaults: { defaults: {
controlPath: "/MMM-NextcloudCookbook/control", controlPath: "/MMM-NextcloudCookbook/control",
animationSpeed: 400 animationSpeed: 400,
shareUrl: "",
sharePassword: ""
}, },
start() { start() {
+57 -6
View File
@@ -1,13 +1,12 @@
# MMM-NextcloudCookbook # MMM-NextcloudCookbook
A MagicMirror module that displays recipes from a read-only Nextcloud public A MagicMirror module that displays recipes from a read-only Nextcloud public
share. It includes a phone-friendly control page for choosing a recipe and share. It includes a phone-friendly control page for choosing a recipe,
entering or leaving recipe mode. adjusting its number of portions, and entering or leaving recipe mode.
The Nextcloud share URL is read only by `node_helper.js` from Create a public share for the folder configured as your Nextcloud Cookbook
`SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL`. An optional protected-share password can recipe directory. Read-only permission is sufficient. Copy the public share
be supplied as `SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD`. Neither value is sent link; it should look like `https://cloud.example.test/s/SHARE_TOKEN`.
to the MagicMirror browser or control page.
## MagicMirror configuration ## MagicMirror configuration
@@ -16,11 +15,32 @@ to the MagicMirror browser or control page.
module: "MMM-NextcloudCookbook", module: "MMM-NextcloudCookbook",
position: "fullscreen_above", position: "fullscreen_above",
config: { config: {
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
controlPath: "/MMM-NextcloudCookbook/control" controlPath: "/MMM-NextcloudCookbook/control"
} }
} }
``` ```
For a password-protected public share, add `sharePassword`:
```js
config: {
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
sharePassword: "PUBLIC_SHARE_PASSWORD"
}
```
MagicMirror sends module configuration to its browser clients. A public-share
URL is normally appropriate there because the token is already a scoped share
credential, but do not put a Nextcloud account password or app password in this
configuration.
For deployments that keep even the share token out of browser-visible
configuration, set `SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL` and, if needed,
`SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD` in the MagicMirror process
environment and omit the corresponding config values. Environment values take
precedence over module configuration and are read only by `node_helper.js`.
Open the controller at: Open the controller at:
```text ```text
@@ -31,6 +51,37 @@ The share must contain Nextcloud Cookbook recipe folders with a `recipe.json`
file in each folder. The module uses Nextcloud's token-scoped public DAV API; file in each folder. The module uses Nextcloud's token-scoped public DAV API;
protected shares authenticate as Nextcloud's `anonymous` public-share user. protected shares authenticate as Nextcloud's `anonymous` public-share user.
## Data-source decision: public WebDAV instead of the Cookbook API
Nextcloud Cookbook provides a REST API, but its external API requires
Nextcloud user credentials on every request. A read-only public-share token
cannot authenticate to that API.
This module intentionally reads the recipe files through Nextcloud's public
WebDAV interface because its job is limited to listing and displaying recipes.
That gives the mirror a narrower security boundary:
- access is scoped to the shared recipe folder;
- the share can be read-only;
- the mirror receives no general Nextcloud account credential; and
- the module cannot create, update, or delete recipes.
Cookbook stores recipes as ordinary `recipe.json` files, so WebDAV provides the
data required by this read-only display without relying on Cookbook's database
index. Index synchronization and reindexing matter when an external tool writes
recipe files; this module does not write them.
The Cookbook API would become preferable if the module later needs
Cookbook-native search, categories, keywords, imports, or recipe editing. In
that case, use a dedicated Nextcloud service user with read-only access to the
recipe folder and a dedicated app password rather than credentials for a
person's normal account.
References:
- [Cookbook API documentation](https://nextcloud.github.io/cookbook/dev/api/0.1.0/index.html)
- [Cookbook user documentation](https://nextcloud.github.io/cookbook/user/)
## Development ## Development
```bash ```bash
+32
View File
@@ -3,8 +3,14 @@ const status = document.querySelector("#status");
const list = document.querySelector("#recipes"); const list = document.querySelector("#recipes");
const search = document.querySelector("#search"); const search = document.querySelector("#search");
const exit = document.querySelector("#exit"); const exit = document.querySelector("#exit");
const portionControls = document.querySelector("#portions");
const portionCount = document.querySelector("#portion-count");
const fewerPortions = document.querySelector("#fewer-portions");
const morePortions = document.querySelector("#more-portions");
let recipes = []; let recipes = [];
let activeRecipeId = null; let activeRecipeId = null;
let activeRecipeName = null;
let portions = null;
async function request(path, options) { async function request(path, options) {
const response = await fetch(`${apiBase}${path}`, { const response = await fetch(`${apiBase}${path}`, {
@@ -39,6 +45,9 @@ function render() {
return item; return item;
})); }));
if (!matches.length) status.textContent = "No matching recipes."; if (!matches.length) status.textContent = "No matching recipes.";
portionControls.hidden = !activeRecipeId;
portionCount.textContent = portions ?? "";
fewerPortions.disabled = portions <= 1;
} }
async function activate(recipe) { async function activate(recipe) {
@@ -49,6 +58,8 @@ async function activate(recipe) {
body: JSON.stringify({ recipeId: recipe.id }) body: JSON.stringify({ recipeId: recipe.id })
}); });
activeRecipeId = state.recipeId; activeRecipeId = state.recipeId;
activeRecipeName = recipe.name;
portions = state.portions;
status.textContent = `${recipe.name} is now on the mirror.`; status.textContent = `${recipe.name} is now on the mirror.`;
render(); render();
} catch (error) { } catch (error) {
@@ -56,11 +67,30 @@ async function activate(recipe) {
} }
} }
async function adjustPortions(delta) {
try {
const state = await request("/portions", {
method: "POST",
body: JSON.stringify({ delta })
});
portions = state.portions;
status.textContent = `${activeRecipeName} is now on the mirror.`;
render();
} catch (error) {
status.textContent = error.message;
}
}
fewerPortions.addEventListener("click", () => adjustPortions(-1));
morePortions.addEventListener("click", () => adjustPortions(1));
exit.addEventListener("click", async () => { exit.addEventListener("click", async () => {
status.textContent = "Leaving recipe mode…"; status.textContent = "Leaving recipe mode…";
try { try {
await request("/exit", { method: "POST", body: "{}" }); await request("/exit", { method: "POST", body: "{}" });
activeRecipeId = null; activeRecipeId = null;
activeRecipeName = null;
portions = null;
status.textContent = "The mirror is back to its normal display."; status.textContent = "The mirror is back to its normal display.";
render(); render();
} catch (error) { } catch (error) {
@@ -73,6 +103,8 @@ search.addEventListener("input", render);
Promise.all([request("/state"), request("/recipes")]) Promise.all([request("/state"), request("/recipes")])
.then(([state, loadedRecipes]) => { .then(([state, loadedRecipes]) => {
activeRecipeId = state.recipeId; activeRecipeId = state.recipeId;
activeRecipeName = state.recipe?.name || null;
portions = state.portions;
recipes = loadedRecipes; recipes = loadedRecipes;
status.textContent = `${recipes.length} recipes available.`; status.textContent = `${recipes.length} recipes available.`;
render(); render();
+5
View File
@@ -16,6 +16,11 @@
<button id="exit" class="secondary" type="button">Exit recipe mode</button> <button id="exit" class="secondary" type="button">Exit recipe mode</button>
</header> </header>
<p id="status" role="status">Loading recipes…</p> <p id="status" role="status">Loading recipes…</p>
<div id="portions" class="portions" hidden>
<button id="fewer-portions" type="button" aria-label="One fewer portion"></button>
<span><strong id="portion-count"></strong> portions</span>
<button id="more-portions" type="button" aria-label="One more portion">+</button>
</div>
<label for="search">Find a recipe</label> <label for="search">Find a recipe</label>
<input id="search" type="search" autocomplete="off" placeholder="Search recipes"> <input id="search" type="search" autocomplete="off" placeholder="Search recipes">
<ul id="recipes" aria-live="polite"></ul> <ul id="recipes" aria-live="polite"></ul>
+22
View File
@@ -61,6 +61,28 @@ button {
#status { min-height: 1.5em; color: #665a45; } #status { min-height: 1.5em; color: #665a45; }
.portions {
display: flex;
gap: 14px;
align-items: center;
margin: 4px 0 20px;
}
.portions[hidden] { display: none; }
.portions button {
width: 44px;
height: 44px;
color: #fff;
background: #5a4b30;
font-size: 1.6rem;
line-height: 1;
}
.portions button:disabled { cursor: default; opacity: .4; }
.portions span { min-width: 92px; text-align: center; }
#recipes { display: grid; gap: 10px; padding: 0; list-style: none; } #recipes { display: grid; gap: 10px; padding: 0; list-style: none; }
.recipe { .recipe {
+62
View File
@@ -0,0 +1,62 @@
const FRACTIONS = new Map([
["¼", 1 / 4], ["½", 1 / 2], ["¾", 3 / 4], ["⅓", 1 / 3],
["⅔", 2 / 3], ["⅛", 1 / 8], ["⅜", 3 / 8], ["⅝", 5 / 8], ["⅞", 7 / 8]
]);
function parseYield(value) {
const match = String(value ?? "").match(/\d+(?:\.\d+)?/);
if (!match) return null;
const portions = Number(match[0]);
return Number.isFinite(portions) && portions > 0 ? portions : null;
}
function parseQuantity(value) {
if (FRACTIONS.has(value)) return FRACTIONS.get(value);
if (value.includes("/")) {
const [numerator, denominator] = value.split("/").map(Number);
return denominator ? numerator / denominator : null;
}
return Number(value);
}
function formatQuantity(value) {
const whole = Math.floor(value + 1e-8);
const remainder = value - whole;
const candidates = [[0, ""], [1 / 8, "⅛"], [1 / 4, "¼"], [1 / 3, "⅓"],
[3 / 8, "⅜"], [1 / 2, "½"], [5 / 8, "⅝"], [2 / 3, "⅔"],
[3 / 4, "¾"], [7 / 8, "⅞"], [1, ""]];
const [fraction, symbol] = candidates.reduce((best, candidate) => (
Math.abs(candidate[0] - remainder) < Math.abs(best[0] - remainder) ? candidate : best
));
if (Math.abs(fraction - remainder) <= 0.02) {
const adjustedWhole = fraction === 1 ? whole + 1 : whole;
return `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0";
}
return Number(value.toFixed(2)).toString();
}
function scaleIngredient(ingredient, factor) {
if (typeof ingredient !== "string" || factor === 1) return ingredient;
const match = ingredient.match(/^(\s*)(?:(\d+)\s+)?(\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅛⅜⅝⅞])(?=\s|[a-zA-Z])/);
if (!match) return ingredient;
const whole = Number(match[2] || 0);
const quantity = parseQuantity(match[3]);
if (!Number.isFinite(quantity)) return ingredient;
const replacement = `${match[1]}${formatQuantity((whole + quantity) * factor)}`;
return replacement + ingredient.slice(match[0].length);
}
function scaleRecipe(recipe, portions) {
const basePortions = parseYield(recipe?.recipeYield);
if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe;
const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, "");
return {
...recipe,
recipeYield: suffix ? `${portions} ${suffix}` : portions,
recipeIngredient: (recipe.recipeIngredient || []).map((ingredient) => (
scaleIngredient(ingredient, portions / basePortions)
))
};
}
module.exports = { parseYield, scaleIngredient, scaleRecipe };
+54 -8
View File
@@ -4,11 +4,12 @@ const express = require("express");
const NodeHelper = require("node_helper"); const NodeHelper = require("node_helper");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
const NextcloudPublicShare = require("./lib/nextcloud-public-share"); const NextcloudPublicShare = require("./lib/nextcloud-public-share");
const { parseYield, scaleRecipe } = require("./lib/recipe-scaler");
module.exports = NodeHelper.create({ module.exports = NodeHelper.create({
start() { start() {
this.config = { controlPath: "/MMM-NextcloudCookbook/control" }; this.config = { controlPath: "/MMM-NextcloudCookbook/control" };
this.state = { active: false, recipeId: null, recipe: null, error: null }; this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null };
this.recipeCache = { expires: 0, recipes: [] }; this.recipeCache = { expires: 0, recipes: [] };
this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json"); this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json");
this.configureClient(); this.configureClient();
@@ -19,6 +20,12 @@ module.exports = NodeHelper.create({
socketNotificationReceived(notification, payload) { socketNotificationReceived(notification, payload) {
if (notification !== "CONFIG") return; if (notification !== "CONFIG") return;
this.config = { ...this.config, ...payload }; this.config = { ...this.config, ...payload };
try {
this.configureClient();
} catch (error) {
this.setError(error);
return;
}
this.stateReady.then(async () => { this.stateReady.then(async () => {
this.sendSocketNotification("STATE", this.publicState()); this.sendSocketNotification("STATE", this.publicState());
await this.restoreRecipe(); await this.restoreRecipe();
@@ -26,13 +33,17 @@ module.exports = NodeHelper.create({
}, },
configureClient() { configureClient() {
const shareUrl = process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL; const shareUrl = process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL || this.config.shareUrl;
const password = process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD
|| this.config.sharePassword
|| "";
this.client = shareUrl this.client = shareUrl
? new NextcloudPublicShare({ ? new NextcloudPublicShare({
shareUrl, shareUrl,
password: process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD || "" password
}) })
: null; : null;
this.recipeCache = { expires: 0, recipes: [] };
}, },
registerRoutes() { registerRoutes() {
@@ -69,7 +80,30 @@ module.exports = NodeHelper.create({
return response.status(400).json({ error: "recipeId is required" }); return response.status(400).json({ error: "recipeId is required" });
} }
const recipe = await this.getClient().getRecipe(recipeId); const recipe = await this.getClient().getRecipe(recipeId);
this.state = { active: true, recipeId, recipe, error: null }; this.state = {
active: true,
recipeId,
recipe,
portions: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
error: null
};
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
return response.json(this.publicState());
} catch (error) {
return next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/portions", async (request, response, next) => {
try {
const delta = request.body?.delta;
if (!this.state.active || !this.state.recipe) {
return response.status(409).json({ error: "No recipe is active" });
}
if (delta !== -1 && delta !== 1) {
return response.status(400).json({ error: "delta must be -1 or 1" });
}
this.state.portions = Math.max(1, this.state.portions + delta);
await this.saveState(); await this.saveState();
this.sendSocketNotification("STATE", this.publicState()); this.sendSocketNotification("STATE", this.publicState());
return response.json(this.publicState()); return response.json(this.publicState());
@@ -79,7 +113,7 @@ module.exports = NodeHelper.create({
}); });
this.expressApp.post("/MMM-NextcloudCookbook/api/exit", async (_request, response, next) => { this.expressApp.post("/MMM-NextcloudCookbook/api/exit", async (_request, response, next) => {
try { try {
this.state = { active: false, recipeId: null, recipe: null, error: null }; this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null };
await this.saveState(); await this.saveState();
this.sendSocketNotification("STATE", this.publicState()); this.sendSocketNotification("STATE", this.publicState());
response.json(this.publicState()); response.json(this.publicState());
@@ -109,7 +143,13 @@ module.exports = NodeHelper.create({
try { try {
const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8")); const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8"));
if (!persisted.active || !persisted.recipeId) return; if (!persisted.active || !persisted.recipeId) return;
this.state = { active: true, recipeId: persisted.recipeId, recipe: null, error: null }; this.state = {
active: true,
recipeId: persisted.recipeId,
recipe: null,
portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
error: null
};
} catch (error) { } catch (error) {
if (error.code !== "ENOENT") throw error; if (error.code !== "ENOENT") throw error;
} }
@@ -119,6 +159,7 @@ module.exports = NodeHelper.create({
if (!this.state.active || !this.state.recipeId) return; if (!this.state.active || !this.state.recipeId) return;
try { try {
this.state.recipe = await this.getClient().getRecipe(this.state.recipeId); this.state.recipe = await this.getClient().getRecipe(this.state.recipeId);
this.state.portions ||= Math.max(1, Math.round(parseYield(this.state.recipe.recipeYield) || 1));
this.state.error = null; this.state.error = null;
} catch (error) { } catch (error) {
this.setError(error); this.setError(error);
@@ -128,7 +169,11 @@ module.exports = NodeHelper.create({
async saveState() { async saveState() {
const temporary = `${this.stateFile}.tmp`; const temporary = `${this.stateFile}.tmp`;
await fs.writeFile(temporary, `${JSON.stringify({ active: this.state.active, recipeId: this.state.recipeId })}\n`, { await fs.writeFile(temporary, `${JSON.stringify({
active: this.state.active,
recipeId: this.state.recipeId,
portions: this.state.portions
})}\n`, {
mode: 0o600 mode: 0o600
}); });
await fs.rename(temporary, this.stateFile); await fs.rename(temporary, this.stateFile);
@@ -143,7 +188,8 @@ module.exports = NodeHelper.create({
return { return {
active: this.state.active, active: this.state.active,
recipeId: this.state.recipeId, recipeId: this.state.recipeId,
recipe: this.state.recipe, recipe: this.state.recipe ? scaleRecipe(this.state.recipe, this.state.portions) : null,
portions: this.state.portions,
error: this.state.error error: this.state.error
}; };
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "Display recipes from a Nextcloud Cookbook share on MagicMirror", "description": "Display recipes from a Nextcloud Cookbook share on MagicMirror",
"main": "node_helper.js", "main": "node_helper.js",
"scripts": { "scripts": {
"check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/nextcloud-public-share.js && node --check control/app.js", "check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/nextcloud-public-share.js && node --check lib/recipe-scaler.js && node --check control/app.js",
"test": "npm run check && node --test" "test": "npm run check && node --test"
}, },
"engines": { "engines": {
+28
View File
@@ -0,0 +1,28 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseYield, scaleIngredient, scaleRecipe } = require("../lib/recipe-scaler");
test("extracts the number of portions from a recipe yield", () => {
assert.equal(parseYield("6 servings"), 6);
assert.equal(parseYield(4), 4);
assert.equal(parseYield("as needed"), null);
});
test("scales leading whole, fractional, and mixed quantities", () => {
assert.equal(scaleIngredient("2 cups oats", 2), "4 cups oats");
assert.equal(scaleIngredient("½ cup milk", 2), "1 cup milk");
assert.equal(scaleIngredient("1 1/2 tsp salt", 2), "3 tsp salt");
assert.equal(scaleIngredient("Salt to taste", 2), "Salt to taste");
});
test("returns a scaled copy of a recipe", () => {
const original = {
name: "Oatmeal Cups",
recipeYield: "6 servings",
recipeIngredient: ["2 cups oats", "Salt to taste"]
};
const scaled = scaleRecipe(original, 9);
assert.equal(scaled.recipeYield, "9 servings");
assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]);
assert.equal(original.recipeYield, "6 servings");
});