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