From 464ae4159d6209336d03f4903b5b8290e6a5cf85 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Mon, 3 Aug 2026 15:22:28 -0500 Subject: [PATCH] Add Nextcloud recipe display and controller --- .gitignore | 3 + MMM-NextcloudCookbook.css | 93 +++++++ MMM-NextcloudCookbook.js | 148 ++++++++++ README.md | 39 +++ control/app.js | 82 ++++++ control/index.html | 25 ++ control/styles.css | 89 ++++++ lib/nextcloud-public-share.js | 105 +++++++ node_helper.js | 144 ++++++++++ package-lock.json | 407 ++++++++++++++++++++++++++++ package.json | 18 ++ test/nextcloud-public-share.test.js | 36 +++ 12 files changed, 1189 insertions(+) create mode 100644 .gitignore create mode 100644 MMM-NextcloudCookbook.css create mode 100644 MMM-NextcloudCookbook.js create mode 100644 control/app.js create mode 100644 control/index.html create mode 100644 control/styles.css create mode 100644 lib/nextcloud-public-share.js create mode 100644 node_helper.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 test/nextcloud-public-share.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c444c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +coverage/ +*.log diff --git a/MMM-NextcloudCookbook.css b/MMM-NextcloudCookbook.css new file mode 100644 index 0000000..1753f55 --- /dev/null +++ b/MMM-NextcloudCookbook.css @@ -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; +} diff --git a/MMM-NextcloudCookbook.js b/MMM-NextcloudCookbook.js new file mode 100644 index 0000000..0a4a837 --- /dev/null +++ b/MMM-NextcloudCookbook.js @@ -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] : []; + }); + } +}); diff --git a/README.md b/README.md index e69de29..acfeea2 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/control/app.js b/control/app.js new file mode 100644 index 0000000..b5368a3 --- /dev/null +++ b/control/app.js @@ -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; + }); diff --git a/control/index.html b/control/index.html new file mode 100644 index 0000000..12b7159 --- /dev/null +++ b/control/index.html @@ -0,0 +1,25 @@ + + + + + + Kitchen Mirror Recipes + + + +
+
+
+

Kitchen Mirror

+

Recipes

+
+ +
+

Loading recipes…

+ + + +
+ + + diff --git a/control/styles.css b/control/styles.css new file mode 100644 index 0000000..973f978 --- /dev/null +++ b/control/styles.css @@ -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; +} diff --git a/lib/nextcloud-public-share.js b/lib/nextcloud-public-share.js new file mode 100644 index 0000000..5442866 --- /dev/null +++ b/lib/nextcloud-public-share.js @@ -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/"); + + 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: "" + }); + 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; diff --git a/node_helper.js b/node_helper.js new file mode 100644 index 0000000..4616cd6 --- /dev/null +++ b/node_helper.js @@ -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 + }; + } +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b3432d6 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7b80993 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/test/nextcloud-public-share.test.js b/test/nextcloud-public-share.test.js new file mode 100644 index 0000000..03aec67 --- /dev/null +++ b/test/nextcloud-public-share.test.js @@ -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(` + + /public.php/dav/files/share-token/Apple%20Pie/recipe.json + `, { 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"))); +});