mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 10:26:25 +08:00
feat: bases
Add the bases feature: formula engine package, grid/table UI, and the base-embed editor extension, with supporting client and server changes.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { register } from "./registry";
|
||||
|
||||
register({
|
||||
name: "toNumber", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "number",
|
||||
eval: ([v]) => {
|
||||
if (v == null) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
},
|
||||
doc: "Parses the value as a number, or null.", category: "coercion",
|
||||
});
|
||||
register({
|
||||
name: "toString", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "string",
|
||||
eval: ([v]) => v == null ? "" : String(v),
|
||||
doc: "Converts the value to a string.", category: "coercion",
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { register } from "./registry";
|
||||
import { makeErrorCell } from "../error";
|
||||
|
||||
const toDate = (v: unknown): Date | null => {
|
||||
if (v == null) return null;
|
||||
const d = new Date(String(v));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
|
||||
register({
|
||||
name: "now", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
|
||||
eval: () => new Date().toISOString(),
|
||||
doc: "Current timestamp.", category: "date",
|
||||
});
|
||||
register({
|
||||
name: "today", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
|
||||
eval: () => {
|
||||
const d = new Date(); d.setUTCHours(0, 0, 0, 0); return d.toISOString();
|
||||
},
|
||||
doc: "Midnight UTC of today.", category: "date",
|
||||
});
|
||||
register({
|
||||
name: "dateAdd", arity: { min: 3, max: 3 }, paramTypes: ["date", "number", "string"], returnType: "date",
|
||||
eval: ([base, amt, unit]) => {
|
||||
const d = toDate(base);
|
||||
if (!d) return makeErrorCell("DATE_INVALID", "invalid date");
|
||||
const n = Number(amt);
|
||||
const u = String(unit);
|
||||
const r = new Date(d);
|
||||
if (u === "days") r.setUTCDate(r.getUTCDate() + n);
|
||||
else if (u === "hours") r.setUTCHours(r.getUTCHours() + n);
|
||||
else if (u === "minutes") r.setUTCMinutes(r.getUTCMinutes() + n);
|
||||
else if (u === "months") r.setUTCMonth(r.getUTCMonth() + n);
|
||||
else if (u === "years") r.setUTCFullYear(r.getUTCFullYear() + n);
|
||||
else return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
|
||||
return r.toISOString();
|
||||
},
|
||||
doc: "Adds a duration to a date. Units: days, hours, minutes, months, years.", category: "date",
|
||||
});
|
||||
register({
|
||||
name: "dateBetween", arity: { min: 3, max: 3 }, paramTypes: ["date", "date", "string"], returnType: "number",
|
||||
eval: ([a, b, unit]) => {
|
||||
const da = toDate(a), db = toDate(b);
|
||||
if (!da || !db) return makeErrorCell("DATE_INVALID", "invalid date");
|
||||
const ms = db.getTime() - da.getTime();
|
||||
const u = String(unit);
|
||||
if (u === "days") return Math.floor(ms / 86_400_000);
|
||||
if (u === "hours") return Math.floor(ms / 3_600_000);
|
||||
if (u === "minutes") return Math.floor(ms / 60_000);
|
||||
return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
|
||||
},
|
||||
doc: "Difference between two dates in a given unit.", category: "date",
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import "./logic";
|
||||
import "./math";
|
||||
import "./string";
|
||||
import "./date";
|
||||
import "./coercion";
|
||||
export { registry, register } from "./registry";
|
||||
export type { FormulaFn } from "./registry";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { register } from "./registry";
|
||||
|
||||
register({
|
||||
name: "empty",
|
||||
arity: { min: 1, max: 1 },
|
||||
paramTypes: "any",
|
||||
returnType: "boolean",
|
||||
eval: ([v]) => v == null || v === "" || (typeof v === "object" && v !== null && "__err" in v),
|
||||
doc: "Returns true if the value is null or empty string or an error.",
|
||||
category: "logic",
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { register } from "./registry";
|
||||
import { makeErrorCell } from "../error";
|
||||
import type { Value } from "../types";
|
||||
|
||||
const num = (v: unknown): number | null => v == null ? null : Number(v);
|
||||
|
||||
register({
|
||||
name: "round", arity: { min: 1, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([v, places]) => {
|
||||
const n = num(v);
|
||||
if (n == null) return null;
|
||||
const p = places == null ? 0 : Math.trunc(Number(places));
|
||||
const factor = Math.pow(10, p);
|
||||
return Math.round(n * factor) / factor;
|
||||
},
|
||||
doc: "Rounds to the nearest integer, or to `places` decimals if given.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "floor", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
|
||||
eval: ([v]) => { const n = num(v); return n == null ? null : Math.floor(n); },
|
||||
doc: "Rounds down.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "ceil", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
|
||||
eval: ([v]) => { const n = num(v); return n == null ? null : Math.ceil(n); },
|
||||
doc: "Rounds up.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "abs", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
|
||||
eval: ([v]) => { const n = num(v); return n == null ? null : Math.abs(n); },
|
||||
doc: "Absolute value.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "min", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: (args) => {
|
||||
const nums = args.map(num).filter((n): n is number => n != null);
|
||||
return nums.length ? Math.min(...nums) : null;
|
||||
},
|
||||
doc: "Minimum of the arguments.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "max", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: (args) => {
|
||||
const nums = args.map(num).filter((n): n is number => n != null);
|
||||
return nums.length ? Math.max(...nums) : null;
|
||||
},
|
||||
doc: "Maximum of the arguments.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "mod", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
if (na == null || nb == null) return null;
|
||||
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
|
||||
return na % nb;
|
||||
},
|
||||
doc: "Remainder after division.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "add", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
return na == null || nb == null ? null : na + nb;
|
||||
},
|
||||
doc: "Sum of two numbers.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "subtract", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
return na == null || nb == null ? null : na - nb;
|
||||
},
|
||||
doc: "Difference of two numbers.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "multiply", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
return na == null || nb == null ? null : na * nb;
|
||||
},
|
||||
doc: "Product of two numbers.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "divide", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
if (na == null || nb == null) return null;
|
||||
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
|
||||
return na / nb;
|
||||
},
|
||||
doc: "Quotient of two numbers.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "pow", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
return na == null || nb == null ? null : Math.pow(na, nb);
|
||||
},
|
||||
doc: "Base raised to an exponent.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "sqrt", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
|
||||
eval: ([v]) => {
|
||||
const n = num(v);
|
||||
if (n == null) return null;
|
||||
if (n < 0) return makeErrorCell("TYPE_MISMATCH", "sqrt of negative number");
|
||||
return Math.sqrt(n);
|
||||
},
|
||||
doc: "Positive square root.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "sum", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: (args) => {
|
||||
// Null propagates as 0 so `sum(prop("A"), prop("B"))` still works when
|
||||
// some cells are empty — matches Airtable/Notion semantics.
|
||||
let total = 0;
|
||||
for (const v of args) {
|
||||
const n = num(v);
|
||||
if (n != null && Number.isFinite(n)) total += n;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
doc: "Sum of the arguments.", category: "math",
|
||||
});
|
||||
const meanEval = (args: Value[]): Value => {
|
||||
const nums: number[] = [];
|
||||
for (const v of args) {
|
||||
const n = num(v);
|
||||
if (n != null && Number.isFinite(n)) nums.push(n);
|
||||
}
|
||||
if (nums.length === 0) return null;
|
||||
return nums.reduce((a, b) => a + b, 0) / nums.length;
|
||||
};
|
||||
register({
|
||||
name: "mean", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: meanEval,
|
||||
doc: "Arithmetic average of the arguments.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "average", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: meanEval,
|
||||
doc: "Arithmetic average of the arguments (alias of mean).", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "median", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: (args) => {
|
||||
const nums: number[] = [];
|
||||
for (const v of args) {
|
||||
const n = num(v);
|
||||
if (n != null && Number.isFinite(n)) nums.push(n);
|
||||
}
|
||||
if (nums.length === 0) return null;
|
||||
nums.sort((a, b) => a - b);
|
||||
const mid = Math.floor(nums.length / 2);
|
||||
return nums.length % 2 === 0
|
||||
? (nums[mid - 1] + nums[mid]) / 2
|
||||
: nums[mid];
|
||||
},
|
||||
doc: "Middle value of the arguments.", category: "math",
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FormulaResultType, Value, EvalContext } from "../types";
|
||||
|
||||
export type FormulaFn = {
|
||||
name: string;
|
||||
arity: { min: number; max: number | null };
|
||||
paramTypes: FormulaResultType[] | "any" | "variadic-any";
|
||||
returnType: FormulaResultType | ((argTypes: FormulaResultType[]) => FormulaResultType);
|
||||
eval: (args: Value[], ctx: EvalContext) => Value;
|
||||
doc: string;
|
||||
category: "logic" | "math" | "string" | "date" | "coercion";
|
||||
};
|
||||
|
||||
export const registry: Map<string, FormulaFn> = new Map();
|
||||
|
||||
export function register(fn: FormulaFn): void {
|
||||
// Functions are looked up case-insensitively (see eval/typecheck), so the
|
||||
// registry is keyed by the lowercased name. fn.name keeps its canonical
|
||||
// casing for display in the function picker and `format()`.
|
||||
const key = fn.name.toLowerCase();
|
||||
if (registry.has(key)) {
|
||||
throw new Error(`Duplicate formula function: ${fn.name}`);
|
||||
}
|
||||
registry.set(key, fn);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { register } from "./registry";
|
||||
|
||||
const s = (v: unknown): string => v == null ? "" : String(v);
|
||||
|
||||
register({
|
||||
name: "concat", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "string",
|
||||
eval: (args) => args.map(s).join(""),
|
||||
doc: "Concatenates strings.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "length", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "number",
|
||||
eval: ([v]) => s(v).length,
|
||||
doc: "Length of a string.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "contains", arity: { min: 2, max: 2 }, paramTypes: ["string", "string"], returnType: "boolean",
|
||||
eval: ([a, b]) => s(a).includes(s(b)),
|
||||
doc: "Returns true if the first string contains the second.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "lower", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
|
||||
eval: ([v]) => s(v).toLowerCase(),
|
||||
doc: "Lowercases the string.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "upper", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
|
||||
eval: ([v]) => s(v).toUpperCase(),
|
||||
doc: "Uppercases the string.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "trim", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
|
||||
eval: ([v]) => s(v).trim(),
|
||||
doc: "Strips whitespace from both ends.", category: "string",
|
||||
});
|
||||
Reference in New Issue
Block a user