diff --git a/MMM-NextcloudCookbook.css b/MMM-NextcloudCookbook.css index 1753f55..0c86d3c 100644 --- a/MMM-NextcloudCookbook.css +++ b/MMM-NextcloudCookbook.css @@ -15,7 +15,7 @@ inset: 0; z-index: 20; overflow: hidden; - padding: 42px 52px; + padding: 50px 58px; background: #11110f; } @@ -40,18 +40,18 @@ } .MMM-NextcloudCookbook .ncc-recipe h1 { - margin: 0 100px 18px 0; + margin: 0 100px 24px 0; color: #fff6d8; - font-size: 52px; + font-size: 68px; line-height: 1.08; } .MMM-NextcloudCookbook .ncc-meta { display: flex; - gap: 8px 18px; + gap: 10px 22px; align-items: baseline; - margin: 0 0 24px; - font-size: 21px; + margin: 0 0 32px; + font-size: 28px; } .MMM-NextcloudCookbook .ncc-meta dt { @@ -66,25 +66,25 @@ .MMM-NextcloudCookbook .ncc-columns { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(430px, 1.4fr); - gap: 46px; + gap: 52px; } .MMM-NextcloudCookbook .ncc-columns h2 { - margin: 0 0 12px; + margin: 0 0 18px; color: #d6b96b; - font-size: 30px; + font-size: 42px; } .MMM-NextcloudCookbook .ncc-columns ul, .MMM-NextcloudCookbook .ncc-columns ol { margin: 0; padding-left: 1.25em; - font-size: 23px; - line-height: 1.3; + font-size: 34px; + line-height: 1.34; } .MMM-NextcloudCookbook .ncc-columns li { - margin-bottom: 9px; + margin-bottom: 14px; } .MMM-NextcloudCookbook .ncc-error { diff --git a/MMM-NextcloudCookbook.js b/MMM-NextcloudCookbook.js index 0a4a837..01476d9 100644 --- a/MMM-NextcloudCookbook.js +++ b/MMM-NextcloudCookbook.js @@ -3,7 +3,9 @@ Module.register("MMM-NextcloudCookbook", { defaults: { controlPath: "/MMM-NextcloudCookbook/control", - animationSpeed: 400 + animationSpeed: 400, + shareUrl: "", + sharePassword: "" }, start() { diff --git a/README.md b/README.md index bef1c81..1a1cba4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # MMM-NextcloudCookbook 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 -entering or leaving recipe mode. +share. It includes a phone-friendly control page for choosing a recipe, +adjusting its number of portions, and entering or leaving recipe mode. -The Nextcloud share URL is read only by `node_helper.js` from -`SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL`. An optional protected-share password can -be supplied as `SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD`. Neither value is sent -to the MagicMirror browser or control page. +Create a public share for the folder configured as your Nextcloud Cookbook +recipe directory. Read-only permission is sufficient. Copy the public share +link; it should look like `https://cloud.example.test/s/SHARE_TOKEN`. ## MagicMirror configuration @@ -16,11 +15,32 @@ to the MagicMirror browser or control page. module: "MMM-NextcloudCookbook", position: "fullscreen_above", config: { + shareUrl: "https://cloud.example.test/s/SHARE_TOKEN", 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: ```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; 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 ```bash diff --git a/control/app.js b/control/app.js index b5368a3..3c3b00c 100644 --- a/control/app.js +++ b/control/app.js @@ -3,8 +3,14 @@ const status = document.querySelector("#status"); const list = document.querySelector("#recipes"); const search = document.querySelector("#search"); 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 activeRecipeId = null; +let activeRecipeName = null; +let portions = null; async function request(path, options) { const response = await fetch(`${apiBase}${path}`, { @@ -39,6 +45,9 @@ function render() { return item; })); if (!matches.length) status.textContent = "No matching recipes."; + portionControls.hidden = !activeRecipeId; + portionCount.textContent = portions ?? ""; + fewerPortions.disabled = portions <= 1; } async function activate(recipe) { @@ -49,6 +58,8 @@ async function activate(recipe) { body: JSON.stringify({ recipeId: recipe.id }) }); activeRecipeId = state.recipeId; + activeRecipeName = recipe.name; + portions = state.portions; status.textContent = `${recipe.name} is now on the mirror.`; render(); } 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 () => { status.textContent = "Leaving recipe mode…"; try { await request("/exit", { method: "POST", body: "{}" }); activeRecipeId = null; + activeRecipeName = null; + portions = null; status.textContent = "The mirror is back to its normal display."; render(); } catch (error) { @@ -73,6 +103,8 @@ search.addEventListener("input", render); Promise.all([request("/state"), request("/recipes")]) .then(([state, loadedRecipes]) => { activeRecipeId = state.recipeId; + activeRecipeName = state.recipe?.name || null; + portions = state.portions; recipes = loadedRecipes; status.textContent = `${recipes.length} recipes available.`; render(); diff --git a/control/index.html b/control/index.html index 12b7159..86559e6 100644 --- a/control/index.html +++ b/control/index.html @@ -16,6 +16,11 @@

