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")));
+});