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>
194 lines
5.2 KiB
TypeScript
194 lines
5.2 KiB
TypeScript
/**
|
||
* 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) };
|
||
}
|