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,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) };
|
||||
}
|
||||
Reference in New Issue
Block a user