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"); const { parseYield, scaleRecipe } = require("./lib/recipe-scaler"); module.exports = NodeHelper.create({ start() { this.config = { controlPath: "/MMM-NextcloudCookbook/control" }; this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null }; this.recipeCache = { expires: 0, recipes: [] }; this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json"); this.configureClient(); this.registerRoutes(); this.stateReady = this.loadState().catch((error) => this.setError(error)); }, socketNotificationReceived(notification, payload) { if (notification !== "CONFIG") return; this.config = { ...this.config, ...payload }; try { this.configureClient(); } catch (error) { this.setError(error); return; } this.stateReady.then(async () => { this.sendSocketNotification("STATE", this.publicState()); await this.restoreRecipe(); }).catch((error) => this.setError(error)); }, configureClient() { const shareUrl = process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL || this.config.shareUrl; const password = process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD || this.config.sharePassword || ""; this.client = shareUrl ? new NextcloudPublicShare({ shareUrl, password }) : null; this.recipeCache = { expires: 0, recipes: [] }; }, 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 = this.config.controlUrl || `${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, portions: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)), 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/portions", async (request, response, next) => { try { const delta = request.body?.delta; if (!this.state.active || !this.state.recipe) { return response.status(409).json({ error: "No recipe is active" }); } if (delta !== -1 && delta !== 1) { return response.status(400).json({ error: "delta must be -1 or 1" }); } this.state.portions = Math.max(1, this.state.portions + delta); 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, portions: 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, portions: Number.isInteger(persisted.portions) ? persisted.portions : 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.portions ||= Math.max(1, Math.round(parseYield(this.state.recipe.recipeYield) || 1)); 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, portions: this.state.portions })}\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 ? scaleRecipe(this.state.recipe, this.state.portions) : null, portions: this.state.portions, error: this.state.error }; } });