@@ -117,6 +117,44 @@
|
|||||||
margin-bottom: calc(18px * var(--ncc-content-scale));
|
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 {
|
.MMM-NextcloudCookbook .ncc-error {
|
||||||
color: #ff9d91;
|
color: #ff9d91;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
|
|||||||
@@ -123,11 +123,11 @@ Module.register("MMM-NextcloudCookbook", {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
root.appendChild(this.renderRecipe(this.state.recipe));
|
root.appendChild(this.renderRecipe(this.state.recipe, this.state.progress));
|
||||||
return root;
|
return root;
|
||||||
},
|
},
|
||||||
|
|
||||||
renderRecipe(recipe) {
|
renderRecipe(recipe, progress = {}) {
|
||||||
const article = document.createElement("article");
|
const article = document.createElement("article");
|
||||||
article.className = "ncc-recipe";
|
article.className = "ncc-recipe";
|
||||||
|
|
||||||
@@ -138,6 +138,7 @@ Module.register("MMM-NextcloudCookbook", {
|
|||||||
|
|
||||||
const metadata = [
|
const metadata = [
|
||||||
["Yield", recipe.recipeYield],
|
["Yield", recipe.recipeYield],
|
||||||
|
["Original yield", recipe.originalRecipeYield !== recipe.recipeYield ? recipe.originalRecipeYield : null],
|
||||||
["Prep", NCCFormatDuration(recipe.prepTime)],
|
["Prep", NCCFormatDuration(recipe.prepTime)],
|
||||||
["Cook", NCCFormatDuration(recipe.cookTime)],
|
["Cook", NCCFormatDuration(recipe.cookTime)],
|
||||||
["Total", NCCFormatDuration(recipe.totalTime)]
|
["Total", NCCFormatDuration(recipe.totalTime)]
|
||||||
@@ -168,9 +169,34 @@ Module.register("MMM-NextcloudCookbook", {
|
|||||||
ingredientsTitle.textContent = "Ingredients";
|
ingredientsTitle.textContent = "Ingredients";
|
||||||
ingredients.appendChild(ingredientsTitle);
|
ingredients.appendChild(ingredientsTitle);
|
||||||
const ingredientList = document.createElement("ul");
|
const ingredientList = document.createElement("ul");
|
||||||
(recipe.recipeIngredient || []).forEach((ingredient) => {
|
(recipe.recipeIngredient || []).forEach((ingredient, index) => {
|
||||||
const item = document.createElement("li");
|
const item = document.createElement("li");
|
||||||
item.textContent = ingredient;
|
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);
|
ingredientList.appendChild(item);
|
||||||
});
|
});
|
||||||
ingredients.appendChild(ingredientList);
|
ingredients.appendChild(ingredientList);
|
||||||
@@ -180,9 +206,26 @@ Module.register("MMM-NextcloudCookbook", {
|
|||||||
instructionsTitle.textContent = "Instructions";
|
instructionsTitle.textContent = "Instructions";
|
||||||
instructions.appendChild(instructionsTitle);
|
instructions.appendChild(instructionsTitle);
|
||||||
const instructionList = document.createElement("ol");
|
const instructionList = document.createElement("ol");
|
||||||
this.flattenInstructions(recipe.recipeInstructions || []).forEach((step) => {
|
this.flattenInstructions(recipe.recipeInstructions || []).forEach((step, index) => {
|
||||||
const item = document.createElement("li");
|
const item = document.createElement("li");
|
||||||
item.textContent = step;
|
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);
|
instructionList.appendChild(item);
|
||||||
});
|
});
|
||||||
instructions.appendChild(instructionList);
|
instructions.appendChild(instructionList);
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
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,
|
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
|
Create a public share for the folder configured as your Nextcloud Cookbook
|
||||||
recipe directory. Read-only permission is sufficient. Copy the public share
|
recipe directory. Read-only permission is sufficient. Copy the public share
|
||||||
@@ -30,6 +33,11 @@ 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
|
The recipe title, timing metadata, yield, and QR controller remain at fixed
|
||||||
sizes.
|
sizes.
|
||||||
|
|
||||||
|
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
|
The configured `position` controls where the QR controller appears during the
|
||||||
normal MagicMirror layout. When a recipe is activated, the module displays a
|
normal MagicMirror layout. When a recipe is activated, the module displays a
|
||||||
fixed full-screen overlay regardless of that region. Exiting recipe mode
|
fixed full-screen overlay regardless of that region. Exiting recipe mode
|
||||||
|
|||||||
+87
-14
@@ -7,10 +7,18 @@ const portionControls = document.querySelector("#portions");
|
|||||||
const portionCount = document.querySelector("#portion-count");
|
const portionCount = document.querySelector("#portion-count");
|
||||||
const fewerPortions = document.querySelector("#fewer-portions");
|
const fewerPortions = document.querySelector("#fewer-portions");
|
||||||
const morePortions = document.querySelector("#more-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 recipes = [];
|
||||||
let activeRecipeId = null;
|
let activeRecipeId = null;
|
||||||
let activeRecipeName = null;
|
let activeRecipeLabel = null;
|
||||||
|
let displayedRecipe = null;
|
||||||
let portions = null;
|
let portions = null;
|
||||||
|
let progress = { completedIngredients: [], completedSteps: [] };
|
||||||
|
let recipeModeActive = false;
|
||||||
|
|
||||||
async function request(path, options) {
|
async function request(path, options) {
|
||||||
const response = await fetch(`${apiBase}${path}`, {
|
const response = await fetch(`${apiBase}${path}`, {
|
||||||
@@ -45,9 +53,65 @@ 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;
|
portionControls.hidden = !recipeModeActive;
|
||||||
portionCount.textContent = portions ?? "";
|
portionCount.textContent = portions ?? "";
|
||||||
fewerPortions.disabled = portions <= 1;
|
fewerPortions.disabled = portions <= 1;
|
||||||
|
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} (original: ${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: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activate(recipe) {
|
async function activate(recipe) {
|
||||||
@@ -57,9 +121,8 @@ async function activate(recipe) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ recipeId: recipe.id })
|
body: JSON.stringify({ recipeId: recipe.id })
|
||||||
});
|
});
|
||||||
activeRecipeId = state.recipeId;
|
applyState(state);
|
||||||
activeRecipeName = recipe.name;
|
activeRecipeLabel = 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) {
|
||||||
@@ -73,24 +136,36 @@ async function adjustPortions(delta) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ delta })
|
body: JSON.stringify({ delta })
|
||||||
});
|
});
|
||||||
portions = state.portions;
|
applyState(state);
|
||||||
status.textContent = `${activeRecipeName} is now on the mirror.`;
|
status.textContent = `${activeRecipeLabel} is now on the mirror.`;
|
||||||
render();
|
render();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = error.message;
|
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);
|
||||||
|
render();
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error.message;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fewerPortions.addEventListener("click", () => adjustPortions(-1));
|
fewerPortions.addEventListener("click", () => adjustPortions(-1));
|
||||||
morePortions.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: "{}" });
|
const state = await request("/exit", { method: "POST", body: "{}" });
|
||||||
activeRecipeId = null;
|
applyState(state);
|
||||||
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) {
|
||||||
@@ -102,9 +177,7 @@ 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;
|
applyState(state);
|
||||||
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();
|
||||||
|
|||||||
@@ -21,6 +21,18 @@
|
|||||||
<span><strong id="portion-count"></strong> portions</span>
|
<span><strong id="portion-count"></strong> portions</span>
|
||||||
<button id="more-portions" type="button" aria-label="One more portion">+</button>
|
<button id="more-portions" type="button" aria-label="One more portion">+</button>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
|||||||
@@ -102,6 +102,30 @@ button {
|
|||||||
|
|
||||||
.recipe strong { font-size: 1.12rem; }
|
.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; }
|
||||||
|
.checklist input { margin-top: .25rem; min-width: 1.1rem; min-height: 1.1rem; }
|
||||||
|
.checklist .completed { color: #777; text-decoration: line-through; }
|
||||||
|
.checklist .quantity-warning { color: #876500; }
|
||||||
|
.checklist .quantity-error { color: #a2342b; }
|
||||||
|
|
||||||
.recipe span {
|
.recipe span {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -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 };
|
||||||
+54
-7
@@ -5,11 +5,15 @@ 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");
|
const { parseYield, scaleRecipe } = require("./lib/recipe-scaler");
|
||||||
|
const { normalizeProgress, updateProgress } = require("./lib/recipe-progress");
|
||||||
|
|
||||||
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, portions: null, error: null };
|
this.state = {
|
||||||
|
active: false, recipeId: null, recipe: null, portions: null,
|
||||||
|
progress: normalizeProgress(), 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();
|
||||||
@@ -18,6 +22,10 @@ module.exports = NodeHelper.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
socketNotificationReceived(notification, payload) {
|
socketNotificationReceived(notification, payload) {
|
||||||
|
if (notification === "UPDATE_PROGRESS") {
|
||||||
|
this.updateProgressFromMirror(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (notification !== "CONFIG") return;
|
if (notification !== "CONFIG") return;
|
||||||
this.config = { ...this.config, ...payload };
|
this.config = { ...this.config, ...payload };
|
||||||
try {
|
try {
|
||||||
@@ -82,11 +90,17 @@ 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);
|
||||||
|
const isResumingRecipe = this.state.recipeId === recipeId
|
||||||
|
&& Number.isInteger(this.state.portions)
|
||||||
|
&& this.state.portions > 0;
|
||||||
this.state = {
|
this.state = {
|
||||||
active: true,
|
active: true,
|
||||||
recipeId,
|
recipeId,
|
||||||
recipe,
|
recipe,
|
||||||
portions: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
|
portions: isResumingRecipe
|
||||||
|
? this.state.portions
|
||||||
|
: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
|
||||||
|
progress: isResumingRecipe ? this.state.progress : normalizeProgress(),
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
await this.saveState();
|
await this.saveState();
|
||||||
@@ -113,9 +127,25 @@ module.exports = NodeHelper.create({
|
|||||||
return next(error);
|
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) => {
|
this.expressApp.post("/MMM-NextcloudCookbook/api/exit", async (_request, response, next) => {
|
||||||
try {
|
try {
|
||||||
this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null };
|
this.state = { ...this.state, active: false, 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());
|
||||||
@@ -144,12 +174,13 @@ module.exports = NodeHelper.create({
|
|||||||
async loadState() {
|
async loadState() {
|
||||||
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.recipeId) return;
|
||||||
this.state = {
|
this.state = {
|
||||||
active: true,
|
active: Boolean(persisted.active),
|
||||||
recipeId: persisted.recipeId,
|
recipeId: persisted.recipeId,
|
||||||
recipe: null,
|
recipe: null,
|
||||||
portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
|
portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
|
||||||
|
progress: normalizeProgress(persisted.progress),
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -174,7 +205,8 @@ module.exports = NodeHelper.create({
|
|||||||
await fs.writeFile(temporary, `${JSON.stringify({
|
await fs.writeFile(temporary, `${JSON.stringify({
|
||||||
active: this.state.active,
|
active: this.state.active,
|
||||||
recipeId: this.state.recipeId,
|
recipeId: this.state.recipeId,
|
||||||
portions: this.state.portions
|
portions: this.state.portions,
|
||||||
|
progress: this.state.progress
|
||||||
})}\n`, {
|
})}\n`, {
|
||||||
mode: 0o600
|
mode: 0o600
|
||||||
});
|
});
|
||||||
@@ -190,9 +222,24 @@ 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 ? 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,
|
portions: this.state.portions,
|
||||||
|
progress: this.state.progress,
|
||||||
error: this.state.error
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+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/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"
|
"test": "npm run check && node --test"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user