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>
132 lines
4.7 KiB
TypeScript
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)));
|
|
}
|