Files
numolus/app/frontend/io/operations.ts
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

132 lines
4.7 KiB
TypeScript

/**
* High-level import/export operations. Each one is atomic: on any failure it
* throws and leaves the document untouched (the UI catches and shows a toast).
*/
import type { DocumentStore } from '../store/document';
import { downloadBlob, downloadText } from './download';
import { serializeDocument, parseDocument, reidSheets } from './json';
import {
parseCsv,
sniffDelimiter,
matrixToCells,
tableToCsv,
type Delimiter,
type CsvExportOptions,
} from './csv';
import { documentToXlsx, xlsxToSheets, importedToSheet } from './xlsx';
export function readAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
fr.onload = () => resolve(String(fr.result));
fr.readAsText(file);
});
}
export function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
fr.onload = () => resolve(fr.result as ArrayBuffer);
fr.readAsArrayBuffer(file);
});
}
export function extensionOf(name: string): string {
const i = name.lastIndexOf('.');
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
}
/** Yield to the event loop so a large parse doesn't freeze the UI. */
const breathe = () => new Promise((r) => setTimeout(r, 0));
// ---- export ---------------------------------------------------------
/** A document title as a file name (without extension). */
export function fileBaseName(title: string): string {
return title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').trim() || 'dokument';
}
export function exportJson(store: DocumentStore, name = 'dokument'): void {
downloadText(`${fileBaseName(name)}.json`, serializeDocument(store.snapshot()), 'application/json');
}
/** Save as the native `.num` file (the JSON document, zipped -- see num.ts).
* The zip code loads on first use, like the xlsx support. */
export async function exportNum(store: DocumentStore, name = 'dokument'): Promise<void> {
const { packNum, NUM_MIMETYPE } = await import('./num');
const bytes = packNum(serializeDocument(store.snapshot()));
downloadBlob(`${fileBaseName(name)}.num`, new Blob([bytes], { type: NUM_MIMETYPE }));
}
/** The document JSON inside a `.num` file -- then imported like a `.json`. */
export async function readNumFile(buffer: ArrayBuffer): Promise<string> {
const { unpackNum } = await import('./num');
return unpackNum(new Uint8Array(buffer));
}
export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOptions): void {
const table = store.activeTable.value;
if (!table) throw new Error('Keine Tabelle ausgewählt.');
downloadText(`${table.name}.csv`, tableToCsv(store, table.id, opts), 'text/csv;charset=utf-8');
}
export async function exportXlsx(store: DocumentStore, name = 'arbeitsmappe'): Promise<void> {
downloadBlob(`${fileBaseName(name)}.xlsx`, await documentToXlsx(store));
}
// ---- import: JSON ------------------------------------------------
export function importJsonReplace(store: DocumentStore, text: string): void {
store.replaceDocument(parseDocument(text)); // throws JsonImportError on bad schema
}
export function importJsonAppend(store: DocumentStore, text: string): void {
store.appendSheets(reidSheets(parseDocument(text)));
}
// ---- import: CSV -----------------------------------------------
export interface CsvImportConfig {
mode: 'new-table' | 'replace-active';
delimiter: Delimiter | 'auto';
detectNumbers: boolean;
name?: string;
}
export async function importCsv(
store: DocumentStore,
text: string,
cfg: CsvImportConfig,
): Promise<void> {
const delimiter: Delimiter = cfg.delimiter === 'auto' ? sniffDelimiter(text) : cfg.delimiter;
const matrix = parseCsv(text, delimiter);
if (matrix.length > 2000) await breathe();
const { cells, rows, cols } = matrixToCells(matrix, cfg.detectNumbers);
if (cfg.mode === 'replace-active') {
const table = store.activeTable.value;
if (!table) throw new Error('Keine Zieltabelle ausgewählt.');
store.replaceTableCells(table.id, cells, Math.max(rows, 12), Math.max(cols, 6));
} else {
store.addTableFromCells(
store.doc.activeSheetId,
cfg.name?.trim() || 'Import',
cells,
Math.max(rows, 12),
Math.max(cols, 6),
);
}
}
// ---- import: XLSX --------------------------------------------
export async function importXlsxFile(store: DocumentStore, buffer: ArrayBuffer): Promise<void> {
const imported = await xlsxToSheets(buffer);
if (imported.length === 0) throw new Error('Die Arbeitsmappe enthält keine Tabellen.');
await breathe();
store.appendSheets(imported.map((s, i) => importedToSheet(s, i)));
}