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"); const { normalizeProgress, updateProgress } = require("./lib/recipe-progress"); function normalizeTheme(theme) { return theme === "light" ? "light" : "dark"; } module.exports = NodeHelper.create({ start() { this.config = { controlPath: "/MMM-NextcloudCookbook/control", theme: "dark" }; this.state = { active: false, recipeId: null, recipe: null, portions: null, recipeName: null, progress: normalizeProgress(), theme: "dark", error: null }; this.hasPersistedTheme = false; 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 === "UPDATE_PROGRESS") { this.updateProgressFromMirror(payload); return; } if (notification === "SET_THEME") { this.updateTheme(payload); return; } if (notification !== "CONFIG") return; this.config = { ...this.config, ...payload }; try { this.configureClient(); } catch (error) { this.setError(error); return; } this.stateReady.then(async () => { if (!this.hasPersistedTheme) this.state.theme = normalizeTheme(this.config.theme); 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 = request.query.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); const isResumingRecipe = this.state.recipeId === recipeId && Number.isInteger(this.state.portions) && this.state.portions > 0; this.state = { active: true, recipeId, recipe, recipeName: recipe.name || "Untitled recipe", portions: isResumingRecipe ? this.state.portions : Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)), progress: isResumingRecipe ? this.state.progress : normalizeProgress(), 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/resume", async (_request, response, next) => { try { if (this.state.active) return response.json(this.publicState()); if (!this.state.recipeId) return response.status(409).json({ error: "No recent recipe to resume" }); const recipe = await this.getClient().getRecipe(this.state.recipeId); this.state = { ...this.state, active: true, recipe, recipeName: recipe.name || "Untitled recipe", portions: Number.isInteger(this.state.portions) && this.state.portions > 0 ? this.state.portions : Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)), progress: normalizeProgress(this.state.progress), 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/theme", async (request, response, next) => { try { const theme = request.body?.theme; if (!["light", "dark"].includes(theme)) { return response.status(400).json({ error: "theme must be light or dark" }); } this.state.theme = theme; 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/progress", async (request, response, next) => { try { if (!this.state.active || !this.state.recipe) { return response.status(409).json({ error: "No recipe is active" }); } this.state.progress = updateProgress(this.state.recipe, this.state.progress, request.body || {}); await this.saveState(); this.sendSocketNotification("STATE", this.publicState()); return response.json(this.publicState()); } catch (error) { if (error.message === "Invalid progress update" || error.message === "Progress item does not exist") { return response.status(400).json({ error: error.message }); } return next(error); } }); this.expressApp.post("/MMM-NextcloudCookbook/api/exit", async (_request, response, next) => { try { this.state = { ...this.state, active: false, 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")); this.hasPersistedTheme = typeof persisted.theme === "string"; this.state.theme = normalizeTheme(persisted.theme); if (!persisted.recipeId) return; this.state = { active: Boolean(persisted.active), recipeId: persisted.recipeId, recipe: null, recipeName: typeof persisted.recipeName === "string" ? persisted.recipeName : null, portions: Number.isInteger(persisted.portions) ? persisted.portions : null, progress: normalizeProgress(persisted.progress), theme: this.state.theme, 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, recipeName: this.state.recipeName, portions: this.state.portions, progress: this.state.progress, theme: this.state.theme })}\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.active && this.state.recipe ? scaleRecipe(this.state.recipe, this.state.portions) : null, portions: this.state.portions, progress: this.state.progress, theme: this.state.theme, canResume: !this.state.active && Boolean(this.state.recipeId), lastRecipeName: !this.state.active ? this.state.recipeName : null, error: this.state.error }; }, async updateProgressFromMirror(payload) { try { if (!this.state.active || !this.state.recipe) return; this.state.progress = updateProgress(this.state.recipe, this.state.progress, payload || {}); await this.saveState(); this.sendSocketNotification("STATE", this.publicState()); } catch (error) { console.error(`[MMM-NextcloudCookbook] ${error.message}`); this.sendSocketNotification("STATE", this.publicState()); } }, async updateTheme(theme) { try { if (!["light", "dark"].includes(theme)) throw new Error("theme must be light or dark"); this.state.theme = theme; await this.saveState(); this.sendSocketNotification("STATE", this.publicState()); } catch (error) { console.error(`[MMM-NextcloudCookbook] ${error.message}`); this.sendSocketNotification("STATE", this.publicState()); } } });