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/"); 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: "" }); 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;