From 188b8efe096b0746161632aceb5860d9ad32b8ee Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 10 Oct 2026 06:39:32 +0200 Subject: [PATCH] u --- .../article-catalogue.applet.js | 55 ++++++++++++ src/defaultApplets/article-lookup.applet.js | 43 +++++++++ .../category-inventory.applet.js | 80 +++++++++++++++++ .../delivery-register.applet.js | 63 +++++++++++++ .../order-revenue-by-month.applet.js | 50 +++++++++++ src/tools/applet.js | 88 +++++++++++++++++++ src/tools/vfs.js | 45 +++++++++- 7 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 src/defaultApplets/article-catalogue.applet.js create mode 100644 src/defaultApplets/article-lookup.applet.js create mode 100644 src/defaultApplets/category-inventory.applet.js create mode 100644 src/defaultApplets/delivery-register.applet.js create mode 100644 src/defaultApplets/order-revenue-by-month.applet.js create mode 100644 src/tools/applet.js diff --git a/src/defaultApplets/article-catalogue.applet.js b/src/defaultApplets/article-catalogue.applet.js new file mode 100644 index 0000000..92756b7 --- /dev/null +++ b/src/defaultApplets/article-catalogue.applet.js @@ -0,0 +1,55 @@ +// @applet-input-schema {"type":"object","properties":{"query":{"type":"string","title":"Article name contains","default":"","description":"Case-insensitive substring match on the article name. Leave empty to include every article."},"limit":{"type":"integer","title":"Max rows","default":200,"minimum":1,"maximum":1000},"onlyTracked":{"type":"boolean","title":"Only stock-tracked articles","default":false,"description":"When true, articles with track_stock=false are omitted."}}} +// @applet-output-schema sku:string;name:string;net:number;taxRate:number;track_stock:boolean;categoryId:integer +// +// Article catalogue — articles with their net price, resolved by walking the category tree. +// +// Demonstrates: joining two endpoints (the tree's category ids and each category's articles) and +// flattening the result into a single table. Useful as a base for a price list or a stock export. +// Endpoints: GET /category/tree, GET /category/{id}/articles. + +const BATCH = 8; + +module.exports = async function (applet_input) { + const query = String(applet_input.query || '').trim().toLowerCase(); + const limit = Math.max(1, Number(applet_input.limit) || 200); + const onlyTracked = applet_input.onlyTracked === true; + + const treeRes = await fetch('/category/tree'); + if (!treeRes.ok) throw new Error(`GET /category/tree failed: ${treeRes.status}`); + const { tree = [] } = await treeRes.json(); + + const rows = []; + for (let i = 0; i < tree.length; i += BATCH) { + const batch = tree.slice(i, i + BATCH); + const results = await Promise.all( + batch.map(async (node) => { + const res = await fetch(`/category/${node.id}/articles`); + if (!res.ok) throw new Error(`GET /category/${node.id}/articles failed: ${res.status}`); + const { articles = [] } = await res.json(); + return articles.map((article) => ({ + sku: article.sku, + name: article.name || '', + // net/taxRate are null when a category article has no price in this customer group. + net: article.net == null ? '' : article.net, + taxRate: article.taxRate == null ? '' : article.taxRate, + track_stock: Boolean(article.track_stock), + categoryId: node.id, + })); + }) + ); + rows.push(...results.flat()); + } + + // Root-category articles are returned by more than one endpoint, so de-duplicate by SKU. + const bySku = new Map(); + for (const row of rows) bySku.set(row.sku, row); + + const filtered = [...bySku.values()] + .filter((row) => !query || String(row.name).toLowerCase().includes(query)) + .filter((row) => !onlyTracked || row.track_stock) + .sort((a, b) => String(a.sku).localeCompare(String(b.sku))) + .slice(0, limit); + + console.log(`${filtered.length} article(s) shown (${bySku.size} unique SKU(s) scanned).`); + return filtered; +}; diff --git a/src/defaultApplets/article-lookup.applet.js b/src/defaultApplets/article-lookup.applet.js new file mode 100644 index 0000000..8005504 --- /dev/null +++ b/src/defaultApplets/article-lookup.applet.js @@ -0,0 +1,43 @@ +// @applet-input-schema {"type":"object","properties":{"sku":{"type":"string","title":"Article SKU","default":"","description":"Exact SKU to inspect. Leave empty for a single-row manifest summary."}}} +// @applet-output-schema key:string;value:string +// +// Article lookup — the raw `GET /article/{sku}` payload as a key/value sheet. +// +// Demonstrates: returning a single object (one row per key) and embedding `console.log` for a +// human-readable summary alongside the machine-readable table. +// Endpoints: GET /article/{sku}. +// +// Tip: this applet is small on purpose — copy it as a starting point for your own lookups. + +module.exports = async function (applet_input) { + const sku = String(applet_input.sku || '').trim(); + if (!sku) { + throw new Error('Please provide a SKU (e.g. "42000009").'); + } + + const res = await fetch(`/article/${encodeURIComponent(sku)}`); + if (res.status === 404) throw new Error(`Article "${sku}" was not found.`); + if (!res.ok) throw new Error(`GET /article/${sku} failed: ${res.status}`); + const { article } = await res.json(); + + const price = (article.prices || [])[0]; + const brand = article.brand?.name || '(no brand)'; + const categoryIds = (article.categoryIds || []).join(', ') || '(none)'; + console.log(`${sku}: ${article.name} — brand ${brand}, ${article.eans?.length || 0} EAN(s).`); + + const rows = [ + { key: 'sku', value: article.sku }, + { key: 'name', value: article.name }, + { key: 'brand', value: brand }, + { key: 'categoryIds', value: categoryIds }, + { key: 'unit', value: article.unit?.si ?? '' }, + { key: 'unitamount', value: article.unitamount ?? '' }, + { key: 'weight', value: article.weight ?? '' }, + { key: 'track_stock', value: article.track_stock }, + { key: 'net', value: price?.net ?? '' }, + { key: 'taxRate', value: price?.taxRate ?? '' }, + { key: 'eans', value: (article.eans || []).join(', ') }, + { key: 'suppliers', value: (article.suppliers || []).length }, + ]; + return rows; +}; diff --git a/src/defaultApplets/category-inventory.applet.js b/src/defaultApplets/category-inventory.applet.js new file mode 100644 index 0000000..c0acd2c --- /dev/null +++ b/src/defaultApplets/category-inventory.applet.js @@ -0,0 +1,80 @@ +// @applet-input-schema {"type":"object","properties":{"query":{"type":"string","title":"Category name contains","default":"","description":"Case-insensitive substring match on the category name. Leave empty to include every category."},"limit":{"type":"integer","title":"Max rows","default":100,"minimum":1,"maximum":500},"sortBy":{"type":"string","title":"Sort by","default":"articles","enum":["articles","name","depth"],"description":"articles = most articles first, name = A–Z, depth = tree depth then name."}}} +// @applet-output-schema id:integer;name:string;depth:integer;articles:integer;children:integer +// +// Category inventory — every category with its article count and child count. +// +// Demonstrates: reading the tree once, doing the flattening yourself, and then issuing one +// bounded request per category. A category's articles are NOT part of /category/tree (that +// response only carries the direct articles of the company root), so the per-category endpoint +// is the correct source. +// Endpoints: GET /category/tree, GET /category/{id}/articles. + +const BATCH = 8; + +module.exports = async function (applet_input) { + const query = String(applet_input.query || '').trim().toLowerCase(); + const limit = Math.max(1, Number(applet_input.limit) || 100); + const sortBy = ['articles', 'name', 'depth'].includes(applet_input.sortBy) ? applet_input.sortBy : 'articles'; + + // 1. One call gives the whole tree (id, parentId, sort, name). + const treeRes = await fetch('/category/tree'); + if (!treeRes.ok) throw new Error(`GET /category/tree failed: ${treeRes.status}`); + const { tree = [] } = await treeRes.json(); + + // 2. Flatten depth-first so `depth` and `children` are known without extra requests. + const byId = new Map(tree.map((node) => [node.id, node])); + const childrenOf = new Map(); + for (const node of tree) { + if (node.parentId == null) continue; + if (!childrenOf.has(node.parentId)) childrenOf.set(node.parentId, []); + childrenOf.get(node.parentId).push(node); + } + // A node whose parent is not in the list is an outermost node of this company's tree. + const roots = tree.filter((node) => node.parentId == null || !byId.has(node.parentId)); + + const flat = []; + function visit(node, depth) { + flat.push({ node, depth }); + for (const child of childrenOf.get(node.id) || []) visit(child, depth + 1); + } + for (const root of roots) visit(root, 0); + + const selected = flat.filter( + (entry) => !query || String(entry.node.name || '').toLowerCase().includes(query) + ); + + // 3. Count articles per selected category in small parallel batches. + const counted = []; + for (let i = 0; i < selected.length; i += BATCH) { + const batch = selected.slice(i, i + BATCH); + const results = await Promise.all( + batch.map(async ({ node, depth }) => { + const res = await fetch(`/category/${node.id}/articles`); + if (!res.ok) throw new Error(`GET /category/${node.id}/articles failed: ${res.status}`); + const { articles = [] } = await res.json(); + return { + id: node.id, + name: node.name || '(unnamed)', + depth, + articles: articles.length, + children: (childrenOf.get(node.id) || []).length, + }; + }) + ); + counted.push(...results); + } + + // 4. Sort and cap. + if (sortBy === 'name') { + counted.sort((a, b) => a.name.localeCompare(b.name)); + } else if (sortBy === 'depth') { + counted.sort((a, b) => a.depth - b.depth || a.name.localeCompare(b.name)); + } else { + counted.sort((a, b) => b.articles - a.articles || a.name.localeCompare(b.name)); + } + const rows = counted.slice(0, limit); + + const totalArticles = rows.reduce((sum, r) => sum + r.articles, 0); + console.log(`${rows.length} of ${flat.length} category node(s), ${totalArticles} article(s) in them.`); + return rows; +}; diff --git a/src/defaultApplets/delivery-register.applet.js b/src/defaultApplets/delivery-register.applet.js new file mode 100644 index 0000000..f72885e --- /dev/null +++ b/src/defaultApplets/delivery-register.applet.js @@ -0,0 +1,63 @@ +// @applet-input-schema {"type":"object","properties":{"dateFrom":{"type":"string","title":"From date (YYYY-MM-DD)","default":"","description":"Only include documents dated on or after this day. Leave empty for no lower bound."},"dateTo":{"type":"string","title":"To date (YYYY-MM-DD)","default":"","description":"Only include documents dated on or before this day. Leave empty for no upper bound."},"limit":{"type":"integer","title":"Max rows","default":100,"minimum":1,"maximum":500}}} +// @applet-output-schema number:string;date:string;supplier:string;lines:integer;net:number +// +// Goods receipt register — deliveries with their line-item count and net total. +// +// Demonstrates: paging a list endpoint AND fetching the per-document detail in parallel, plus +// filtering by a date window. `GET /delivery` returns headers, so the totals require the detail +// call; the detail fetches are batched to avoid one request per document at a time. +// Endpoints: GET /delivery (paged), GET /delivery/{id}. + +const BATCH = 10; + +module.exports = async function (applet_input) { + const from = String(applet_input.dateFrom || '').trim(); + const to = String(applet_input.dateTo || '').trim(); + const maxRows = Math.max(1, Number(applet_input.limit) || 100); + + // 1. Page through the delivery headers. + const deliveries = []; + let offset = 0; + for (;;) { + const res = await fetch(`/delivery?offset=${offset}&limit=100`); + if (!res.ok) throw new Error(`GET /delivery failed: ${res.status}`); + const data = await res.json(); + const page = data.deliveries || []; + deliveries.push(...page); + offset += page.length; + if (page.length === 0 || offset >= (data.total ?? deliveries.length)) break; + } + + // 2. Date window filter (ISO strings sort lexicographically, so string compare is safe here). + const inWindow = deliveries + .filter((d) => (!from || d.date >= from) && (!to || d.date <= to)) + .slice(0, maxRows); + + // 3. Fetch details in small parallel batches to bound the burst on the server. + const rows = []; + for (let i = 0; i < inWindow.length; i += BATCH) { + const batch = inWindow.slice(i, i + BATCH); + const details = await Promise.all( + batch.map(async (d) => { + const res = await fetch(`/delivery/${d.id}`); + if (!res.ok) throw new Error(`GET /delivery/${d.id} failed: ${res.status}`); + const { delivery } = await res.json(); + const lines = delivery.lines || []; + const net = lines.reduce((sum, line) => sum + (line.amount || 0) * (line.net || 0), 0); + return { + number: delivery.number, + date: String(delivery.date || '').slice(0, 10), + supplier: d.supplierId == null ? '' : String(d.supplierId), + lines: lines.length, + net: Math.round(net * 100) / 100, + }; + }) + ); + rows.push(...details); + } + + // 4. Oldest first reads more naturally for a register. + rows.sort((a, b) => a.date.localeCompare(b.date) || a.number.localeCompare(b.number)); + console.log(`${rows.length} delivery document(s), ${rows.reduce((n, r) => n + r.lines, 0)} line(s).`); + return rows; +}; diff --git a/src/defaultApplets/order-revenue-by-month.applet.js b/src/defaultApplets/order-revenue-by-month.applet.js new file mode 100644 index 0000000..0a834e5 --- /dev/null +++ b/src/defaultApplets/order-revenue-by-month.applet.js @@ -0,0 +1,50 @@ +// @applet-input-schema {"type":"object","properties":{"month":{"type":"string","title":"Month (YYYY-MM)","default":"","description":"Only include orders placed in this month. Leave empty for all months."},"limit":{"type":"integer","title":"Max rows","default":50,"minimum":1,"maximum":500}}} +// @applet-output-schema month:string;orders:integer;positions:integer;net:number +// +// Order revenue by month — orders grouped per calendar month. +// +// Demonstrates: paging, then aggregating into a Map keyed by `YYYY-MM` and rendering a small +// report. `GET /order` list rows already carry their line items, so no detail call is needed. +// Endpoints: GET /order (paged). + +module.exports = async function (applet_input) { + const only = String(applet_input.month || '').trim(); + const maxRows = Math.max(1, Number(applet_input.limit) || 50); + + // 1. Page through orders. + const orders = []; + let offset = 0; + for (;;) { + const res = await fetch(`/order?offset=${offset}&limit=100`); + if (!res.ok) throw new Error(`GET /order failed: ${res.status}`); + const data = await res.json(); + const page = data.orders || []; + orders.push(...page); + offset += page.length; + if (page.length === 0 || offset >= (data.total ?? orders.length)) break; + } + + // 2. Aggregate per calendar month. + const buckets = new Map(); + for (const order of orders) { + const month = String(order.date || '').slice(0, 7); + if (!month) continue; + if (only && month !== only) continue; + const bucket = buckets.get(month) || { month, orders: 0, positions: 0, net: 0 }; + bucket.orders += 1; + for (const line of order.lines || []) { + bucket.positions += 1; + bucket.net += (line.amount || 0) * (line.net || 0); + } + buckets.set(month, bucket); + } + + const rows = [...buckets.values()] + .map((b) => ({ ...b, net: Math.round(b.net * 100) / 100 })) + .sort((a, b) => a.month.localeCompare(b.month)) + .slice(-maxRows); + + const totalNet = rows.reduce((sum, r) => sum + r.net, 0); + console.log(`${rows.length} month(s), ${rows.reduce((n, r) => n + r.orders, 0)} order(s), net ${totalNet.toFixed(2)}.`); + return rows; +}; diff --git a/src/tools/applet.js b/src/tools/applet.js new file mode 100644 index 0000000..a076b29 --- /dev/null +++ b/src/tools/applet.js @@ -0,0 +1,88 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * applet.js — the default `.applet.js` example files. + * + * Default applets are the executable counterpart to default SKILL files: small, real examples + * seeded into every new company's VFS so an operator can open one, read it, and adapt it instead + * of starting from a blank editor. They follow the same two rules as skills: + * + * 1. **Flat only.** The name is a bare file name at the VFS root — no path separators — so the + * whole set stays enumerable and addressable by name. + * 2. **Version-gated, never overwritten.** A seeded applet is written only when the target file + * does not exist yet. Once an operator edits one, their version is never clobbered by a + * later run — bump {@link DEFAULT_APPLETS_VERSION} and the file name to ship a new example. + */ + +/** Resolved directory holding the shipped example applets. */ +export const DEFAULT_APPLETS_DIR = fs.existsSync(path.resolve(__dirname, '../defaultApplets')) + ? path.resolve(__dirname, '../defaultApplets') + : path.resolve(process.cwd(), 'defaultApplets'); + +/** Canonical extension for applet files, including the leading dot. */ +export const APPLET_EXT = '.applet.js'; + +/** + * Bumped whenever the shipped examples change materially. Embedded in the seeded file name so a + * rewritten example arrives as a NEW file rather than overwriting one an operator may have + * customised. Keep `defaultApplets/` file names in sync with this prefix. + */ +export const DEFAULT_APPLETS_VERSION = 'v1'; + +/** + * Checks whether a bare file name is an applet file name. + * Rejects anything containing a path separator — applets are flat by contract, like skills. + * @param {string} name + * @returns {boolean} + */ +export function isAppletFileName(name) { + if (typeof name !== 'string') return false; + const clean = name.trim(); + if (!clean) return false; + if (clean.includes('/') || clean.includes('\\')) return false; + if (!clean.toLowerCase().endsWith(APPLET_EXT)) return false; + return clean.slice(0, -APPLET_EXT.length).trim().length > 0; +} + +/** + * Checks whether a VFS path points at an applet file, tolerating leading `./` or `/`. + * @param {string} p + * @returns {boolean} + */ +export function isAppletPath(p) { + if (typeof p !== 'string') return false; + return isAppletFileName(p.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '')); +} + +/** + * Reads every shipped example applet from {@link DEFAULT_APPLETS_DIR}. + * @returns {Array<{ name: string, content: string }>} + */ +export function getDefaultApplets() { + if (!fs.existsSync(DEFAULT_APPLETS_DIR)) return []; + + let dirents; + try { + dirents = fs.readdirSync(DEFAULT_APPLETS_DIR, { withFileTypes: true }); + } catch { + return []; + } + + const out = []; + for (const dirent of dirents) { + if (!dirent.isFile() || !isAppletFileName(dirent.name)) continue; + const absPath = path.join(DEFAULT_APPLETS_DIR, dirent.name); + try { + out.push({ name: dirent.name, content: fs.readFileSync(absPath, 'utf8') }); + } catch (err) { + console.warn(`[applet] failed to read default applet "${dirent.name}":`, err.message); + } + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} diff --git a/src/tools/vfs.js b/src/tools/vfs.js index 5599862..9bc2065 100644 --- a/src/tools/vfs.js +++ b/src/tools/vfs.js @@ -3,6 +3,7 @@ import path from 'path'; import { getStore } from '../db/store.js'; import env from '../config/env.js'; import { getDefaultSkills } from './skill.js'; +import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.js'; const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/; const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']); @@ -164,7 +165,39 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR } /** - * Initializes the VFS for the selected company with default SKILL files. + * Loads the shipped example `.applet.js` files into the selected company's VFS. + * + * Version-gated: each example is written under a `v1-` prefixed name and only when that exact + * file is absent, so an operator's edits survive and a new example revision ships as a new file. + * Must stay in step with {@link loadDefaultSkills}, which runs alongside it. + * + * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx + * @returns {Promise>} List of seeded applet file names + */ +export async function loadDefaultApplets({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) { + const store = passedStore || await getStore(); + const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId); + fs.mkdirSync(vfsRoot, { recursive: true }); + + const seeded = []; + for (const applet of getDefaultApplets()) { + const name = `${DEFAULT_APPLETS_VERSION}-${applet.name}`; + try { + const diskPath = path.resolve(vfsRoot, name); + if (!fs.existsSync(diskPath)) { + fs.writeFileSync(diskPath, applet.content, 'utf8'); + } + seeded.push(name); + } catch (err) { + console.error(`[vfs] failed to write default applet "${name}" to disk:`, err.message); + } + } + + return seeded; +} + +/** + * Initializes the VFS for the selected company with default SKILL files and example applets. * * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx * @returns {Promise} vfsRoot directory path @@ -175,11 +208,13 @@ export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot fs.mkdirSync(vfsRoot, { recursive: true }); await loadDefaultSkills({ googleAuthId, store, vfsRoot }); + await loadDefaultApplets({ googleAuthId, store, vfsRoot }); return vfsRoot; } /** - * Ensure the local directory for this company's VFS exists and default skills are loaded. + * Ensure the local directory for this company's VFS exists and the default skills and example + * applets are loaded. * * @param {{ googleAuthId: number }} ctx * @returns {Promise} vfsRoot directory path @@ -195,6 +230,12 @@ export async function prepareVfs({ googleAuthId }) { console.warn('[vfs] prepare warning:', err.message); } + try { + await loadDefaultApplets({ googleAuthId, store, vfsRoot }); + } catch (err) { + console.warn('[vfs] prepare applet warning:', err.message); + } + return vfsRoot; }