Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8fbcf4104 | ||
|
|
22daf703bb | ||
|
|
803d722836 | ||
|
|
6f28f83f4a | ||
|
|
b61ca70bed |
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
coverage/
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
|
.env
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
text-align: left;
|
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 {
|
.MMM-NextcloudCookbook .ncc-qr {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -37,6 +46,28 @@
|
|||||||
top: 18px;
|
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 {
|
.MMM-NextcloudCookbook .ncc-qr img {
|
||||||
width: 164px;
|
width: 164px;
|
||||||
height: 164px;
|
height: 164px;
|
||||||
@@ -117,6 +148,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;
|
||||||
|
|||||||
+93
-11
@@ -6,6 +6,7 @@ Module.register("MMM-NextcloudCookbook", {
|
|||||||
controlUrl: "",
|
controlUrl: "",
|
||||||
animationSpeed: 400,
|
animationSpeed: 400,
|
||||||
layout: "stacked",
|
layout: "stacked",
|
||||||
|
theme: "dark",
|
||||||
shareUrl: "",
|
shareUrl: "",
|
||||||
sharePassword: ""
|
sharePassword: ""
|
||||||
},
|
},
|
||||||
@@ -27,9 +28,17 @@ Module.register("MMM-NextcloudCookbook", {
|
|||||||
|
|
||||||
socketNotificationReceived(notification, payload) {
|
socketNotificationReceived(notification, payload) {
|
||||||
if (notification !== "STATE") return;
|
if (notification !== "STATE") return;
|
||||||
|
const previous = this.state;
|
||||||
this.state = payload;
|
this.state = payload;
|
||||||
this.setRecipeMode(Boolean(payload.active));
|
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) {
|
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() {
|
getDom() {
|
||||||
const root = document.createElement("section");
|
const root = document.createElement("section");
|
||||||
root.className = this.state.active
|
root.className = `ncc-root ${this.state.active ? "ncc-active" : "ncc-idle"} ncc-theme-${this.state.theme || this.config.theme}`;
|
||||||
? "ncc-root ncc-active"
|
|
||||||
: "ncc-root ncc-idle";
|
|
||||||
|
|
||||||
const qr = document.createElement("a");
|
const qr = document.createElement("a");
|
||||||
qr.className = "ncc-qr";
|
qr.className = "ncc-qr";
|
||||||
@@ -105,6 +130,14 @@ Module.register("MMM-NextcloudCookbook", {
|
|||||||
qr.append(qrLabel, qrImage);
|
qr.append(qrLabel, qrImage);
|
||||||
root.appendChild(qr);
|
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.active) return root;
|
||||||
|
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
@@ -123,11 +156,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";
|
||||||
|
|
||||||
@@ -136,8 +169,11 @@ Module.register("MMM-NextcloudCookbook", {
|
|||||||
title.textContent = recipe.name || "Untitled recipe";
|
title.textContent = recipe.name || "Untitled recipe";
|
||||||
header.appendChild(title);
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const yieldValue = recipe.originalRecipeYield && recipe.originalRecipeYield !== recipe.recipeYield
|
||||||
|
? `${recipe.recipeYield} (originally ${recipe.originalRecipeYield})`
|
||||||
|
: recipe.recipeYield;
|
||||||
const metadata = [
|
const metadata = [
|
||||||
["Yield", recipe.recipeYield],
|
["Yield", yieldValue],
|
||||||
["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 +204,36 @@ 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;
|
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);
|
ingredientList.appendChild(item);
|
||||||
});
|
});
|
||||||
ingredients.appendChild(ingredientList);
|
ingredients.appendChild(ingredientList);
|
||||||
@@ -180,9 +243,28 @@ 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;
|
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);
|
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
|
||||||
@@ -17,7 +20,8 @@ link; it should look like `https://cloud.example.test/s/SHARE_TOKEN`.
|
|||||||
config: {
|
config: {
|
||||||
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
|
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
|
||||||
controlUrl: "http://mirror.example.test:8080/MMM-NextcloudCookbook/control",
|
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
|
The recipe title, timing metadata, yield, and QR controller remain at fixed
|
||||||
sizes.
|
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
|
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
|
||||||
|
|||||||
+118
-14
@@ -3,14 +3,27 @@ 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 resume = document.querySelector("#resume");
|
||||||
|
const themeToggle = document.querySelector("#theme-toggle");
|
||||||
const portionControls = document.querySelector("#portions");
|
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;
|
||||||
|
let canResume = false;
|
||||||
|
let lastRecipeName = null;
|
||||||
|
let theme = "dark";
|
||||||
|
|
||||||
async function request(path, options) {
|
async function request(path, options) {
|
||||||
const response = await fetch(`${apiBase}${path}`, {
|
const response = await fetch(`${apiBase}${path}`, {
|
||||||
@@ -45,9 +58,71 @@ 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;
|
||||||
|
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) {
|
async function activate(recipe) {
|
||||||
@@ -57,9 +132,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 +147,56 @@ 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);
|
||||||
|
} 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));
|
||||||
|
|
||||||
|
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 () => {
|
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 +208,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();
|
||||||
|
|||||||
@@ -13,7 +13,11 @@
|
|||||||
<p class="eyebrow">Kitchen Mirror</p>
|
<p class="eyebrow">Kitchen Mirror</p>
|
||||||
<h1>Recipes</h1>
|
<h1>Recipes</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<button id="exit" class="secondary" type="button">Exit recipe mode</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<p id="status" role="status">Loading recipes…</p>
|
<p id="status" role="status">Loading recipes…</p>
|
||||||
<div id="portions" class="portions" hidden>
|
<div id="portions" class="portions" hidden>
|
||||||
@@ -21,6 +25,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>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ header {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
|
||||||
|
|
||||||
h1 { margin: 0; font-size: 2.3rem; }
|
h1 { margin: 0; font-size: 2.3rem; }
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
@@ -102,6 +104,37 @@ 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; 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 {
|
.recipe span {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -109,3 +142,12 @@ button {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -30,33 +30,53 @@ function formatQuantity(value) {
|
|||||||
));
|
));
|
||||||
if (Math.abs(fraction - remainder) <= 0.02) {
|
if (Math.abs(fraction - remainder) <= 0.02) {
|
||||||
const adjustedWhole = fraction === 1 ? whole + 1 : whole;
|
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) {
|
function scaleIngredient(ingredient, factor) {
|
||||||
if (typeof ingredient !== "string" || factor === 1) return ingredient;
|
return scaleIngredientWithStatus(ingredient, factor).value;
|
||||||
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) {
|
function scaleRecipe(recipe, portions) {
|
||||||
const basePortions = parseYield(recipe?.recipeYield);
|
const basePortions = parseYield(recipe?.recipeYield);
|
||||||
if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe;
|
if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe;
|
||||||
const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, "");
|
const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, "");
|
||||||
|
const factor = portions / basePortions;
|
||||||
|
const ingredients = (recipe.recipeIngredient || []).map((ingredient) => (
|
||||||
|
scaleIngredientWithStatus(ingredient, factor)
|
||||||
|
));
|
||||||
return {
|
return {
|
||||||
...recipe,
|
...recipe,
|
||||||
recipeYield: suffix ? `${portions} ${suffix}` : portions,
|
recipeYield: suffix ? `${portions} ${suffix}` : portions,
|
||||||
recipeIngredient: (recipe.recipeIngredient || []).map((ingredient) => (
|
originalRecipeYield: recipe.recipeYield,
|
||||||
scaleIngredient(ingredient, portions / basePortions)
|
recipeIngredient: ingredients.map(({ value }) => value),
|
||||||
))
|
recipeIngredientStatus: ingredients.map(({ status }) => status)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { parseYield, scaleIngredient, scaleRecipe };
|
module.exports = { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe };
|
||||||
|
|||||||
+124
-8
@@ -5,11 +5,20 @@ 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");
|
||||||
|
|
||||||
|
function normalizeTheme(theme) {
|
||||||
|
return theme === "light" ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = NodeHelper.create({
|
module.exports = NodeHelper.create({
|
||||||
start() {
|
start() {
|
||||||
this.config = { controlPath: "/MMM-NextcloudCookbook/control" };
|
this.config = { controlPath: "/MMM-NextcloudCookbook/control", theme: "dark" };
|
||||||
this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null };
|
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.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 +27,14 @@ module.exports = NodeHelper.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
socketNotificationReceived(notification, payload) {
|
socketNotificationReceived(notification, payload) {
|
||||||
|
if (notification === "UPDATE_PROGRESS") {
|
||||||
|
this.updateProgressFromMirror(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (notification === "SET_THEME") {
|
||||||
|
this.updateTheme(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (notification !== "CONFIG") return;
|
if (notification !== "CONFIG") return;
|
||||||
this.config = { ...this.config, ...payload };
|
this.config = { ...this.config, ...payload };
|
||||||
try {
|
try {
|
||||||
@@ -27,6 +44,7 @@ module.exports = NodeHelper.create({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.stateReady.then(async () => {
|
this.stateReady.then(async () => {
|
||||||
|
if (!this.hasPersistedTheme) this.state.theme = normalizeTheme(this.config.theme);
|
||||||
this.sendSocketNotification("STATE", this.publicState());
|
this.sendSocketNotification("STATE", this.publicState());
|
||||||
await this.restoreRecipe();
|
await this.restoreRecipe();
|
||||||
}).catch((error) => this.setError(error));
|
}).catch((error) => this.setError(error));
|
||||||
@@ -82,11 +100,18 @@ 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)),
|
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
|
error: null
|
||||||
};
|
};
|
||||||
await this.saveState();
|
await this.saveState();
|
||||||
@@ -96,6 +121,43 @@ module.exports = NodeHelper.create({
|
|||||||
return next(error);
|
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) => {
|
this.expressApp.post("/MMM-NextcloudCookbook/api/portions", async (request, response, next) => {
|
||||||
try {
|
try {
|
||||||
const delta = request.body?.delta;
|
const delta = request.body?.delta;
|
||||||
@@ -113,9 +175,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 +222,17 @@ 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;
|
this.hasPersistedTheme = typeof persisted.theme === "string";
|
||||||
|
this.state.theme = normalizeTheme(persisted.theme);
|
||||||
|
if (!persisted.recipeId) return;
|
||||||
this.state = {
|
this.state = {
|
||||||
active: true,
|
active: Boolean(persisted.active),
|
||||||
recipeId: persisted.recipeId,
|
recipeId: persisted.recipeId,
|
||||||
recipe: null,
|
recipe: null,
|
||||||
|
recipeName: typeof persisted.recipeName === "string" ? persisted.recipeName : null,
|
||||||
portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
|
portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
|
||||||
|
progress: normalizeProgress(persisted.progress),
|
||||||
|
theme: this.state.theme,
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -174,7 +257,10 @@ 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
|
recipeName: this.state.recipeName,
|
||||||
|
portions: this.state.portions,
|
||||||
|
progress: this.state.progress,
|
||||||
|
theme: this.state.theme
|
||||||
})}\n`, {
|
})}\n`, {
|
||||||
mode: 0o600
|
mode: 0o600
|
||||||
});
|
});
|
||||||
@@ -190,9 +276,39 @@ 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,
|
||||||
|
theme: this.state.theme,
|
||||||
|
canResume: !this.state.active && Boolean(this.state.recipeId),
|
||||||
|
lastRecipeName: !this.state.active ? this.state.recipeName : null,
|
||||||
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());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+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/);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
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", () => {
|
test("extracts the number of portions from a recipe yield", () => {
|
||||||
assert.equal(parseYield("6 servings"), 6);
|
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.equal(scaled.recipeYield, "9 servings");
|
||||||
assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]);
|
assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]);
|
||||||
assert.equal(original.recipeYield, "6 servings");
|
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"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user