6 Commits
Author SHA1 Message Date
aaron.axvig a8fbcf4104 fix: preserve recipe progress view state
Refs #1, #5
2026-08-14 21:02:21 -05:00
aaron.axvig 22daf703bb feat: add recipe theme and resume controls
Fixes #4, #7
2026-08-14 13:51:00 -05:00
aaron.axvig 803d722836 chore: ignore local environment files 2026-08-14 13:29:05 -05:00
aaron.axvig 6f28f83f4a feat: synchronize recipe completion controls
Fixes #5\nRefs #1, #2
2026-08-14 13:28:57 -05:00
aaron.axvig b61ca70bed feat: annotate scaled ingredient quantities
Refs #1, #2
2026-08-14 13:28:39 -05:00
aaron.axvig de8eab9022 Keep recipe metadata pairs together 2026-08-03 22:27:44 -05:00
14 changed files with 611 additions and 57 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
coverage/
*.log
.env
+77 -1
View File
@@ -15,6 +15,15 @@
text-align: left;
}
.MMM-NextcloudCookbook .ncc-active.ncc-theme-light {
color: #27231b;
background: #f8f6ef;
}
.MMM-NextcloudCookbook .ncc-theme-light .ncc-recipe h1 { color: #302817; }
.MMM-NextcloudCookbook .ncc-theme-light .ncc-meta dt,
.MMM-NextcloudCookbook .ncc-theme-light .ncc-columns h2 { color: #765900; }
.MMM-NextcloudCookbook .ncc-qr {
display: flex;
flex-direction: column;
@@ -37,6 +46,28 @@
top: 18px;
}
.MMM-NextcloudCookbook .ncc-theme-toggle {
padding: 10px 14px;
border: 0;
border-radius: 6px;
color: #27231b;
background: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.MMM-NextcloudCookbook .ncc-active .ncc-theme-toggle {
position: absolute;
top: 190px;
right: 18px;
}
.MMM-NextcloudCookbook .ncc-theme-light .ncc-theme-toggle {
color: #f8f6ef;
background: #302817;
}
.MMM-NextcloudCookbook .ncc-qr img {
width: 164px;
height: 164px;
@@ -63,12 +94,19 @@
.MMM-NextcloudCookbook .ncc-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 22px;
gap: 10px 24px;
align-items: baseline;
margin: 0 0 36px;
font-size: 36px;
}
.MMM-NextcloudCookbook .ncc-meta-item {
display: inline-flex;
gap: 8px;
align-items: baseline;
white-space: nowrap;
}
.MMM-NextcloudCookbook .ncc-meta dt {
color: #d6b96b;
font-weight: 700;
@@ -110,6 +148,44 @@
margin-bottom: calc(18px * var(--ncc-content-scale));
}
.MMM-NextcloudCookbook .ncc-progress-control {
display: flex;
gap: calc(16px * var(--ncc-content-scale));
align-items: flex-start;
cursor: pointer;
}
.MMM-NextcloudCookbook .ncc-progress-control input {
width: calc(40px * var(--ncc-content-scale));
height: calc(40px * var(--ncc-content-scale));
min-width: 28px;
min-height: 28px;
margin: calc(10px * var(--ncc-content-scale)) 0 0;
accent-color: #8fda9a;
cursor: pointer;
}
.MMM-NextcloudCookbook .ncc-quantity-warning {
color: #ffd66b;
}
.MMM-NextcloudCookbook .ncc-quantity-warning::marker {
content: "⚠ ";
}
.MMM-NextcloudCookbook .ncc-quantity-error {
color: #ff9d91;
}
.MMM-NextcloudCookbook .ncc-quantity-error::marker {
content: "! ";
}
.MMM-NextcloudCookbook .ncc-completed {
color: #a8a79f;
text-decoration: line-through;
}
.MMM-NextcloudCookbook .ncc-error {
color: #ff9d91;
font-size: 30px;
+97 -12
View File
@@ -6,6 +6,7 @@ Module.register("MMM-NextcloudCookbook", {
controlUrl: "",
animationSpeed: 400,
layout: "stacked",
theme: "dark",
shareUrl: "",
sharePassword: ""
},
@@ -27,9 +28,17 @@ Module.register("MMM-NextcloudCookbook", {
socketNotificationReceived(notification, payload) {
if (notification !== "STATE") return;
const previous = this.state;
this.state = payload;
this.setRecipeMode(Boolean(payload.active));
this.updateDom(this.config.animationSpeed);
const requiresDomUpdate = previous.active !== payload.active
|| previous.recipeId !== payload.recipeId
|| previous.portions !== payload.portions
|| previous.theme !== payload.theme
|| previous.error !== payload.error
|| previous.recipe?.recipeYield !== payload.recipe?.recipeYield;
if (requiresDomUpdate) this.updateDom(this.config.animationSpeed);
else this.updateProgressDom();
},
notificationReceived(notification) {
@@ -85,11 +94,27 @@ Module.register("MMM-NextcloudCookbook", {
}
},
updateProgressDom() {
const root = document.getElementById(this.identifier)?.querySelector(".ncc-active");
if (!root) return;
root.querySelectorAll("[data-ncc-progress-kind]").forEach((item) => {
const kind = item.dataset.nccProgressKind;
const index = Number(item.dataset.nccProgressIndex);
const completed = kind === "ingredient"
? this.state.progress?.completedIngredients?.includes(index)
: this.state.progress?.completedSteps?.includes(index);
item.classList.toggle("ncc-completed", completed);
const checkbox = item.querySelector("input[type=checkbox]");
if (checkbox) {
checkbox.checked = completed;
checkbox.disabled = false;
}
});
},
getDom() {
const root = document.createElement("section");
root.className = this.state.active
? "ncc-root ncc-active"
: "ncc-root ncc-idle";
root.className = `ncc-root ${this.state.active ? "ncc-active" : "ncc-idle"} ncc-theme-${this.state.theme || this.config.theme}`;
const qr = document.createElement("a");
qr.className = "ncc-qr";
@@ -105,6 +130,14 @@ Module.register("MMM-NextcloudCookbook", {
qr.append(qrLabel, qrImage);
root.appendChild(qr);
const themeToggle = document.createElement("button");
themeToggle.type = "button";
themeToggle.className = "ncc-theme-toggle";
const nextTheme = this.state.theme === "light" ? "dark" : "light";
themeToggle.textContent = `Use ${nextTheme} mode`;
themeToggle.addEventListener("click", () => this.sendSocketNotification("SET_THEME", nextTheme));
root.appendChild(themeToggle);
if (!this.state.active) return root;
if (this.state.error) {
@@ -123,11 +156,11 @@ Module.register("MMM-NextcloudCookbook", {
return root;
}
root.appendChild(this.renderRecipe(this.state.recipe));
root.appendChild(this.renderRecipe(this.state.recipe, this.state.progress));
return root;
},
renderRecipe(recipe) {
renderRecipe(recipe, progress = {}) {
const article = document.createElement("article");
article.className = "ncc-recipe";
@@ -136,8 +169,11 @@ Module.register("MMM-NextcloudCookbook", {
title.textContent = recipe.name || "Untitled recipe";
header.appendChild(title);
const yieldValue = recipe.originalRecipeYield && recipe.originalRecipeYield !== recipe.recipeYield
? `${recipe.recipeYield} (originally ${recipe.originalRecipeYield})`
: recipe.recipeYield;
const metadata = [
["Yield", recipe.recipeYield],
["Yield", yieldValue],
["Prep", NCCFormatDuration(recipe.prepTime)],
["Cook", NCCFormatDuration(recipe.cookTime)],
["Total", NCCFormatDuration(recipe.totalTime)]
@@ -146,11 +182,14 @@ Module.register("MMM-NextcloudCookbook", {
const list = document.createElement("dl");
list.className = "ncc-meta";
metadata.forEach(([label, value]) => {
const pair = document.createElement("div");
pair.className = "ncc-meta-item";
const term = document.createElement("dt");
term.textContent = label;
const detail = document.createElement("dd");
detail.textContent = value;
list.append(term, detail);
pair.append(term, detail);
list.appendChild(pair);
});
header.appendChild(list);
}
@@ -165,9 +204,36 @@ Module.register("MMM-NextcloudCookbook", {
ingredientsTitle.textContent = "Ingredients";
ingredients.appendChild(ingredientsTitle);
const ingredientList = document.createElement("ul");
(recipe.recipeIngredient || []).forEach((ingredient) => {
(recipe.recipeIngredient || []).forEach((ingredient, index) => {
const item = document.createElement("li");
item.textContent = ingredient;
item.dataset.nccProgressKind = "ingredient";
item.dataset.nccProgressIndex = index;
const status = recipe.recipeIngredientStatus?.[index];
if (status === "unquantified") {
item.classList.add("ncc-quantity-warning");
item.setAttribute("aria-label", `Verify quantity: ${ingredient}`);
} else if (status === "unscalable" || status === "approximate") {
item.classList.add("ncc-quantity-error");
item.setAttribute("aria-label", `Scaling warning: ${ingredient}`);
}
const completed = progress.completedIngredients?.includes(index);
if (completed) item.classList.add("ncc-completed");
const label = document.createElement("label");
label.className = "ncc-progress-control";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = completed;
checkbox.setAttribute("aria-label", `Mark ingredient complete: ${ingredient}`);
checkbox.addEventListener("change", () => {
checkbox.disabled = true;
this.sendSocketNotification("UPDATE_PROGRESS", {
kind: "ingredient", index, completed: checkbox.checked
});
});
const text = document.createElement("span");
text.textContent = ingredient;
label.append(checkbox, text);
item.appendChild(label);
ingredientList.appendChild(item);
});
ingredients.appendChild(ingredientList);
@@ -177,9 +243,28 @@ Module.register("MMM-NextcloudCookbook", {
instructionsTitle.textContent = "Instructions";
instructions.appendChild(instructionsTitle);
const instructionList = document.createElement("ol");
this.flattenInstructions(recipe.recipeInstructions || []).forEach((step) => {
this.flattenInstructions(recipe.recipeInstructions || []).forEach((step, index) => {
const item = document.createElement("li");
item.textContent = step;
item.dataset.nccProgressKind = "step";
item.dataset.nccProgressIndex = index;
const completed = progress.completedSteps?.includes(index);
if (completed) item.classList.add("ncc-completed");
const label = document.createElement("label");
label.className = "ncc-progress-control";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = completed;
checkbox.setAttribute("aria-label", `Mark step complete: ${step}`);
checkbox.addEventListener("change", () => {
checkbox.disabled = true;
this.sendSocketNotification("UPDATE_PROGRESS", {
kind: "step", index, completed: checkbox.checked
});
});
const text = document.createElement("span");
text.textContent = step;
label.append(checkbox, text);
item.appendChild(label);
instructionList.appendChild(item);
});
instructions.appendChild(instructionList);
+19 -2
View File
@@ -2,7 +2,10 @@
A MagicMirror module that displays recipes from a read-only Nextcloud public
share. It includes a phone-friendly control page for choosing a recipe,
adjusting its number of portions, and entering or leaving recipe mode.
adjusting its number of portions, marking ingredients and steps complete, and
entering or leaving recipe mode. Completion marks are shared with the mirror
and retained until a different recipe is selected, so a recipe can be resumed
after exiting recipe mode.
Create a public share for the folder configured as your Nextcloud Cookbook
recipe directory. Read-only permission is sufficient. Copy the public share
@@ -17,7 +20,8 @@ link; it should look like `https://cloud.example.test/s/SHARE_TOKEN`.
config: {
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
controlUrl: "http://mirror.example.test:8080/MMM-NextcloudCookbook/control",
layout: "stacked"
layout: "stacked",
theme: "light"
}
}
```
@@ -30,6 +34,19 @@ the best use of the available screen while keeping all recipe content visible.
The recipe title, timing metadata, yield, and QR controller remain at fixed
sizes.
`theme` defaults to `"dark"`. Set it to `"light"` for a light starting theme;
the controller and recipe overlay also offer a toggle, and the chosen theme is
saved locally. After exiting recipe mode, use **Resume last recipe** in the
controller to restore its portions and completion marks.
Ingredients and instructions can be marked complete from either the phone
controller or a touchscreen on the mirror; both views remain synchronized.
When portions change, the display shows both the selected yield and the
recipe's original yield. Ingredients without a leading quantity are marked in
yellow because they cannot be scaled; unsupported or imprecise numeric scaling
is marked in red.
The configured `position` controls where the QR controller appears during the
normal MagicMirror layout. When a recipe is activated, the module displays a
fixed full-screen overlay regardless of that region. Exiting recipe mode
+118 -14
View File
@@ -3,14 +3,27 @@ const status = document.querySelector("#status");
const list = document.querySelector("#recipes");
const search = document.querySelector("#search");
const exit = document.querySelector("#exit");
const resume = document.querySelector("#resume");
const themeToggle = document.querySelector("#theme-toggle");
const portionControls = document.querySelector("#portions");
const portionCount = document.querySelector("#portion-count");
const fewerPortions = document.querySelector("#fewer-portions");
const morePortions = document.querySelector("#more-portions");
const activeRecipe = document.querySelector("#active-recipe");
const activeRecipeName = document.querySelector("#active-recipe-name");
const activeRecipeYield = document.querySelector("#active-recipe-yield");
const activeIngredients = document.querySelector("#active-ingredients");
const activeInstructions = document.querySelector("#active-instructions");
let recipes = [];
let activeRecipeId = null;
let activeRecipeName = null;
let activeRecipeLabel = null;
let displayedRecipe = null;
let portions = null;
let progress = { completedIngredients: [], completedSteps: [] };
let recipeModeActive = false;
let canResume = false;
let lastRecipeName = null;
let theme = "dark";
async function request(path, options) {
const response = await fetch(`${apiBase}${path}`, {
@@ -45,9 +58,71 @@ function render() {
return item;
}));
if (!matches.length) status.textContent = "No matching recipes.";
portionControls.hidden = !activeRecipeId;
portionControls.hidden = !recipeModeActive;
portionCount.textContent = portions ?? "";
fewerPortions.disabled = portions <= 1;
resume.hidden = !canResume;
resume.textContent = lastRecipeName ? `Resume ${lastRecipeName}` : "Resume last recipe";
themeToggle.textContent = theme === "light" ? "Use dark mode" : "Use light mode";
renderActiveRecipe();
}
function flattenInstructions(instructions) {
return instructions.flatMap((instruction) => {
if (typeof instruction === "string") return [instruction];
if (Array.isArray(instruction?.itemListElement)) return flattenInstructions(instruction.itemListElement);
return instruction?.text ? [instruction.text] : [];
});
}
function renderChecklist(container, items, completed, kind, statuses = []) {
container.replaceChildren(...items.map((item, index) => {
const row = document.createElement("li");
const label = document.createElement("label");
const check = document.createElement("input");
check.type = "checkbox";
check.checked = completed.includes(index);
check.addEventListener("change", () => updateProgress(kind, index, check.checked));
const text = document.createElement("span");
text.textContent = item;
if (check.checked) row.classList.add("completed");
if (statuses[index] === "unquantified") {
row.classList.add("quantity-warning");
text.prepend("⚠ Verify quantity: ");
} else if (["unscalable", "approximate"].includes(statuses[index])) {
row.classList.add("quantity-error");
text.prepend("! Scaling warning: ");
}
label.append(check, text);
row.appendChild(label);
return row;
}));
}
function renderActiveRecipe() {
const recipe = displayedRecipe;
activeRecipe.hidden = !recipe;
if (!recipe) return;
activeRecipeName.textContent = recipe.name || "Untitled recipe";
const original = recipe.originalRecipeYield;
activeRecipeYield.textContent = original && original !== recipe.recipeYield
? `Yield: ${recipe.recipeYield} (originally ${original})`
: `Yield: ${recipe.recipeYield || "Not specified"}`;
renderChecklist(activeIngredients, recipe.recipeIngredient || [], progress.completedIngredients, "ingredient",
recipe.recipeIngredientStatus || []);
renderChecklist(activeInstructions, flattenInstructions(recipe.recipeInstructions || []), progress.completedSteps, "step");
}
function applyState(state) {
recipeModeActive = Boolean(state.active);
activeRecipeId = recipeModeActive ? state.recipeId : null;
activeRecipeLabel = state.recipe?.name || activeRecipeLabel;
displayedRecipe = recipeModeActive ? state.recipe : null;
portions = state.portions;
progress = state.progress || { completedIngredients: [], completedSteps: [] };
canResume = Boolean(state.canResume);
lastRecipeName = state.lastRecipeName || null;
theme = state.theme === "light" ? "light" : "dark";
}
async function activate(recipe) {
@@ -57,9 +132,8 @@ async function activate(recipe) {
method: "POST",
body: JSON.stringify({ recipeId: recipe.id })
});
activeRecipeId = state.recipeId;
activeRecipeName = recipe.name;
portions = state.portions;
applyState(state);
activeRecipeLabel = recipe.name;
status.textContent = `${recipe.name} is now on the mirror.`;
render();
} catch (error) {
@@ -73,24 +147,56 @@ async function adjustPortions(delta) {
method: "POST",
body: JSON.stringify({ delta })
});
portions = state.portions;
status.textContent = `${activeRecipeName} is now on the mirror.`;
applyState(state);
status.textContent = `${activeRecipeLabel} is now on the mirror.`;
render();
} catch (error) {
status.textContent = error.message;
}
}
async function updateProgress(kind, index, completed) {
try {
const state = await request("/progress", {
method: "POST",
body: JSON.stringify({ kind, index, completed })
});
applyState(state);
} catch (error) {
status.textContent = error.message;
render();
}
}
fewerPortions.addEventListener("click", () => adjustPortions(-1));
morePortions.addEventListener("click", () => adjustPortions(1));
resume.addEventListener("click", async () => {
try {
applyState(await request("/resume", { method: "POST", body: "{}" }));
status.textContent = `${activeRecipeLabel} is back on the mirror.`;
render();
} catch (error) {
status.textContent = error.message;
}
});
themeToggle.addEventListener("click", async () => {
try {
applyState(await request("/theme", {
method: "POST", body: JSON.stringify({ theme: theme === "light" ? "dark" : "light" })
}));
render();
} catch (error) {
status.textContent = error.message;
}
});
exit.addEventListener("click", async () => {
status.textContent = "Leaving recipe mode…";
try {
await request("/exit", { method: "POST", body: "{}" });
activeRecipeId = null;
activeRecipeName = null;
portions = null;
const state = await request("/exit", { method: "POST", body: "{}" });
applyState(state);
status.textContent = "The mirror is back to its normal display.";
render();
} catch (error) {
@@ -102,9 +208,7 @@ search.addEventListener("input", render);
Promise.all([request("/state"), request("/recipes")])
.then(([state, loadedRecipes]) => {
activeRecipeId = state.recipeId;
activeRecipeName = state.recipe?.name || null;
portions = state.portions;
applyState(state);
recipes = loadedRecipes;
status.textContent = `${recipes.length} recipes available.`;
render();
+17 -1
View File
@@ -13,7 +13,11 @@
<p class="eyebrow">Kitchen Mirror</p>
<h1>Recipes</h1>
</div>
<button id="exit" class="secondary" type="button">Exit recipe mode</button>
<div class="header-actions">
<button id="theme-toggle" class="secondary" type="button"></button>
<button id="resume" class="secondary" type="button" hidden>Resume last recipe</button>
<button id="exit" class="secondary" type="button">Exit recipe mode</button>
</div>
</header>
<p id="status" role="status">Loading recipes…</p>
<div id="portions" class="portions" hidden>
@@ -21,6 +25,18 @@
<span><strong id="portion-count"></strong> portions</span>
<button id="more-portions" type="button" aria-label="One more portion">+</button>
</div>
<article id="active-recipe" class="active-recipe" hidden>
<h2 id="active-recipe-name"></h2>
<p id="active-recipe-yield"></p>
<section>
<h3>Ingredients</h3>
<ul id="active-ingredients" class="checklist"></ul>
</section>
<section>
<h3>Instructions</h3>
<ol id="active-instructions" class="checklist"></ol>
</section>
</article>
<label for="search">Find a recipe</label>
<input id="search" type="search" autocomplete="off" placeholder="Search recipes">
<ul id="recipes" aria-live="polite"></ul>
+42
View File
@@ -23,6 +23,8 @@ header {
justify-content: space-between;
}
.header-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
h1 { margin: 0; font-size: 2.3rem; }
.eyebrow {
@@ -102,6 +104,37 @@ button {
.recipe strong { font-size: 1.12rem; }
.active-recipe {
margin: 1.5rem 0;
padding: 1rem;
border: 1px solid #d8c9ad;
border-radius: 8px;
background: #fffdf8;
}
.active-recipe h2,
.active-recipe h3 { margin-top: 0; }
.checklist {
display: grid;
gap: .7rem;
padding-left: 1.4rem;
}
.checklist li { padding-left: .2rem; }
.checklist label { display: flex; gap: .6rem; align-items: flex-start; margin: 0; font-weight: normal; }
.checklist input {
width: 1.2rem;
min-width: 1.2rem;
height: 1.2rem;
min-height: 1.2rem;
margin: .18rem 0 0;
padding: 0;
}
.checklist .completed { color: #777; text-decoration: line-through; }
.checklist .quantity-warning { color: #876500; }
.checklist .quantity-error { color: #a2342b; }
.recipe span {
display: -webkit-box;
overflow: hidden;
@@ -109,3 +142,12 @@ button {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (max-width: 560px) {
main { padding: 18px 14px 44px; }
header { align-items: flex-start; flex-direction: column; }
.header-actions { width: 100%; justify-content: stretch; }
.header-actions button { flex: 1 1 auto; }
.active-recipe { margin: 1rem 0; padding: .85rem; }
.checklist { gap: .55rem; padding-left: 1.2rem; }
}
+35
View File
@@ -0,0 +1,35 @@
function flattenInstructions(instructions) {
return instructions.flatMap((instruction) => {
if (typeof instruction === "string") return [instruction];
if (Array.isArray(instruction?.itemListElement)) return flattenInstructions(instruction.itemListElement);
return instruction?.text ? [instruction.text] : [];
});
}
function normalizeProgress(progress = {}) {
return {
completedIngredients: [...new Set((progress.completedIngredients || []).filter(Number.isInteger))],
completedSteps: [...new Set((progress.completedSteps || []).filter(Number.isInteger))]
};
}
function updateProgress(recipe, progress, { kind, index, completed }) {
if (!recipe || !["ingredient", "step"].includes(kind)
|| !Number.isInteger(index) || typeof completed !== "boolean") {
throw new Error("Invalid progress update");
}
const limit = kind === "ingredient"
? (recipe.recipeIngredient || []).length
: flattenInstructions(recipe.recipeInstructions || []).length;
if (index < 0 || index >= limit) throw new Error("Progress item does not exist");
const key = kind === "ingredient" ? "completedIngredients" : "completedSteps";
const next = normalizeProgress(progress);
const items = new Set(next[key]);
if (completed) items.add(index);
else items.delete(index);
next[key] = [...items].sort((left, right) => left - right);
return next;
}
module.exports = { flattenInstructions, normalizeProgress, updateProgress };
+34 -14
View File
@@ -30,33 +30,53 @@ function formatQuantity(value) {
));
if (Math.abs(fraction - remainder) <= 0.02) {
const adjustedWhole = fraction === 1 ? whole + 1 : whole;
return `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0";
return {
value: `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0",
approximate: false
};
}
return Number(value.toFixed(2)).toString();
return { value: Number(value.toFixed(2)).toString(), approximate: true };
}
function scaleIngredientWithStatus(ingredient, factor) {
if (typeof ingredient !== "string" || factor === 1) {
return { value: ingredient, status: "unchanged" };
}
const match = ingredient.match(/^(\s*)(?:(\d+)\s+)?(\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅛⅜⅝⅞])(?=\s|[a-zA-Z])/);
if (!match) {
const hasLeadingQuantity = /^\s*(?:\d|[¼½¾⅓⅔⅛⅜⅝⅞])/.test(ingredient);
return { value: ingredient, status: hasLeadingQuantity ? "unscalable" : "unquantified" };
}
const whole = Number(match[2] || 0);
const quantity = parseQuantity(match[3]);
if (!Number.isFinite(quantity)) return { value: ingredient, status: "unscalable" };
const formatted = formatQuantity((whole + quantity) * factor);
const replacement = `${match[1]}${formatted.value}`;
return {
value: replacement + ingredient.slice(match[0].length),
status: formatted.approximate ? "approximate" : "scaled"
};
}
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);
return scaleIngredientWithStatus(ingredient, factor).value;
}
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*/, "");
const factor = portions / basePortions;
const ingredients = (recipe.recipeIngredient || []).map((ingredient) => (
scaleIngredientWithStatus(ingredient, factor)
));
return {
...recipe,
recipeYield: suffix ? `${portions} ${suffix}` : portions,
recipeIngredient: (recipe.recipeIngredient || []).map((ingredient) => (
scaleIngredient(ingredient, portions / basePortions)
))
originalRecipeYield: recipe.recipeYield,
recipeIngredient: ingredients.map(({ value }) => value),
recipeIngredientStatus: ingredients.map(({ status }) => status)
};
}
module.exports = { parseYield, scaleIngredient, scaleRecipe };
module.exports = { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe };
+124 -8
View File
@@ -5,11 +5,20 @@ const NodeHelper = require("node_helper");
const QRCode = require("qrcode");
const NextcloudPublicShare = require("./lib/nextcloud-public-share");
const { parseYield, scaleRecipe } = require("./lib/recipe-scaler");
const { normalizeProgress, updateProgress } = require("./lib/recipe-progress");
function normalizeTheme(theme) {
return theme === "light" ? "light" : "dark";
}
module.exports = NodeHelper.create({
start() {
this.config = { controlPath: "/MMM-NextcloudCookbook/control" };
this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null };
this.config = { controlPath: "/MMM-NextcloudCookbook/control", theme: "dark" };
this.state = {
active: false, recipeId: null, recipe: null, portions: null,
recipeName: null, progress: normalizeProgress(), theme: "dark", error: null
};
this.hasPersistedTheme = false;
this.recipeCache = { expires: 0, recipes: [] };
this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json");
this.configureClient();
@@ -18,6 +27,14 @@ module.exports = NodeHelper.create({
},
socketNotificationReceived(notification, payload) {
if (notification === "UPDATE_PROGRESS") {
this.updateProgressFromMirror(payload);
return;
}
if (notification === "SET_THEME") {
this.updateTheme(payload);
return;
}
if (notification !== "CONFIG") return;
this.config = { ...this.config, ...payload };
try {
@@ -27,6 +44,7 @@ module.exports = NodeHelper.create({
return;
}
this.stateReady.then(async () => {
if (!this.hasPersistedTheme) this.state.theme = normalizeTheme(this.config.theme);
this.sendSocketNotification("STATE", this.publicState());
await this.restoreRecipe();
}).catch((error) => this.setError(error));
@@ -82,11 +100,18 @@ module.exports = NodeHelper.create({
return response.status(400).json({ error: "recipeId is required" });
}
const recipe = await this.getClient().getRecipe(recipeId);
const isResumingRecipe = this.state.recipeId === recipeId
&& Number.isInteger(this.state.portions)
&& this.state.portions > 0;
this.state = {
active: true,
recipeId,
recipe,
portions: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
recipeName: recipe.name || "Untitled recipe",
portions: isResumingRecipe
? this.state.portions
: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
progress: isResumingRecipe ? this.state.progress : normalizeProgress(),
error: null
};
await this.saveState();
@@ -96,6 +121,43 @@ module.exports = NodeHelper.create({
return next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/resume", async (_request, response, next) => {
try {
if (this.state.active) return response.json(this.publicState());
if (!this.state.recipeId) return response.status(409).json({ error: "No recent recipe to resume" });
const recipe = await this.getClient().getRecipe(this.state.recipeId);
this.state = {
...this.state,
active: true,
recipe,
recipeName: recipe.name || "Untitled recipe",
portions: Number.isInteger(this.state.portions) && this.state.portions > 0
? this.state.portions
: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
progress: normalizeProgress(this.state.progress),
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/theme", async (request, response, next) => {
try {
const theme = request.body?.theme;
if (!["light", "dark"].includes(theme)) {
return response.status(400).json({ error: "theme must be light or dark" });
}
this.state.theme = theme;
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;
@@ -113,9 +175,25 @@ module.exports = NodeHelper.create({
return next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/progress", async (request, response, next) => {
try {
if (!this.state.active || !this.state.recipe) {
return response.status(409).json({ error: "No recipe is active" });
}
this.state.progress = updateProgress(this.state.recipe, this.state.progress, request.body || {});
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
return response.json(this.publicState());
} catch (error) {
if (error.message === "Invalid progress update" || error.message === "Progress item does not exist") {
return response.status(400).json({ error: error.message });
}
return next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/exit", async (_request, response, next) => {
try {
this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null };
this.state = { ...this.state, active: false, error: null };
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
response.json(this.publicState());
@@ -144,12 +222,17 @@ module.exports = NodeHelper.create({
async loadState() {
try {
const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8"));
if (!persisted.active || !persisted.recipeId) return;
this.hasPersistedTheme = typeof persisted.theme === "string";
this.state.theme = normalizeTheme(persisted.theme);
if (!persisted.recipeId) return;
this.state = {
active: true,
active: Boolean(persisted.active),
recipeId: persisted.recipeId,
recipe: null,
recipeName: typeof persisted.recipeName === "string" ? persisted.recipeName : null,
portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
progress: normalizeProgress(persisted.progress),
theme: this.state.theme,
error: null
};
} catch (error) {
@@ -174,7 +257,10 @@ module.exports = NodeHelper.create({
await fs.writeFile(temporary, `${JSON.stringify({
active: this.state.active,
recipeId: this.state.recipeId,
portions: this.state.portions
recipeName: this.state.recipeName,
portions: this.state.portions,
progress: this.state.progress,
theme: this.state.theme
})}\n`, {
mode: 0o600
});
@@ -190,9 +276,39 @@ module.exports = NodeHelper.create({
return {
active: this.state.active,
recipeId: this.state.recipeId,
recipe: this.state.recipe ? scaleRecipe(this.state.recipe, this.state.portions) : null,
recipe: this.state.active && this.state.recipe
? scaleRecipe(this.state.recipe, this.state.portions)
: null,
portions: this.state.portions,
progress: this.state.progress,
theme: this.state.theme,
canResume: !this.state.active && Boolean(this.state.recipeId),
lastRecipeName: !this.state.active ? this.state.recipeName : null,
error: this.state.error
};
},
async updateProgressFromMirror(payload) {
try {
if (!this.state.active || !this.state.recipe) return;
this.state.progress = updateProgress(this.state.recipe, this.state.progress, payload || {});
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
} catch (error) {
console.error(`[MMM-NextcloudCookbook] ${error.message}`);
this.sendSocketNotification("STATE", this.publicState());
}
},
async updateTheme(theme) {
try {
if (!["light", "dark"].includes(theme)) throw new Error("theme must be light or dark");
this.state.theme = theme;
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
} catch (error) {
console.error(`[MMM-NextcloudCookbook] ${error.message}`);
this.sendSocketNotification("STATE", this.publicState());
}
}
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mmm-nextcloud-cookbook",
"version": "0.2.1",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mmm-nextcloud-cookbook",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"fast-xml-parser": "5.10.1",
"qrcode": "1.5.4"
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "mmm-nextcloud-cookbook",
"version": "0.2.1",
"version": "0.2.2",
"private": true,
"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/format-duration.js && node --check lib/nextcloud-public-share.js && node --check lib/recipe-scaler.js && node --check control/app.js",
"check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/format-duration.js && node --check lib/nextcloud-public-share.js && node --check lib/recipe-progress.js && node --check lib/recipe-scaler.js && node --check control/app.js",
"test": "npm run check && node --test"
},
"engines": {
+25
View File
@@ -0,0 +1,25 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { flattenInstructions, updateProgress } = require("../lib/recipe-progress");
const recipe = {
recipeIngredient: ["1 cup flour", "1 egg"],
recipeInstructions: ["Mix", { itemListElement: [{ text: "Bake" }] }]
};
test("flattens nested recipe instructions", () => {
assert.deepEqual(flattenInstructions(recipe.recipeInstructions), ["Mix", "Bake"]);
});
test("updates and validates cooking progress", () => {
const ingredientProgress = updateProgress(recipe, {}, {
kind: "ingredient", index: 1, completed: true
});
assert.deepEqual(ingredientProgress, { completedIngredients: [1], completedSteps: [] });
assert.deepEqual(updateProgress(recipe, ingredientProgress, {
kind: "ingredient", index: 1, completed: false
}), { completedIngredients: [], completedSteps: [] });
assert.throws(() => updateProgress(recipe, {}, {
kind: "step", index: 2, completed: true
}), /does not exist/);
});
+18 -1
View File
@@ -1,6 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseYield, scaleIngredient, scaleRecipe } = require("../lib/recipe-scaler");
const { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe } = require("../lib/recipe-scaler");
test("extracts the number of portions from a recipe yield", () => {
assert.equal(parseYield("6 servings"), 6);
@@ -25,4 +25,21 @@ test("returns a scaled copy of a recipe", () => {
assert.equal(scaled.recipeYield, "9 servings");
assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]);
assert.equal(original.recipeYield, "6 servings");
assert.equal(scaled.originalRecipeYield, "6 servings");
assert.deepEqual(scaled.recipeIngredientStatus, ["scaled", "unquantified"]);
});
test("reports ingredient scaling confidence", () => {
assert.deepEqual(scaleIngredientWithStatus("2 cups oats", 1.5), {
value: "3 cups oats", status: "scaled"
});
assert.deepEqual(scaleIngredientWithStatus("Salt to taste", 2), {
value: "Salt to taste", status: "unquantified"
});
assert.deepEqual(scaleIngredientWithStatus("1-2 tbsp oil", 2), {
value: "1-2 tbsp oil", status: "unscalable"
});
assert.deepEqual(scaleIngredientWithStatus("1 cup milk", 0.7), {
value: "0.7 cup milk", status: "approximate"
});
});