This commit is contained in:
seb
2026-10-10 06:39:32 +02:00
parent 7fb7435779
commit 188b8efe09
7 changed files with 422 additions and 2 deletions

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

88
src/tools/applet.js Normal file
View File

@@ -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;
}

View File

@@ -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<Array<string>>} 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<string>} 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<string>} 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;
}