Files
wkhandClaude Opus 5.5 e14bada114 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>
2026-09-28 20:09:06 +02:00

194 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) };
}