Add Rails backend: accounts via magic link, server documents, sharing

The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+469
View File
@@ -0,0 +1,469 @@
/**
* Built-in functions: a plain table { NAME -> fn(values) }.
*
* By the time a function runs, the evaluator has already:
* - resolved every range argument into a flat list of cell values,
* - flattened scalar arguments into that same list,
* - checked for errors among the values (an error short-circuits and is
* returned instead of calling the function).
*
* So a function only ever sees `(number | string | null)[]`. Each function
* decides for itself how to treat text and empty cells. Per spec:
* - empty cell -> ignored by every aggregate, never counted by COUNT
* - text -> ignored by the numeric aggregates
*/
import { FormulaError, isError, type CellValue } from './types';
import {
dateToSerial,
timeSerial,
todaySerial,
nowSerial,
strictNum,
serialParts,
isoWeekNumber,
endOfMonthSerial,
addMonthsSerial,
parseDateTimeLiteral,
type SerialParts,
} from './dates';
export type FunctionFn = (values: CellValue[]) => CellValue;
/** Keep only real numbers (finite). */
function nums(values: CellValue[]): number[] {
const out: number[] = [];
for (const v of values) {
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
}
return out;
}
/** The one truthy convention used everywhere in this engine a condition is
* needed (IF's condition -- both the formula function and its real
* short-circuit implementation in evaluator.ts's evalIf() --, a "Mit
* Schleife" block's IF statement, and AND/OR/NOT below): a number is
* truthy unless it's exactly `0`; text is truthy unless empty; an empty
* cell (`null`) is never truthy. Exported so evaluator.ts can reuse the
* exact same rule instead of duplicating it. */
export function isTruthy(v: CellValue): boolean {
return typeof v === 'number' ? v !== 0 : v !== null && v !== '';
}
/** Check the argument count and coerce every argument to a number (see
* `strictNum`) -- the shared prologue of the numeric date functions. An
* optional argument left out (or an empty `;;` slot) comes back as
* `undefined`. */
function numArgs(
name: string,
values: CellValue[],
min: number,
max: number,
): [number, ...(number | undefined)[]] | FormulaError {
if (values.length < min || values.length > max) {
const range = min === max ? `${min}` : `${min}–${max}`;
return new FormulaError('#ERR', `${name} erwartet ${range} Argument${max === 1 ? '' : 'e'}`);
}
const out: Array<number | undefined> = [];
for (let i = 0; i < values.length; i++) {
if (i >= min && values[i] === null) {
out.push(undefined);
continue;
}
const n = strictNum(values[i]);
if (isError(n)) return n;
out.push(n);
}
return out as [number, ...(number | undefined)[]];
}
/** YEAR/MONTH/DAY/HOUR/MINUTE/SECOND: one serial in, one field out. */
function part(name: string, pick: (p: SerialParts) => number): FunctionFn {
return (values) => {
const a = numArgs(name, values, 1, 1);
if (isError(a)) return a;
return pick(serialParts(a[0]));
};
}
function holidaySet(values: CellValue[]): Set<number> {
const out = new Set<number>();
for (const v of values) if (typeof v === 'number') out.add(serialParts(v).day);
return out;
}
function isWorkday(day: number, holidays: Set<number>): boolean {
return serialParts(day).weekday <= 5 && !holidays.has(day);
}
/** DATEVALUE/TIMEVALUE's single argument: a recognizable date/time text, or
* a number (already a serial). */
function textDateArg(name: string, values: CellValue[]): number | FormulaError {
if (values.length !== 1) return new FormulaError('#ERR', `${name} erwartet 1 Argument`);
const v = values[0];
if (typeof v === 'number') return v;
const hit = typeof v === 'string' ? parseDateTimeLiteral(v) : null;
if (!hit) return new FormulaError('#VALUE!', `${name}: '${v ?? ''}' ist kein Datum/keine Uhrzeit`);
return hit.serial;
}
export const FUNCTIONS: Record<string, FunctionFn> = {
SUM(values) {
return nums(values).reduce((a, b) => a + b, 0);
},
AVG(values) {
const n = nums(values);
if (n.length === 0) return new FormulaError('#DIV/0!', 'AVG of no numbers');
return n.reduce((a, b) => a + b, 0) / n.length;
},
MIN(values) {
const n = nums(values);
return n.length ? Math.min(...n) : 0;
},
MAX(values) {
const n = nums(values);
return n.length ? Math.max(...n) : 0;
},
COUNT(values) {
return nums(values).length;
},
COUNT2(values) {
return values.length;
},
// Plain text join, no separator (unlike Excel's TEXTJOIN). Same rendering
// as the `&` operator (toText() in evaluator.ts): a number prints plain
// (no locale formatting), an empty cell contributes ''.
CONCAT(values) {
return values.map((v) => (v === null ? '' : String(v))).join('');
},
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
// division when B1=0). This entry only exists so IF is a registered
// built-in for hasFunction()'s sake (the store's custom-function name
// check then correctly refuses to let a user shadow it) and as a
// defensive, best-effort fallback if this were ever reached directly
// (values are already-eagerly-computed here, so it can't short-circuit --
// see evaluator.ts's evalIf() for the real implementation).
IF(values) {
if (values.length !== 2 && values.length !== 3) {
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
}
const [cond, thenValue, elseValue] = values;
if (isError(cond)) return cond;
if (isTruthy(cond)) return thenValue;
return values.length === 3 ? elseValue : 0;
},
// Ordinary (non-short-circuit) functions -- like real Excel, EVERY
// argument is evaluated regardless of the others (so e.g.
// =AND(1=2; 1/0) is still #DIV/0!, not FALSE, exactly as in Excel) --
// no special-casing in evaluator.ts needed, unlike IF/XLOOKUP. A range
// argument flattens into one truthy check per cell, same as Excel.
AND(values) {
if (values.length === 0) return new FormulaError('#ERR', 'AND expects at least 1 argument');
return values.every(isTruthy) ? 1 : 0;
},
OR(values) {
if (values.length === 0) return new FormulaError('#ERR', 'OR expects at least 1 argument');
return values.some(isTruthy) ? 1 : 0;
},
NOT(values) {
if (values.length !== 1) return new FormulaError('#ERR', 'NOT expects exactly 1 argument');
return isTruthy(values[0]) ? 0 : 1;
},
// Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one,
// TODAY/NOW read the current one. `null` (empty cell) arguments count as 0,
// same as everywhere else in this engine; a non-numeric text argument is
// `#VALUE!`.
DATE(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'DATE expects 3 arguments (Jahr; Monat; Tag)');
}
const y = strictNum(values[0]);
if (isError(y)) return y;
const mo = strictNum(values[1]);
if (isError(mo)) return mo;
const d = strictNum(values[2]);
if (isError(d)) return d;
return dateToSerial(y, mo, d);
},
TIME(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'TIME expects 3 arguments (Stunde; Minute; Sekunde)');
}
const h = strictNum(values[0]);
if (isError(h)) return h;
const mi = strictNum(values[1]);
if (isError(mi)) return mi;
const s = strictNum(values[2]);
if (isError(s)) return s;
return timeSerial(h, mi, s);
},
// Not "volatile": this engine only recalculates on precedent change, so a
// cell holding =TODAY()/=NOW() refreshes when THAT cell is next re-edited
// or recalculated, not spontaneously when the clock ticks over (documented
// limitation, see README.md's "Bekannte Grenzen").
TODAY(values) {
if (values.length !== 0) return new FormulaError('#ERR', 'TODAY erwartet keine Argumente');
return todaySerial();
},
NOW(values) {
if (values.length !== 0) return new FormulaError('#ERR', 'NOW erwartet keine Argumente');
return nowSerial();
},
// ---- date/time parts & calendar arithmetic ----------------------------
// All take date serials (see dates.ts). Where Excel has a US-centric
// default, the ISO 8601 convention is the default here: weeks start on
// Monday (WEEKDAY -> 1 = Monday), week numbers are ISO weeks.
YEAR: part('YEAR', (p) => p.year),
MONTH: part('MONTH', (p) => p.month),
DAY: part('DAY', (p) => p.date),
HOUR: part('HOUR', (p) => p.hour),
MINUTE: part('MINUTE', (p) => p.minute),
SECOND: part('SECOND', (p) => p.second),
// WEEKDAY(Datum; [Typ]) -- Typ as in Excel: 1 = Sunday 1 ... Saturday 7,
// 2 = Monday 1 ... Sunday 7 (ISO, the default here), 3 = Monday 0 ...
// Sunday 6.
WEEKDAY(values) {
const a = numArgs('WEEKDAY', values, 1, 2);
if (isError(a)) return a;
const iso = serialParts(a[0]).weekday;
switch (a[1] ?? 2) {
case 1:
return (iso % 7) + 1;
case 2:
return iso;
case 3:
return iso - 1;
default:
return new FormulaError('#VALUE!', 'WEEKDAY: Typ muss 1, 2 oder 3 sein');
}
},
ISOWEEKNUM(values) {
const a = numArgs('ISOWEEKNUM', values, 1, 1);
if (isError(a)) return a;
return isoWeekNumber(a[0]);
},
// WEEKNUM(Datum; [Typ]) -- ISO week by default (Typ 21, as in Excel);
// Typ 1 = weeks start Sunday, 2 = weeks start Monday, both counting the
// week containing 1 January as week 1 (Excel's US-style numbering).
WEEKNUM(values) {
const a = numArgs('WEEKNUM', values, 1, 2);
if (isError(a)) return a;
const type = a[1] ?? 21;
if (type === 21) return isoWeekNumber(a[0]);
if (type !== 1 && type !== 2) return new FormulaError('#VALUE!', 'WEEKNUM: Typ muss 1, 2 oder 21 sein');
const p = serialParts(a[0]);
const jan1 = Math.round(dateToSerial(p.year, 1, 1));
const jan1Iso = serialParts(jan1).weekday; // 1 = Monday
const offset = type === 2 ? jan1Iso - 1 : jan1Iso % 7; // days since week start
return Math.floor((p.day - jan1 + offset) / 7) + 1;
},
EDATE(values) {
const a = numArgs('EDATE', values, 2, 2);
if (isError(a)) return a;
return addMonthsSerial(a[0], Math.trunc(a[1] ?? 0));
},
EOMONTH(values) {
const a = numArgs('EOMONTH', values, 2, 2);
if (isError(a)) return a;
return endOfMonthSerial(a[0], Math.trunc(a[1] ?? 0));
},
// DAYS(Ende; Start) -- whole calendar days between two dates.
DAYS(values) {
const a = numArgs('DAYS', values, 2, 2);
if (isError(a)) return a;
return serialParts(a[0]).day - serialParts(a[1] ?? 0).day;
},
// DATEDIF(Start; Ende; Einheit) -- "Y", "M", "D" complete years/months/
// days, "MD"/"YM"/"YD" the remainder ignoring months+years/years.
DATEDIF(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'DATEDIF erwartet 3 Argumente (Start; Ende; Einheit)');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const e0 = strictNum(values[1]);
if (isError(e0)) return e0;
const unit = typeof values[2] === 'string' ? values[2].trim().toUpperCase() : '';
const s = serialParts(s0);
const e = serialParts(e0);
if (s.day > e.day) return new FormulaError('#VALUE!', 'DATEDIF: Start liegt nach Ende');
let months = (e.year - s.year) * 12 + (e.month - s.month);
if (e.date < s.date) months -= 1;
switch (unit) {
case 'D':
return e.day - s.day;
case 'M':
return months;
case 'Y':
return Math.floor(months / 12);
case 'YM':
return months % 12;
case 'MD':
return e.day - addMonthsSerial(s.day, months);
case 'YD': {
const years = Math.floor(months / 12);
return e.day - addMonthsSerial(s.day, years * 12);
}
default:
return new FormulaError('#VALUE!', 'DATEDIF: Einheit muss "Y", "M", "D", "MD", "YM" oder "YD" sein');
}
},
// NETWORKDAYS(Start; Ende; [Feiertage…]) -- Monday-Friday days in the
// span, both ends included, minus any listed holiday on a weekday.
NETWORKDAYS(values) {
if (values.length < 2) {
return new FormulaError('#ERR', 'NETWORKDAYS erwartet mindestens 2 Argumente (Start; Ende; [Feiertage])');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const e0 = strictNum(values[1]);
if (isError(e0)) return e0;
const holidays = holidaySet(values.slice(2));
const a = serialParts(s0).day;
const b = serialParts(e0).day;
const [lo, hi] = a <= b ? [a, b] : [b, a];
if (hi - lo > 366 * 1000) return new FormulaError('#VALUE!', 'NETWORKDAYS: Zeitraum zu groß');
let n = 0;
for (let d = lo; d <= hi; d++) if (isWorkday(d, holidays)) n++;
return a <= b ? n : -n;
},
// WORKDAY(Start; Tage; [Feiertage…]) -- the date `Tage` working days
// (Monday-Friday, not a holiday) after (or, negative, before) Start.
WORKDAY(values) {
if (values.length < 2) {
return new FormulaError('#ERR', 'WORKDAY erwartet mindestens 2 Argumente (Start; Tage; [Feiertage])');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const n0 = strictNum(values[1]);
if (isError(n0)) return n0;
let left = Math.trunc(Math.abs(n0));
if (left > 366 * 1000) return new FormulaError('#VALUE!', 'WORKDAY: zu viele Tage');
const step = n0 < 0 ? -1 : 1;
const holidays = holidaySet(values.slice(2));
let d = serialParts(s0).day;
while (left > 0) {
d += step;
if (isWorkday(d, holidays)) left--;
}
return d;
},
// DATEVALUE("2026-09-02") / TIMEVALUE("14:30") -- the date resp. time part
// of a text in any format a cell entry is recognized in (ISO, 02.09.2026,
// 14:30, …). A number (a cell already holding a date) passes through.
DATEVALUE(values) {
const v = textDateArg('DATEVALUE', values);
if (isError(v)) return v;
return serialParts(v).day;
},
TIMEVALUE(values) {
const v = textDateArg('TIMEVALUE', values);
if (isError(v)) return v;
const p = serialParts(v);
return (p.hour * 3600 + p.minute * 60 + p.second) / 86400;
},
// XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
// [Vergleichsmodus]; [Suchmodus]) -- like IF, evaluator.ts special-cases
// this name *before* ever reaching FUNCTIONS. Two reasons, not just one:
// 1. WennNichtGefunden must only be evaluated when actually needed
// (short-circuit, same reasoning as IF's branches).
// 2. By the time a function gets here, EVERY argument -- including
// Suchbereich and Rückgabebereich, both ranges -- has already been
// flattened into ONE combined array (see the module comment above).
// That destroys the boundary between the two ranges, so there is no
// way to even attempt a best-effort fallback here: unlike IF's
// 2-3 scalar arguments, XLOOKUP's array arguments can't be recovered
// from a flat `values` list of unknown split point. This entry exists
// purely so XLOOKUP is a registered built-in (hasFunction() /
// name-reservation) -- see evaluator.ts's evalXLookup() for the real
// implementation. Reaching this in practice would be an engine bug.
XLOOKUP() {
return new FormulaError('#ERR', 'XLOOKUP must be evaluated by evaluator.ts, not called directly');
},
};
// AVERAGE is an alias of AVG.
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
// CONCATENATE is Excel's classic name for the same thing as CONCAT.
FUNCTIONS.CONCATENATE = FUNCTIONS.CONCAT;
/** One-line signature hint per built-in, for the formula-autocomplete
* dropdown (see store/document.ts's `functionSuggestions()` and
* src/composables/useFormulaAutocomplete.ts). Hand-maintained -- kept
* honest by a test asserting its names exactly match `FUNCTIONS`'s keys,
* so a future new built-in without a matching entry here fails loudly
* instead of just silently missing from the dropdown. */
export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = [
{ name: 'SUM', hint: 'SUM(Zahl1; Zahl2; …)' },
{ name: 'AVG', hint: 'AVG(Zahl1; Zahl2; …)' },
{ name: 'AVERAGE', hint: 'AVERAGE(Zahl1; Zahl2; …)' },
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
{ name: 'COUNT2', hint: 'COUNT2(Text1; Zahl2; …)' },
{ name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' },
{ name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' },
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
{ name: 'AND', hint: 'AND(Bedingung1; Bedingung2; …)' },
{ name: 'OR', hint: 'OR(Bedingung1; Bedingung2; …)' },
{ name: 'NOT', hint: 'NOT(Bedingung)' },
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
{ name: 'TODAY', hint: 'TODAY()' },
{ name: 'NOW', hint: 'NOW()' },
{ name: 'YEAR', hint: 'YEAR(Datum)' },
{ name: 'MONTH', hint: 'MONTH(Datum)' },
{ name: 'DAY', hint: 'DAY(Datum)' },
{ name: 'HOUR', hint: 'HOUR(Zeit)' },
{ name: 'MINUTE', hint: 'MINUTE(Zeit)' },
{ name: 'SECOND', hint: 'SECOND(Zeit)' },
{ name: 'WEEKDAY', hint: 'WEEKDAY(Datum; Typ?)' },
{ name: 'ISOWEEKNUM', hint: 'ISOWEEKNUM(Datum)' },
{ name: 'WEEKNUM', hint: 'WEEKNUM(Datum; Typ?)' },
{ name: 'EDATE', hint: 'EDATE(Datum; Monate)' },
{ name: 'EOMONTH', hint: 'EOMONTH(Datum; Monate)' },
{ name: 'DAYS', hint: 'DAYS(Ende; Start)' },
{ name: 'DATEDIF', hint: 'DATEDIF(Start; Ende; Einheit)' },
{ name: 'NETWORKDAYS', hint: 'NETWORKDAYS(Start; Ende; Feiertage?)' },
{ name: 'WORKDAY', hint: 'WORKDAY(Start; Tage; Feiertage?)' },
{ name: 'DATEVALUE', hint: 'DATEVALUE(Text)' },
{ name: 'TIMEVALUE', hint: 'TIMEVALUE(Text)' },
{ name: 'XLOOKUP', hint: 'XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)' },
];
export function hasFunction(name: string): boolean {
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
}
export function getFunction(name: string): FunctionFn | undefined {
return FUNCTIONS[name.toUpperCase()];
}