Loading recipes…

+ diff --git a/control/styles.css b/control/styles.css index 973f978..aebd31f 100644 --- a/control/styles.css +++ b/control/styles.css @@ -61,6 +61,28 @@ button { #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; } .recipe { diff --git a/lib/recipe-scaler.js b/lib/recipe-scaler.js new file mode 100644 index 0000000..df59b24 --- /dev/null +++ b/lib/recipe-scaler.js @@ -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 }; diff --git a/node_helper.js b/node_helper.js index 9c9337d..2573482 100644 --- a/node_helper.js +++ b/node_helper.js @@ -4,11 +4,12 @@ const express = require("express"); const NodeHelper = require("node_helper"); const QRCode = require("qrcode"); const NextcloudPublicShare = require("./lib/nextcloud-public-share"); +const { parseYield, scaleRecipe } = require("./lib/recipe-scaler"); module.exports = NodeHelper.create({ start() { 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.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json"); this.configureClient(); @@ -19,6 +20,12 @@ module.exports = NodeHelper.create({ socketNotificationReceived(notification, payload) { if (notification !== "CONFIG") return; this.config = { ...this.config, ...payload }; + try { + this.configureClient(); + } catch (error) { + this.setError(error); + return; + } this.stateReady.then(async () => { this.sendSocketNotification("STATE", this.publicState()); await this.restoreRecipe(); @@ -26,13 +33,17 @@ module.exports = NodeHelper.create({ }, 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 ? new NextcloudPublicShare({ shareUrl, - password: process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD || "" + password }) : null; + this.recipeCache = { expires: 0, recipes: [] }; }, registerRoutes() { @@ -69,7 +80,30 @@ module.exports = NodeHelper.create({ return response.status(400).json({ error: "recipeId is required" }); } 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(); this.sendSocketNotification("STATE", 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) => { 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(); this.sendSocketNotification("STATE", this.publicState()); response.json(this.publicState()); @@ -109,7 +143,13 @@ module.exports = NodeHelper.create({ try { const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8")); 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) { if (error.code !== "ENOENT") throw error; } @@ -119,6 +159,7 @@ module.exports = NodeHelper.create({ if (!this.state.active || !this.state.recipeId) return; try { 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; } catch (error) { this.setError(error); @@ -128,7 +169,11 @@ module.exports = NodeHelper.create({ async saveState() { 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 }); await fs.rename(temporary, this.stateFile); @@ -143,7 +188,8 @@ module.exports = NodeHelper.create({ return { active: this.state.active, 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 }; } diff --git a/package.json b/package.json index 7b80993..3dc1159 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Display recipes from a Nextcloud Cookbook share on MagicMirror", "main": "node_helper.js", "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" }, "engines": { diff --git a/test/recipe-scaler.test.js b/test/recipe-scaler.test.js new file mode 100644 index 0000000..e2d250e --- /dev/null +++ b/test/recipe-scaler.test.js @@ -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"); +});