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:
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Tokenizer for the formula language.
|
||||
*
|
||||
* The lexer is deliberately dumb: it splits the input into a flat token stream
|
||||
* and leaves *all* structural decisions to the parser. In particular it does not
|
||||
* try to tell a cell reference ("A1") from a function name ("SUM") or a sheet
|
||||
* name ("Umsatz") -- they are all `IDENT` tokens and the parser disambiguates
|
||||
* them by look-ahead (`(` -> function, `!` -> sheet qualifier, digits -> cell).
|
||||
*/
|
||||
|
||||
import { ERROR_CODES } from './types';
|
||||
|
||||
export type TokenType =
|
||||
| 'NUMBER'
|
||||
| 'STRING'
|
||||
| 'IDENT' // bare word: A1, SUM, Region, Q1 ...
|
||||
| 'QUOTED' // 'Kosten Q3' -- a sheet name in single quotes
|
||||
| 'ERRORLIT' // a literal error code, e.g. "#REF" or "#DIV/0!" -- see ERROR_CODES
|
||||
| 'PLUS'
|
||||
| 'MINUS'
|
||||
| 'STAR'
|
||||
| 'SLASH'
|
||||
| 'CARET'
|
||||
| 'AMP' // "&" -- text concatenation
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'SEMICOLON'
|
||||
| 'COLON'
|
||||
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
|
||||
| 'BANG'
|
||||
| 'EQUALS' // "=" -- assignment (block body) or equality comparison
|
||||
| 'NEQ' // "<>"
|
||||
| 'LT' // "<"
|
||||
| 'GT' // ">"
|
||||
| 'LTE' // "<="
|
||||
| 'GTE' // ">="
|
||||
| 'EOF';
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
/** Raw text of the token (unquoted for STRING/QUOTED). */
|
||||
value: string;
|
||||
/** Character offset in the source, useful for error messages. */
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class LexError extends Error {}
|
||||
|
||||
const SINGLE: Record<string, TokenType> = {
|
||||
'+': 'PLUS',
|
||||
'-': 'MINUS',
|
||||
'*': 'STAR',
|
||||
'/': 'SLASH',
|
||||
'^': 'CARET',
|
||||
'&': 'AMP',
|
||||
'(': 'LPAREN',
|
||||
')': 'RPAREN',
|
||||
';': 'SEMICOLON',
|
||||
':': 'COLON',
|
||||
'!': 'BANG',
|
||||
'=': 'EQUALS',
|
||||
'<': 'LT',
|
||||
'>': 'GT',
|
||||
};
|
||||
|
||||
function isDigit(ch: string): boolean {
|
||||
return ch >= '0' && ch <= '9';
|
||||
}
|
||||
/** Any Unicode letter, not just ASCII -- a table/sheet name (used unquoted
|
||||
* as a formula qualifier whenever it has no spaces) routinely contains
|
||||
* "ä"/"ö"/"ü"/"ß" or other accented letters in this German-first app;
|
||||
* rejecting those at the lexer level made every such name effectively
|
||||
* unusable as a bare qualifier (`Übersicht::A1` used to throw outright). */
|
||||
const IS_LETTER = /\p{L}/u;
|
||||
function isWordStart(ch: string): boolean {
|
||||
// '$' only ever appears inside a cell reference ("$A1", "A$1", "$A$1"), but
|
||||
// the lexer doesn't distinguish those from any other bare word (see the
|
||||
// module doc) -- it's just part of the word here, and the parser is the one
|
||||
// that validates/interprets it as a locked reference axis.
|
||||
return IS_LETTER.test(ch) || ch === '_' || ch === '$';
|
||||
}
|
||||
function isWordPart(ch: string): boolean {
|
||||
return isWordStart(ch) || isDigit(ch);
|
||||
}
|
||||
|
||||
/** Longest-first, so a prefix of another code (none collide today, but this
|
||||
* stays correct if one ever did) never wins the match by accident. */
|
||||
const ERROR_CODES_BY_LENGTH = [...ERROR_CODES].sort((a, b) => b.length - a.length);
|
||||
|
||||
export function tokenize(src: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
|
||||
while (i < n) {
|
||||
const ch = src[i];
|
||||
|
||||
// whitespace
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// number: 12 12.5 .5 1e3 1.2E-3
|
||||
if (isDigit(ch) || (ch === '.' && isDigit(src[i + 1]))) {
|
||||
const start = i;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
if (src[i] === '.') {
|
||||
i++;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
}
|
||||
if (src[i] === 'e' || src[i] === 'E') {
|
||||
let j = i + 1;
|
||||
if (src[j] === '+' || src[j] === '-') j++;
|
||||
if (isDigit(src[j])) {
|
||||
i = j;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
}
|
||||
}
|
||||
tokens.push({ type: 'NUMBER', value: src.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// double-quoted string literal
|
||||
if (ch === '"') {
|
||||
const start = i;
|
||||
i++;
|
||||
let out = '';
|
||||
while (i < n && src[i] !== '"') {
|
||||
if (src[i] === '\\' && i + 1 < n) {
|
||||
out += src[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
out += src[i++];
|
||||
}
|
||||
}
|
||||
if (src[i] !== '"') throw new LexError(`Unterminated string at ${start}`);
|
||||
i++; // closing quote
|
||||
tokens.push({ type: 'STRING', value: out, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// single-quoted sheet name: 'Kosten Q3' ('' -> literal quote)
|
||||
if (ch === "'") {
|
||||
const start = i;
|
||||
i++;
|
||||
let out = '';
|
||||
while (i < n) {
|
||||
if (src[i] === "'" && src[i + 1] === "'") {
|
||||
out += "'";
|
||||
i += 2;
|
||||
} else if (src[i] === "'") {
|
||||
break;
|
||||
} else {
|
||||
out += src[i++];
|
||||
}
|
||||
}
|
||||
if (src[i] !== "'") throw new LexError(`Unterminated sheet name at ${start}`);
|
||||
i++; // closing quote
|
||||
tokens.push({ type: 'QUOTED', value: out, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// bare word: identifiers, function names, cell refs
|
||||
if (isWordStart(ch)) {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < n && isWordPart(src[i])) i++;
|
||||
tokens.push({ type: 'IDENT', value: src.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// "::" table qualifier -- must be checked before the single ":"
|
||||
if (ch === ':' && src[i + 1] === ':') {
|
||||
tokens.push({ type: 'DBLCOLON', value: '::', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// two-character comparison operators -- must be checked before the
|
||||
// single-character "<"/">" fallback below
|
||||
if (ch === '<' && src[i + 1] === '=') {
|
||||
tokens.push({ type: 'LTE', value: '<=', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '>' && src[i + 1] === '=') {
|
||||
tokens.push({ type: 'GTE', value: '>=', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '<' && src[i + 1] === '>') {
|
||||
tokens.push({ type: 'NEQ', value: '<>', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// an error-code literal, e.g. "#REF" or "#DIV/0!" -- lets a formula
|
||||
// literally contain/produce one, same as typing it in Excel; also how a
|
||||
// structural row/col delete marks a now-broken reference in formula text
|
||||
// (see store/document.ts's row/col delete). Recognized as one token
|
||||
// regardless of what follows (a subsequent "!"/"?" is already consumed
|
||||
// as part of the matched code, never re-tokenized as BANG etc.).
|
||||
if (ch === '#') {
|
||||
const code = ERROR_CODES_BY_LENGTH.find((c) => src.startsWith(c, i));
|
||||
if (code) {
|
||||
tokens.push({ type: 'ERRORLIT', value: code, pos: i });
|
||||
i += code.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// single-character punctuation / operators
|
||||
const t = SINGLE[ch];
|
||||
if (t) {
|
||||
tokens.push({ type: t, value: ch, pos: i });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new LexError(`Unexpected character '${ch}' at ${i}`);
|
||||
}
|
||||
|
||||
tokens.push({ type: 'EOF', value: '', pos: n });
|
||||
return tokens;
|
||||
}
|
||||
Reference in New Issue
Block a user