Add Nextcloud recipe display and controller

This commit is contained in:
2026-08-03 15:22:28 -05:00
parent 4ea34ec429
commit 464ae4159d
12 changed files with 1189 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
coverage/
*.log
+93
View File
@@ -0,0 +1,93 @@
.MMM-NextcloudCookbook .ncc-root {
color: #f4f1e8;
box-sizing: border-box;
}
.MMM-NextcloudCookbook .ncc-idle {
position: fixed;
right: 18px;
top: 18px;
z-index: 20;
}
.MMM-NextcloudCookbook .ncc-active {
position: fixed;
inset: 0;
z-index: 20;
overflow: hidden;
padding: 42px 52px;
background: #11110f;
}
.MMM-NextcloudCookbook .ncc-qr {
position: absolute;
right: 18px;
top: 18px;
padding: 6px;
border-radius: 6px;
background: #fff;
line-height: 0;
}
.MMM-NextcloudCookbook .ncc-qr img {
width: 82px;
height: 82px;
}
.MMM-NextcloudCookbook .ncc-active .ncc-qr img {
width: 68px;
height: 68px;
}
.MMM-NextcloudCookbook .ncc-recipe h1 {
margin: 0 100px 18px 0;
color: #fff6d8;
font-size: 52px;
line-height: 1.08;
}
.MMM-NextcloudCookbook .ncc-meta {
display: flex;
gap: 8px 18px;
align-items: baseline;
margin: 0 0 24px;
font-size: 21px;
}
.MMM-NextcloudCookbook .ncc-meta dt {
color: #d6b96b;
font-weight: 700;
}
.MMM-NextcloudCookbook .ncc-meta dd {
margin: 0;
}
.MMM-NextcloudCookbook .ncc-columns {
display: grid;
grid-template-columns: minmax(280px, 0.8fr) minmax(430px, 1.4fr);
gap: 46px;
}
.MMM-NextcloudCookbook .ncc-columns h2 {
margin: 0 0 12px;
color: #d6b96b;
font-size: 30px;
}
.MMM-NextcloudCookbook .ncc-columns ul,
.MMM-NextcloudCookbook .ncc-columns ol {
margin: 0;
padding-left: 1.25em;
font-size: 23px;
line-height: 1.3;
}
.MMM-NextcloudCookbook .ncc-columns li {
margin-bottom: 9px;
}
.MMM-NextcloudCookbook .ncc-error {
color: #ff9d91;
font-size: 30px;
}
+148
View File
@@ -0,0 +1,148 @@
/* global Module, MM */
Module.register("MMM-NextcloudCookbook", {
defaults: {
controlPath: "/MMM-NextcloudCookbook/control",
animationSpeed: 400
},
start() {
this.state = { active: false, recipe: null, error: null };
this.hiddenModules = false;
this.sendSocketNotification("CONFIG", this.config);
},
getStyles() {
return ["MMM-NextcloudCookbook.css"];
},
socketNotificationReceived(notification, payload) {
if (notification !== "STATE") return;
this.state = payload;
this.setRecipeMode(Boolean(payload.active));
this.updateDom(this.config.animationSpeed);
},
setRecipeMode(active) {
if (active && !this.hiddenModules) {
MM.getModules().exceptModule(this).enumerate((module) => {
module.hide(this.config.animationSpeed, { lockString: this.name });
});
this.hiddenModules = true;
} else if (!active && this.hiddenModules) {
MM.getModules().exceptModule(this).enumerate((module) => {
module.show(this.config.animationSpeed, { lockString: this.name });
});
this.hiddenModules = false;
}
},
getDom() {
const root = document.createElement("section");
root.className = this.state.active
? "ncc-root ncc-active"
: "ncc-root ncc-idle";
const qr = document.createElement("a");
qr.className = "ncc-qr";
qr.href = this.config.controlPath;
qr.setAttribute("aria-label", "Open recipe controller");
const qrImage = document.createElement("img");
qrImage.src = "/MMM-NextcloudCookbook/qr.svg";
qrImage.alt = "Recipe controller QR code";
qr.appendChild(qrImage);
root.appendChild(qr);
if (!this.state.active) return root;
if (this.state.error) {
const error = document.createElement("p");
error.className = "ncc-error";
error.textContent = this.state.error;
root.appendChild(error);
return root;
}
if (!this.state.recipe) {
const loading = document.createElement("p");
loading.className = "ncc-loading";
loading.textContent = "Loading recipe…";
root.appendChild(loading);
return root;
}
root.appendChild(this.renderRecipe(this.state.recipe));
return root;
},
renderRecipe(recipe) {
const article = document.createElement("article");
article.className = "ncc-recipe";
const header = document.createElement("header");
const title = document.createElement("h1");
title.textContent = recipe.name || "Untitled recipe";
header.appendChild(title);
const metadata = [
["Yield", recipe.recipeYield],
["Prep", recipe.prepTime],
["Cook", recipe.cookTime],
["Total", recipe.totalTime]
].filter(([, value]) => value);
if (metadata.length) {
const list = document.createElement("dl");
list.className = "ncc-meta";
metadata.forEach(([label, value]) => {
const term = document.createElement("dt");
term.textContent = label;
const detail = document.createElement("dd");
detail.textContent = value;
list.append(term, detail);
});
header.appendChild(list);
}
article.appendChild(header);
const columns = document.createElement("div");
columns.className = "ncc-columns";
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) => {
const item = document.createElement("li");
item.textContent = ingredient;
ingredientList.appendChild(item);
});
ingredients.appendChild(ingredientList);
const instructions = document.createElement("section");
const instructionsTitle = document.createElement("h2");
instructionsTitle.textContent = "Instructions";
instructions.appendChild(instructionsTitle);
const instructionList = document.createElement("ol");
this.flattenInstructions(recipe.recipeInstructions || []).forEach((step) => {
const item = document.createElement("li");
item.textContent = step;
instructionList.appendChild(item);
});
instructions.appendChild(instructionList);
columns.append(ingredients, instructions);
article.appendChild(columns);
return article;
},
flattenInstructions(instructions) {
return instructions.flatMap((instruction) => {
if (typeof instruction === "string") return [instruction];
if (Array.isArray(instruction.itemListElement)) {
return this.flattenInstructions(instruction.itemListElement);
}
return instruction.text ? [instruction.text] : [];
});
}
});
+39
View File
@@ -0,0 +1,39 @@
# MMM-NextcloudCookbook
A MagicMirror module that displays recipes from a read-only Nextcloud public
share. It includes a phone-friendly control page for choosing a recipe and
entering or leaving recipe mode.
The Nextcloud share URL is read only by `node_helper.js` from
`SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL`. An optional protected-share password can
be supplied as `SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD`. Neither value is sent
to the MagicMirror browser or control page.
## MagicMirror configuration
```js
{
module: "MMM-NextcloudCookbook",
position: "fullscreen_above",
config: {
controlPath: "/MMM-NextcloudCookbook/control"
}
}
```
Open the controller at:
```text
http://kitchenmirror.axvig.com:8080/MMM-NextcloudCookbook/control
```
The share must contain Nextcloud Cookbook recipe folders with a `recipe.json`
file in each folder. The module uses Nextcloud's token-scoped public DAV API;
protected shares authenticate as Nextcloud's `anonymous` public-share user.
## Development
```bash
npm install
npm test
```
+82
View File
@@ -0,0 +1,82 @@
const apiBase = "/MMM-NextcloudCookbook/api";
const status = document.querySelector("#status");
const list = document.querySelector("#recipes");
const search = document.querySelector("#search");
const exit = document.querySelector("#exit");
let recipes = [];
let activeRecipeId = null;
async function request(path, options) {
const response = await fetch(`${apiBase}${path}`, {
headers: { "Content-Type": "application/json" },
...options
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Request failed with HTTP ${response.status}`);
}
return response.json();
}
function render() {
const query = search.value.trim().toLocaleLowerCase();
const matches = recipes.filter((recipe) => recipe.name.toLocaleLowerCase().includes(query));
list.replaceChildren(...matches.map((recipe) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = recipe.id === activeRecipeId ? "recipe active" : "recipe";
const title = document.createElement("strong");
title.textContent = recipe.name;
button.appendChild(title);
if (recipe.description) {
const description = document.createElement("span");
description.textContent = recipe.description;
button.appendChild(description);
}
button.addEventListener("click", () => activate(recipe));
item.appendChild(button);
return item;
}));
if (!matches.length) status.textContent = "No matching recipes.";
}
async function activate(recipe) {
status.textContent = `Opening ${recipe.name}`;
try {
const state = await request("/activate", {
method: "POST",
body: JSON.stringify({ recipeId: recipe.id })
});
activeRecipeId = state.recipeId;
status.textContent = `${recipe.name} is now on the mirror.`;
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;
status.textContent = "The mirror is back to its normal display.";
render();
} catch (error) {
status.textContent = error.message;
}
});
search.addEventListener("input", render);
Promise.all([request("/state"), request("/recipes")])
.then(([state, loadedRecipes]) => {
activeRecipeId = state.recipeId;
recipes = loadedRecipes;
status.textContent = `${recipes.length} recipes available.`;
render();
})
.catch((error) => {
status.textContent = error.message;
});
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kitchen Mirror Recipes</title>
<link rel="stylesheet" href="/MMM-NextcloudCookbook/assets/styles.css">
</head>
<body>
<main>
<header>
<div>
<p class="eyebrow">Kitchen Mirror</p>
<h1>Recipes</h1>
</div>
<button id="exit" class="secondary" type="button">Exit recipe mode</button>
</header>
<p id="status" role="status">Loading recipes…</p>
<label for="search">Find a recipe</label>
<input id="search" type="search" autocomplete="off" placeholder="Search recipes">
<ul id="recipes" aria-live="polite"></ul>
</main>
<script src="/MMM-NextcloudCookbook/assets/app.js" defer></script>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
:root {
color-scheme: light;
font-family: system-ui, sans-serif;
color: #27231b;
background: #f3efe5;
}
* { box-sizing: border-box; }
body { margin: 0; }
main {
width: min(760px, 100%);
min-height: 100vh;
margin: auto;
padding: 24px 18px 60px;
}
header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
h1 { margin: 0; font-size: 2.3rem; }
.eyebrow {
margin: 0 0 2px;
color: #75623a;
font-size: .8rem;
font-weight: 800;
letter-spacing: .12em;
text-transform: uppercase;
}
label { display: block; margin: 24px 0 7px; font-weight: 700; }
input, button { font: inherit; }
input {
width: 100%;
padding: 14px;
border: 1px solid #b7aa91;
border-radius: 10px;
background: #fff;
font-size: 1.05rem;
}
button {
border: 0;
border-radius: 10px;
cursor: pointer;
}
.secondary {
padding: 11px 13px;
color: #fff;
background: #5a4b30;
}
#status { min-height: 1.5em; color: #665a45; }
#recipes { display: grid; gap: 10px; padding: 0; list-style: none; }
.recipe {
display: flex;
width: 100%;
padding: 16px;
flex-direction: column;
gap: 5px;
align-items: flex-start;
text-align: left;
color: inherit;
background: #fff;
box-shadow: 0 1px 5px rgb(61 48 24 / 12%);
}
.recipe.active { outline: 3px solid #b58b35; }
.recipe strong { font-size: 1.12rem; }
.recipe span {
display: -webkit-box;
overflow: hidden;
color: #756b59;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
+105
View File
@@ -0,0 +1,105 @@
const { XMLParser } = require("fast-xml-parser");
class NextcloudPublicShare {
constructor({ shareUrl, password = "", fetchImpl = global.fetch }) {
if (!shareUrl) throw new Error("Nextcloud Cookbook share URL is not configured");
if (typeof fetchImpl !== "function") throw new Error("A fetch implementation is required");
const parsed = new URL(shareUrl);
const tokenMatch = parsed.pathname.match(/\/s\/([^/]+)/);
if (!tokenMatch) throw new Error("Nextcloud public share URL must contain /s/<token>");
this.origin = parsed.origin;
this.token = decodeURIComponent(tokenMatch[1]);
this.password = password;
this.fetch = fetchImpl;
this.webdavUrl = new URL(`/public.php/dav/files/${encodeURIComponent(this.token)}/`, this.origin);
this.parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true });
}
get headers() {
if (!this.password) return {};
return { Authorization: `Basic ${Buffer.from(`anonymous:${this.password}`).toString("base64")}` };
}
async listRecipes() {
const response = await this.fetch(this.webdavUrl, {
method: "PROPFIND",
headers: {
...this.headers,
Depth: "infinity",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/xml; charset=utf-8"
},
body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:resourcetype/></d:prop></d:propfind>"
});
if (!response.ok && response.status !== 207) {
throw new Error(`Nextcloud recipe listing failed with HTTP ${response.status}`);
}
const parsed = this.parser.parse(await response.text());
const responses = this.asArray(parsed.multistatus?.response);
const recipePaths = responses
.map((entry) => decodeURIComponent(entry.href || ""))
.filter((href) => href.toLowerCase().endsWith("/recipe.json"));
const recipes = await this.mapWithConcurrency(recipePaths, 6, async (path) => {
const recipe = await this.fetchJson(path);
return {
id: Buffer.from(path).toString("base64url"),
path,
name: recipe.name || this.folderName(path),
description: recipe.description || ""
};
});
return recipes.sort((left, right) => left.name.localeCompare(right.name));
}
async getRecipe(id) {
let path;
try {
path = Buffer.from(id, "base64url").toString("utf8");
} catch {
throw new Error("Invalid recipe identifier");
}
if (!path.startsWith(this.webdavUrl.pathname) || !path.toLowerCase().endsWith("/recipe.json")) {
throw new Error("Invalid recipe identifier");
}
return this.fetchJson(path);
}
async fetchJson(path) {
const response = await this.fetch(new URL(path, this.origin), {
headers: this.headers
});
if (!response.ok) {
throw new Error(`Nextcloud recipe fetch failed with HTTP ${response.status}`);
}
return response.json();
}
folderName(path) {
const parts = path.split("/").filter(Boolean);
return parts.length > 1 ? parts.at(-2) : "Untitled recipe";
}
asArray(value) {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
async mapWithConcurrency(items, limit, mapper) {
const results = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await mapper(items[index]);
}
});
await Promise.all(workers);
return results;
}
}
module.exports = NextcloudPublicShare;
+144
View File
@@ -0,0 +1,144 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const express = require("express");
const NodeHelper = require("node_helper");
const QRCode = require("qrcode");
const NextcloudPublicShare = require("./lib/nextcloud-public-share");
module.exports = NodeHelper.create({
start() {
this.config = { controlPath: "/MMM-NextcloudCookbook/control" };
this.state = { active: false, recipeId: null, recipe: null, error: null };
this.recipeCache = { expires: 0, recipes: [] };
this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json");
this.client = null;
this.registerRoutes();
this.stateReady = this.loadState().catch((error) => this.setError(error));
},
socketNotificationReceived(notification, payload) {
if (notification !== "CONFIG") return;
this.config = { ...this.config, ...payload };
this.client = new NextcloudPublicShare({
shareUrl: process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL,
password: process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD || ""
});
this.stateReady.then(async () => {
this.sendSocketNotification("STATE", this.publicState());
await this.restoreRecipe();
}).catch((error) => this.setError(error));
},
registerRoutes() {
this.expressApp.use("/MMM-NextcloudCookbook/api", express.json({ limit: "16kb" }));
this.expressApp.get("/MMM-NextcloudCookbook/control", (_request, response) => {
response.sendFile(path.join(__dirname, "control", "index.html"));
});
this.expressApp.use(
"/MMM-NextcloudCookbook/assets",
express.static(path.join(__dirname, "control"))
);
this.expressApp.get("/MMM-NextcloudCookbook/qr.svg", async (request, response, next) => {
try {
const url = `${request.protocol}://${request.get("host")}${this.config.controlPath}`;
response.type("image/svg+xml").send(await QRCode.toString(url, { type: "svg", margin: 1 }));
} catch (error) {
next(error);
}
});
this.expressApp.get("/MMM-NextcloudCookbook/api/state", (_request, response) => {
response.json(this.publicState());
});
this.expressApp.get("/MMM-NextcloudCookbook/api/recipes", async (_request, response, next) => {
try {
response.json(await this.listRecipes());
} catch (error) {
next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/activate", async (request, response, next) => {
try {
const recipeId = request.body?.recipeId;
if (typeof recipeId !== "string" || !recipeId) {
return response.status(400).json({ error: "recipeId is required" });
}
const recipe = await this.getClient().getRecipe(recipeId);
this.state = { active: true, recipeId, recipe, 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/exit", async (_request, response, next) => {
try {
this.state = { active: false, recipeId: null, recipe: null, error: null };
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
response.json(this.publicState());
} catch (error) {
next(error);
}
});
this.expressApp.use("/MMM-NextcloudCookbook/api", (error, _request, response, _next) => {
console.error(`[MMM-NextcloudCookbook] ${error.message}`);
response.status(error.status || 500).json({ error: error.message || "Recipe request failed" });
});
},
getClient() {
if (!this.client) throw new Error("Nextcloud Cookbook is not configured yet");
return this.client;
},
async listRecipes() {
if (Date.now() < this.recipeCache.expires) return this.recipeCache.recipes;
const recipes = await this.getClient().listRecipes();
this.recipeCache = { expires: Date.now() + 5 * 60 * 1000, recipes };
return recipes;
},
async loadState() {
try {
const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8"));
if (!persisted.active || !persisted.recipeId) return;
this.state = { active: true, recipeId: persisted.recipeId, recipe: null, error: null };
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
},
async restoreRecipe() {
if (!this.state.active || !this.state.recipeId) return;
try {
this.state.recipe = await this.getClient().getRecipe(this.state.recipeId);
this.state.error = null;
} catch (error) {
this.setError(error);
}
this.sendSocketNotification("STATE", this.publicState());
},
async saveState() {
const temporary = `${this.stateFile}.tmp`;
await fs.writeFile(temporary, `${JSON.stringify({ active: this.state.active, recipeId: this.state.recipeId })}\n`, {
mode: 0o600
});
await fs.rename(temporary, this.stateFile);
},
setError(error) {
this.state.error = error instanceof Error ? error.message : String(error);
this.sendSocketNotification("STATE", this.publicState());
},
publicState() {
return {
active: this.state.active,
recipeId: this.state.recipeId,
recipe: this.state.recipe,
error: this.state.error
};
}
});
+407
View File
@@ -0,0 +1,407 @@
{
"name": "mmm-nextcloud-cookbook",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mmm-nextcloud-cookbook",
"version": "0.1.0",
"dependencies": {
"fast-xml-parser": "5.10.1",
"qrcode": "1.5.4"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
]
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
]
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"engines": {
"node": ">=6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/fast-xml-builder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
"integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"@nodable/entities": "^3.0.0",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.1",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-unsafe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
"integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
]
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"engines": {
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strnum": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "mmm-nextcloud-cookbook",
"version": "0.1.0",
"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/nextcloud-public-share.js && node --check control/app.js",
"test": "npm run check && node --test"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"fast-xml-parser": "5.10.1",
"qrcode": "1.5.4"
}
}
+36
View File
@@ -0,0 +1,36 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const NextcloudPublicShare = require("../lib/nextcloud-public-share");
test("extracts a token and lists recipe JSON files", async () => {
const calls = [];
const fetchImpl = async (url, options = {}) => {
calls.push({ url: String(url), options });
if (options.method === "PROPFIND") {
return new Response(`<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response><d:href>/public.php/dav/files/share-token/Apple%20Pie/recipe.json</d:href></d:response>
</d:multistatus>`, { status: 207 });
}
return Response.json({ name: "Apple Pie", description: "A test recipe" });
};
const client = new NextcloudPublicShare({
shareUrl: "https://cloud.example.test/s/share-token",
fetchImpl
});
const recipes = await client.listRecipes();
assert.equal(recipes.length, 1);
assert.equal(recipes[0].name, "Apple Pie");
assert.equal(calls[0].options.headers.Authorization, undefined);
assert.equal(calls[0].options.headers.Depth, "infinity");
assert.equal(calls[0].options.headers["X-Requested-With"], "XMLHttpRequest");
});
test("rejects recipe identifiers that do not point to recipe.json", async () => {
const client = new NextcloudPublicShare({
shareUrl: "https://cloud.example.test/s/share-token",
fetchImpl: async () => Response.json({})
});
await assert.rejects(() => client.getRecipe(Buffer.from("/other.txt").toString("base64url")));
});