u
This commit is contained in:
55
src/defaultApplets/article-catalogue.applet.js
Normal file
55
src/defaultApplets/article-catalogue.applet.js
Normal 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;
|
||||
};
|
||||
43
src/defaultApplets/article-lookup.applet.js
Normal file
43
src/defaultApplets/article-lookup.applet.js
Normal 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;
|
||||
};
|
||||
80
src/defaultApplets/category-inventory.applet.js
Normal file
80
src/defaultApplets/category-inventory.applet.js
Normal 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;
|
||||
};
|
||||
63
src/defaultApplets/delivery-register.applet.js
Normal file
63
src/defaultApplets/delivery-register.applet.js
Normal 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;
|
||||
};
|
||||
50
src/defaultApplets/order-revenue-by-month.applet.js
Normal file
50
src/defaultApplets/order-revenue-by-month.applet.js
Normal 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
88
src/tools/applet.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user