Add portion controls and improve recipe display
This commit is contained in:
+12
-12
@@ -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,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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user