Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8fbcf4104 | ||
|
|
22daf703bb | ||
|
|
803d722836 | ||
|
|
6f28f83f4a | ||
|
|
b61ca70bed | ||
|
|
de8eab9022 | ||
|
|
3e695d6699 | ||
|
|
bc81e8403d |
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
*.log
|
||||
.env
|
||||
|
||||
+94
-10
@@ -4,7 +4,7 @@
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-active {
|
||||
--ncc-type-scale: 1;
|
||||
--ncc-content-scale: 1;
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -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;
|
||||
@@ -54,19 +85,26 @@
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-recipe h1 {
|
||||
margin: 0 0 calc(26px * var(--ncc-type-scale));
|
||||
margin: 0 0 26px;
|
||||
color: #fff6d8;
|
||||
font-size: calc(82px * var(--ncc-type-scale));
|
||||
font-size: 82px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 22px;
|
||||
gap: 10px 24px;
|
||||
align-items: baseline;
|
||||
margin: 0 0 calc(36px * var(--ncc-type-scale));
|
||||
font-size: calc(36px * var(--ncc-type-scale));
|
||||
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 {
|
||||
@@ -79,27 +117,73 @@
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-columns {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-layout-stacked section:first-child {
|
||||
margin-bottom: calc(42px * var(--ncc-content-scale));
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-layout-side-by-side {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.8fr) minmax(400px, 1.4fr);
|
||||
gap: 42px;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-columns h2 {
|
||||
margin: 0 0 calc(18px * var(--ncc-type-scale));
|
||||
margin: 0 0 calc(18px * var(--ncc-content-scale));
|
||||
color: #d6b96b;
|
||||
font-size: calc(56px * var(--ncc-type-scale));
|
||||
font-size: calc(56px * var(--ncc-content-scale));
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-columns ul,
|
||||
.MMM-NextcloudCookbook .ncc-columns ol {
|
||||
margin: 0;
|
||||
padding-left: 1.25em;
|
||||
font-size: calc(50px * var(--ncc-type-scale));
|
||||
font-size: calc(50px * var(--ncc-content-scale));
|
||||
line-height: 1.36;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-columns li {
|
||||
margin-bottom: calc(18px * var(--ncc-type-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 {
|
||||
|
||||
+102
-15
@@ -5,6 +5,8 @@ Module.register("MMM-NextcloudCookbook", {
|
||||
controlPath: "/MMM-NextcloudCookbook/control",
|
||||
controlUrl: "",
|
||||
animationSpeed: 400,
|
||||
layout: "stacked",
|
||||
theme: "dark",
|
||||
shareUrl: "",
|
||||
sharePassword: ""
|
||||
},
|
||||
@@ -26,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) {
|
||||
@@ -57,7 +67,7 @@ Module.register("MMM-NextcloudCookbook", {
|
||||
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
const scale = (minimum + maximum) / 2;
|
||||
root.style.setProperty("--ncc-type-scale", scale);
|
||||
root.style.setProperty("--ncc-content-scale", scale);
|
||||
const fits = recipe.scrollHeight <= availableHeight
|
||||
&& recipe.scrollWidth <= availableWidth;
|
||||
if (fits) {
|
||||
@@ -67,7 +77,7 @@ Module.register("MMM-NextcloudCookbook", {
|
||||
maximum = scale;
|
||||
}
|
||||
}
|
||||
root.style.setProperty("--ncc-type-scale", best.toFixed(3));
|
||||
root.style.setProperty("--ncc-content-scale", best.toFixed(3));
|
||||
},
|
||||
|
||||
setRecipeMode(active) {
|
||||
@@ -84,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";
|
||||
@@ -104,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) {
|
||||
@@ -122,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";
|
||||
|
||||
@@ -135,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)]
|
||||
@@ -145,27 +182,58 @@ 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);
|
||||
}
|
||||
article.appendChild(header);
|
||||
|
||||
const columns = document.createElement("div");
|
||||
columns.className = "ncc-columns";
|
||||
const layout = this.config.layout === "side-by-side" ? "side-by-side" : "stacked";
|
||||
columns.className = `ncc-columns ncc-layout-${layout}`;
|
||||
|
||||
const ingredients = document.createElement("section");
|
||||
const ingredientsTitle = document.createElement("h2");
|
||||
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);
|
||||
@@ -175,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);
|
||||
|
||||
@@ -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
|
||||
@@ -16,11 +19,34 @@ link; it should look like `https://cloud.example.test/s/SHARE_TOKEN`.
|
||||
position: "bottom_right",
|
||||
config: {
|
||||
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",
|
||||
theme: "light"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`layout` controls the full-screen recipe arrangement. Its default value,
|
||||
`"stacked"`, displays the ingredients above the instructions. Set it to
|
||||
`"side-by-side"` to display ingredients and instructions in two columns. Both
|
||||
layouts automatically scale the ingredient and instruction sections to make
|
||||
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
@@ -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();
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
<p class="eyebrow">Kitchen Mirror</p>
|
||||
<h1>Recipes</h1>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mmm-nextcloud-cookbook",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mmm-nextcloud-cookbook",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "5.10.1",
|
||||
"qrcode": "1.5.4"
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "mmm-nextcloud-cookbook",
|
||||
"version": "0.1.2",
|
||||
"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": {
|
||||
|
||||
@@ -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 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"
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user