Files
MMM-NextcloudCookbook/node_helper.js
T

315 lines
12 KiB
JavaScript

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());
}
}
});