Add Nextcloud recipe display and controller
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
*.log
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] : [];
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
});
|
||||
Generated
+407
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
});
|
||||
Reference in New Issue
Block a user