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
+193
View File
@@ -0,0 +1,193 @@
/**
* CSV import/export -- hand-written, no library.
*
* Export follows RFC 4180: fields containing the delimiter, a double quote, CR
* or LF are wrapped in double quotes and inner quotes are doubled. Line ending
* is CRLF, optional UTF-8 BOM for Excel. Delimiter is "," or ";".
*
* Import: a small state-machine parser that understands quoted fields with
* embedded delimiters / newlines, plus delimiter sniffing and de/en locale
* number normalisation. A leading "=" makes the cell a formula.
*/
import type { DocumentStore } from '../store/document';
import { rawString } from '../store/format';
export type Delimiter = ',' | ';' | '\t';
export interface CsvExportOptions {
delimiter: Delimiter;
bom: boolean;
crlf: boolean;
}
// ---- export --------------------------------------------------------------
function needsQuoting(field: string, delimiter: string): boolean {
return (
field.includes(delimiter) ||
field.includes('"') ||
field.includes('\n') ||
field.includes('\r')
);
}
function quoteField(field: string, delimiter: string): string {
if (!needsQuoting(field, delimiter)) return field;
return `"${field.replace(/"/g, '""')}"`;
}
/** Bounding box of the used area of a sheet. */
function usedBounds(cells: Record<string, string>): { rows: number; cols: number } {
let rows = 0;
let cols = 0;
for (const key of Object.keys(cells)) {
const [c, r] = key.split(',').map(Number);
if (c + 1 > cols) cols = c + 1;
if (r + 1 > rows) rows = r + 1;
}
return { rows, cols };
}
export function tableToCsv(
store: DocumentStore,
tableId: string,
opts: CsvExportOptions,
): string {
const table = store.tableById(tableId);
if (!table) return '';
const bounds = usedBounds(table.cells);
const rows = Math.max(bounds.rows, 1);
const cols = Math.max(bounds.cols, 1);
const eol = opts.crlf ? '\r\n' : '\n';
const lines: string[] = [];
for (let r = 0; r < rows; r++) {
const fields: string[] = [];
for (let c = 0; c < cols; c++) {
const value = rawString(store.cellValue(tableId, c, r));
fields.push(quoteField(value, opts.delimiter));
}
lines.push(fields.join(opts.delimiter));
}
return (opts.bom ? '' : '') + lines.join(eol) + eol;
}
// ---- import --------------------------------------------------------------
export function sniffDelimiter(text: string): Delimiter {
const firstLine = text.slice(0, text.search(/\r?\n/) >>> 0 || text.length);
const counts: Record<Delimiter, number> = { ',': 0, ';': 0, '\t': 0 };
let inQuotes = false;
for (const ch of firstLine) {
if (ch === '"') inQuotes = !inQuotes;
else if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) counts[ch]++;
}
const best = (Object.keys(counts) as Delimiter[]).sort(
(a, b) => counts[b] - counts[a],
)[0];
return counts[best] > 0 ? best : ',';
}
/** RFC-4180-ish parser. Returns a matrix of raw string cells. */
export function parseCsv(text: string, delimiter: Delimiter): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
let i = 0;
const n = text.length;
// strip BOM
if (text.charCodeAt(0) === 0xfeff) i = 1;
const endField = () => {
row.push(field);
field = '';
};
const endRow = () => {
endField();
rows.push(row);
row = [];
};
while (i < n) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
field += ch;
i++;
continue;
}
if (ch === '"') {
inQuotes = true;
i++;
continue;
}
if (ch === delimiter) {
endField();
i++;
continue;
}
if (ch === '\r') {
if (text[i + 1] === '\n') i++;
endRow();
i++;
continue;
}
if (ch === '\n') {
endRow();
i++;
continue;
}
field += ch;
i++;
}
// trailing field / row (unless the file ended exactly on a newline)
if (field !== '' || row.length > 0) endRow();
return rows;
}
const DE_NUMBER = /^-?\d{1,3}(\.\d{3})+(,\d+)?$|^-?\d+,\d+$/;
const EN_NUMBER = /^-?\d{1,3}(,\d{3})+(\.\d+)?$|^-?\d+\.\d+$/;
/** Normalise a locale-formatted number to a plain JS number literal string. */
export function normalizeNumber(value: string): string {
const v = value.trim();
if (v === '' || v.startsWith('=')) return value;
if (/^-?\d+$/.test(v)) return v;
if (DE_NUMBER.test(v)) return v.replace(/\./g, '').replace(',', '.');
if (EN_NUMBER.test(v)) return v.replace(/,/g, '');
return value;
}
export interface CsvToCellsResult {
cells: Record<string, string>;
rows: number;
cols: number;
}
export function matrixToCells(
matrix: string[][],
detectNumbers = true,
): CsvToCellsResult {
const cells: Record<string, string> = {};
let cols = 0;
matrix.forEach((line, r) => {
if (line.length > cols) cols = line.length;
line.forEach((cell, c) => {
let raw = cell;
if (detectNumbers) raw = normalizeNumber(raw);
if (raw !== '') cells[`${c},${r}`] = raw;
});
});
return { cells, rows: matrix.length, cols: Math.max(cols, 1) };
}