199 lines
7.0 KiB
JavaScript
199 lines
7.0 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");
|
|
|
|
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 = 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);
|
|
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
|
|
};
|
|
}
|
|
});
|