Store multiple documents in IndexedDB with a document list
Documents now live in an IndexedDB library instead of a single localStorage slot: each has its own ?doc=<id> URL, a title (editable in the toolbar, used for export file names) and appears in Datei › Dokumente (open, rename, duplicate, delete). New/sample/import create new documents instead of replacing the open one. Tabs stay in step via BroadcastChannel. The old localStorage document is migrated once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,9 @@ import BaseModal from './BaseModal.vue';
|
||||
import CustomFunctionsModal from './CustomFunctionsModal.vue';
|
||||
import NamedRangesModal from './NamedRangesModal.vue';
|
||||
import MacrosModal from './MacrosModal.vue';
|
||||
import DocumentsModal from './DocumentsModal.vue';
|
||||
import { useLibrary } from '../store/library';
|
||||
import { parseDocument } from '../io/json';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
|
||||
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
import type { Delimiter } from '../io/csv';
|
||||
|
||||
const store = useDocument();
|
||||
const library = useLibrary();
|
||||
const { mode: themeMode, cycle: cycleTheme } = useTheme();
|
||||
const toasts = useToasts();
|
||||
|
||||
@@ -105,6 +109,17 @@ const aboutOpen = ref(false);
|
||||
const functionsOpen = ref(false);
|
||||
const namedRangesOpen = ref(false);
|
||||
const macrosOpen = ref(false);
|
||||
const documentsOpen = ref(false);
|
||||
|
||||
// ---- document title (inline rename) ------------------------------------
|
||||
const titleDraft = ref(library.currentTitle.value);
|
||||
watch(library.currentTitle, (t) => (titleDraft.value = t));
|
||||
function commitTitle() {
|
||||
const id = library.state.currentId;
|
||||
const t = titleDraft.value.trim();
|
||||
if (!id || !t) titleDraft.value = library.currentTitle.value;
|
||||
else if (t !== library.currentTitle.value) void library.rename(id, t);
|
||||
}
|
||||
const busy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -157,7 +172,7 @@ function runCsvExport() {
|
||||
}
|
||||
async function runNumExport() {
|
||||
try {
|
||||
await exportNum(store);
|
||||
await exportNum(store, library.currentTitle.value);
|
||||
toasts.success('Dokument als .num gespeichert.');
|
||||
} catch (err) {
|
||||
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
|
||||
@@ -165,13 +180,13 @@ async function runNumExport() {
|
||||
}
|
||||
|
||||
function runJsonExport() {
|
||||
exportJson(store);
|
||||
exportJson(store, library.currentTitle.value);
|
||||
toasts.success('Dokument als JSON exportiert.');
|
||||
}
|
||||
async function runXlsxExport() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await exportXlsx(store);
|
||||
await exportXlsx(store, library.currentTitle.value);
|
||||
toasts.success('Arbeitsmappe als XLSX exportiert.');
|
||||
} catch (err) {
|
||||
toasts.error(`XLSX-Export fehlgeschlagen: ${(err as Error).message}`);
|
||||
@@ -247,13 +262,15 @@ async function handleFile(file: File) {
|
||||
|
||||
defineExpose({ handleFile });
|
||||
|
||||
async function doImport(action: 'replace' | 'append' | 'confirm') {
|
||||
async function doImport(action: 'new' | 'replace' | 'append' | 'confirm') {
|
||||
const ctx = importCtx.value;
|
||||
if (!ctx) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
if (ctx.kind === 'json') {
|
||||
if (action === 'replace') {
|
||||
if (action === 'new') {
|
||||
await library.create(parseDocument(ctx.text), ctx.fileName.replace(/\.(num|json)$/i, ''));
|
||||
} else if (action === 'replace') {
|
||||
if (!confirm('Aktuelles Dokument vollständig ersetzen?')) return;
|
||||
importJsonReplace(store, ctx.text);
|
||||
} else {
|
||||
@@ -279,18 +296,14 @@ async function doImport(action: 'replace' | 'append' | 'confirm') {
|
||||
}
|
||||
}
|
||||
|
||||
function onNew() {
|
||||
if (confirm('Neues, leeres Dokument anlegen? Alle Änderungen gehen verloren.')) {
|
||||
store.newDocument();
|
||||
toasts.info('Neues Dokument angelegt.');
|
||||
}
|
||||
async function onNew() {
|
||||
await library.createBlank();
|
||||
toasts.info('Neues Dokument angelegt.');
|
||||
}
|
||||
|
||||
function onSample() {
|
||||
if (confirm('Musterdokument laden? Alle Änderungen gehen verloren.')) {
|
||||
store.resetToSeed();
|
||||
toasts.info('Musterdokument geladen.');
|
||||
}
|
||||
async function onSample() {
|
||||
await library.createSample();
|
||||
toasts.info('Musterdokument als neues Dokument angelegt.');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -303,6 +316,17 @@ function onSample() {
|
||||
<button class="by" type="button" @click="aboutOpen = true">wicom</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="titleDraft"
|
||||
class="doc-title"
|
||||
type="text"
|
||||
title="Dokumentname (klicken zum Umbenennen)"
|
||||
aria-label="Dokumentname"
|
||||
:size="Math.max(8, Math.min(28, titleDraft.length + 1))"
|
||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
|
||||
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
|
||||
@blur="commitTitle()"
|
||||
/>
|
||||
<button
|
||||
class="gear"
|
||||
:class="{ on: store.ui.settingsOpen }"
|
||||
@@ -381,7 +405,8 @@ function onSample() {
|
||||
<div ref="fileMenuWrap" class="menu-wrap">
|
||||
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
|
||||
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
|
||||
<li><button @click="onNew">Neu</button></li>
|
||||
<li><button @click="documentsOpen = true">Dokumente …</button></li>
|
||||
<li><button @click="onNew">Neues Dokument</button></li>
|
||||
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="pickFile">Öffnen / Importieren … (.num .json .csv .xlsx)</button></li>
|
||||
@@ -433,7 +458,7 @@ function onSample() {
|
||||
@close="importOpen = false"
|
||||
>
|
||||
<template v-if="importCtx?.kind === 'json'">
|
||||
<p>JSON-Dokument erkannt. Wie einfügen?</p>
|
||||
<p>Dokument erkannt. Als eigenes Dokument öffnen oder in das aktuelle übernehmen?</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="importCtx?.kind === 'csv'">
|
||||
@@ -471,7 +496,8 @@ function onSample() {
|
||||
<button @click="importOpen = false">Abbrechen</button>
|
||||
<template v-if="importCtx?.kind === 'json'">
|
||||
<button @click="doImport('append')">Anhängen</button>
|
||||
<button class="primary" @click="doImport('replace')">Ersetzen</button>
|
||||
<button @click="doImport('replace')">Ersetzen</button>
|
||||
<button class="primary" @click="doImport('new')">Als neues Dokument</button>
|
||||
</template>
|
||||
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
|
||||
</template>
|
||||
@@ -481,6 +507,7 @@ function onSample() {
|
||||
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
||||
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
|
||||
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
|
||||
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" />
|
||||
|
||||
<!-- Über / Impressum -->
|
||||
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
||||
@@ -634,6 +661,27 @@ function onSample() {
|
||||
.btn-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.doc-title {
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-title:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.doc-title:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.gear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "Dokumente" -- every document stored in this browser (IndexedDB, see
|
||||
* src/store/library.ts): open, rename, duplicate, delete, create. The open
|
||||
* document is marked; opening another one switches in place (and pushes a
|
||||
* history entry, so the browser's back button returns to the previous one).
|
||||
*/
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useLibrary } from '../store/library';
|
||||
import type { DocMeta } from '../io/library';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const library = useLibrary();
|
||||
|
||||
const renamingId = ref<string | null>(null);
|
||||
const renameValue = ref('');
|
||||
const renameInput = ref<HTMLInputElement[] | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) renamingId.value = null;
|
||||
},
|
||||
);
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
function when(ms: number): string {
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 60_000) return 'gerade eben';
|
||||
if (diff < 3_600_000) return `vor ${Math.floor(diff / 60_000)} Min.`;
|
||||
return fmt.format(ms);
|
||||
}
|
||||
|
||||
async function openDoc(d: DocMeta) {
|
||||
if (renamingId.value) return;
|
||||
await library.open(d.id);
|
||||
emit('close');
|
||||
}
|
||||
async function startRename(d: DocMeta) {
|
||||
renamingId.value = d.id;
|
||||
renameValue.value = d.title;
|
||||
await nextTick();
|
||||
renameInput.value?.[0]?.select();
|
||||
}
|
||||
async function commitRename() {
|
||||
const id = renamingId.value;
|
||||
renamingId.value = null;
|
||||
if (id) await library.rename(id, renameValue.value);
|
||||
}
|
||||
async function remove(d: DocMeta) {
|
||||
if (!confirm(`„${d.title}" endgültig löschen?`)) return;
|
||||
await library.remove(d.id);
|
||||
}
|
||||
async function createNew() {
|
||||
await library.createBlank();
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="open" title="Dokumente" wide @close="emit('close')">
|
||||
<p v-if="!library.state.available" class="hint">
|
||||
In diesem Browser können keine Dokumente gespeichert werden (IndexedDB nicht verfügbar,
|
||||
z. B. im privaten Modus).
|
||||
</p>
|
||||
<ul v-else class="doc-list">
|
||||
<li
|
||||
v-for="d in library.state.docs"
|
||||
:key="d.id"
|
||||
class="doc-row"
|
||||
:class="{ current: d.id === library.state.currentId }"
|
||||
>
|
||||
<input
|
||||
v-if="renamingId === d.id"
|
||||
ref="renameInput"
|
||||
v-model="renameValue"
|
||||
class="rename"
|
||||
type="text"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.esc.stop.prevent="renamingId = null"
|
||||
@blur="commitRename"
|
||||
/>
|
||||
<button v-else class="doc-open" :title="`${d.title} öffnen`" @click="openDoc(d)">
|
||||
<span class="doc-title">{{ d.title }}</span>
|
||||
<span class="doc-when">{{ d.id === library.state.currentId ? 'geöffnet · ' : '' }}{{ when(d.updatedAt) }}</span>
|
||||
</button>
|
||||
<span class="doc-actions">
|
||||
<button class="mini" @click="startRename(d)">Umbenennen</button>
|
||||
<button class="mini" @click="library.duplicate(d.id)">Duplizieren</button>
|
||||
<button class="mini danger" @click="remove(d)">Löschen</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="hint">
|
||||
Dokumente werden nur in diesem Browser gespeichert. Zum Sichern oder Weitergeben:
|
||||
Datei › Speichern (.num).
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<button @click="emit('close')">Schließen</button>
|
||||
<button class="primary" :disabled="!library.state.available" @click="createNew">+ Neues Dokument</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.doc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: min(60vh, 480px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.doc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.doc-row.current {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.doc-open {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 4px 6px;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-open:hover .doc-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
.doc-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.doc-when {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.rename {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.doc-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.doc-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
import { DocumentLibrary, newDocId, type DocMeta } from '../library';
|
||||
|
||||
const meta = (id: string, title: string, updatedAt: number): DocMeta => ({
|
||||
id,
|
||||
title,
|
||||
createdAt: 1,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
/** a fresh, empty in-memory IndexedDB per test */
|
||||
const openLib = () => DocumentLibrary.open(new IDBFactory());
|
||||
|
||||
describe('DocumentLibrary', () => {
|
||||
it('stores meta and content separately and reads them back', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'Budget', 10), '{"x":1}');
|
||||
expect(await lib.getMeta('a')).toEqual(meta('a', 'Budget', 10));
|
||||
expect(await lib.getContent('a')).toBe('{"x":1}');
|
||||
expect(await lib.getContent('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists documents most recently changed first', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('old', 'Alt', 10), '{}');
|
||||
await lib.put(meta('new', 'Neu', 30), '{}');
|
||||
await lib.put(meta('mid', 'Mitte', 20), '{}');
|
||||
expect((await lib.list()).map((d) => d.id)).toEqual(['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
it('patchMeta renames without touching content; unknown ids are ignored', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'Alt', 10), '{"keep":true}');
|
||||
await lib.patchMeta('a', { title: 'Neu' });
|
||||
await lib.patchMeta('nope', { title: 'X' });
|
||||
expect((await lib.getMeta('a'))?.title).toBe('Neu');
|
||||
expect(await lib.getContent('a')).toBe('{"keep":true}');
|
||||
expect(await lib.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('remove deletes meta and content', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'A', 10), '{}');
|
||||
await lib.remove('a');
|
||||
expect(await lib.getMeta('a')).toBeUndefined();
|
||||
expect(await lib.getContent('a')).toBeUndefined();
|
||||
expect(await lib.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps data across re-opening the same database', async () => {
|
||||
const factory = new IDBFactory();
|
||||
await (await DocumentLibrary.open(factory)).put(meta('a', 'A', 10), '{"v":2}');
|
||||
expect(await (await DocumentLibrary.open(factory)).getContent('a')).toBe('{"v":2}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('newDocId', () => {
|
||||
it('produces distinct v4 UUIDs', () => {
|
||||
const a = newDocId();
|
||||
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(newDocId()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The browser's document library: every numolus document the user has in
|
||||
* this browser, stored in IndexedDB. Two object stores, so listing documents
|
||||
* never has to read their (possibly large) contents:
|
||||
*
|
||||
* meta { id, title, createdAt, updatedAt } -- the document list
|
||||
* content { id, json } -- the document itself
|
||||
*
|
||||
* `json` is the serialised DocumentState (the same JSON a .num file holds).
|
||||
* Plain promise wrapper over the IndexedDB API, no library; `factory` is
|
||||
* injectable so tests can run against an in-memory implementation.
|
||||
*/
|
||||
|
||||
export interface DocMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
/** epoch ms */
|
||||
createdAt: number;
|
||||
/** epoch ms of the last *content* change (not cursor moves) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface ContentRecord {
|
||||
id: string;
|
||||
json: string;
|
||||
}
|
||||
|
||||
const DB_NAME = 'numolus';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function req<T>(r: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
r.onsuccess = () => resolve(r.result);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
function done(tx: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Transaktion abgebrochen.'));
|
||||
});
|
||||
}
|
||||
|
||||
export class DocumentLibrary {
|
||||
private constructor(private db: IDBDatabase) {}
|
||||
|
||||
static async open(factory: IDBFactory = indexedDB): Promise<DocumentLibrary> {
|
||||
const r = factory.open(DB_NAME, DB_VERSION);
|
||||
r.onupgradeneeded = () => {
|
||||
const db = r.result;
|
||||
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('content')) db.createObjectStore('content', { keyPath: 'id' });
|
||||
};
|
||||
const db = await req(r);
|
||||
// another tab upgrading the schema later must not be blocked by us
|
||||
db.onversionchange = () => db.close();
|
||||
return new DocumentLibrary(db);
|
||||
}
|
||||
|
||||
/** All documents, most recently changed first. */
|
||||
async list(): Promise<DocMeta[]> {
|
||||
const tx = this.db.transaction('meta', 'readonly');
|
||||
const all = await req(tx.objectStore('meta').getAll() as IDBRequest<DocMeta[]>);
|
||||
return all.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
async getMeta(id: string): Promise<DocMeta | undefined> {
|
||||
const tx = this.db.transaction('meta', 'readonly');
|
||||
return req(tx.objectStore('meta').get(id) as IDBRequest<DocMeta | undefined>);
|
||||
}
|
||||
|
||||
async getContent(id: string): Promise<string | undefined> {
|
||||
const tx = this.db.transaction('content', 'readonly');
|
||||
const rec = await req(tx.objectStore('content').get(id) as IDBRequest<ContentRecord | undefined>);
|
||||
return rec?.json;
|
||||
}
|
||||
|
||||
/** Create or overwrite a document -- meta and content in one transaction. */
|
||||
async put(meta: DocMeta, json: string): Promise<void> {
|
||||
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
|
||||
tx.objectStore('meta').put(meta);
|
||||
tx.objectStore('content').put({ id: meta.id, json } satisfies ContentRecord);
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
/** Update only the listing data (rename). No-op for an unknown id. */
|
||||
async patchMeta(id: string, patch: Partial<Omit<DocMeta, 'id'>>): Promise<void> {
|
||||
const tx = this.db.transaction('meta', 'readwrite');
|
||||
const store = tx.objectStore('meta');
|
||||
const cur = await req(store.get(id) as IDBRequest<DocMeta | undefined>);
|
||||
if (cur) store.put({ ...cur, ...patch, id });
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
|
||||
tx.objectStore('meta').delete(id);
|
||||
tx.objectStore('content').delete(id);
|
||||
await done(tx);
|
||||
}
|
||||
}
|
||||
|
||||
export function newDocId(): string {
|
||||
// randomUUID exists only in secure contexts (https, localhost)
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
const b = crypto.getRandomValues(new Uint8Array(16));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
+11
-6
@@ -44,16 +44,21 @@ const breathe = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// ---- export ---------------------------------------------------------
|
||||
|
||||
export function exportJson(store: DocumentStore): void {
|
||||
downloadText('dokument.json', serializeDocument(store.snapshot()), 'application/json');
|
||||
/** 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): Promise<void> {
|
||||
export async function exportNum(store: DocumentStore, name = 'dokument'): Promise<void> {
|
||||
const { packNum, NUM_MIMETYPE } = await import('./num');
|
||||
const bytes = packNum(serializeDocument(store.snapshot()));
|
||||
downloadBlob('dokument.num', new Blob([bytes], { type: NUM_MIMETYPE }));
|
||||
downloadBlob(`${fileBaseName(name)}.num`, new Blob([bytes], { type: NUM_MIMETYPE }));
|
||||
}
|
||||
|
||||
/** The document JSON inside a `.num` file -- then imported like a `.json`. */
|
||||
@@ -68,8 +73,8 @@ export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOption
|
||||
downloadText(`${table.name}.csv`, tableToCsv(store, table.id, opts), 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
export async function exportXlsx(store: DocumentStore): Promise<void> {
|
||||
downloadBlob('arbeitsmappe.xlsx', await documentToXlsx(store));
|
||||
export async function exportXlsx(store: DocumentStore, name = 'arbeitsmappe'): Promise<void> {
|
||||
downloadBlob(`${fileBaseName(name)}.xlsx`, await documentToXlsx(store));
|
||||
}
|
||||
|
||||
// ---- import: JSON ------------------------------------------------
|
||||
|
||||
+6
-1
@@ -1,5 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { useLibrary } from './store/library';
|
||||
import './styles/main.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
// open the document (from IndexedDB) before the first render, so the app
|
||||
// never flashes an empty document first
|
||||
void useLibrary()
|
||||
.init()
|
||||
.finally(() => createApp(App).mount('#app'));
|
||||
|
||||
+43
-31
@@ -81,11 +81,29 @@ import type {
|
||||
} from './types';
|
||||
import { defaultTableView, DEFAULT_ROW_H, MIN_GRID_SIZE, MAX_GRID_SIZE } from './types';
|
||||
import { createBlankDocument, createSeedDocument } from '../data/seed';
|
||||
import { validateDocument } from '../io/json';
|
||||
|
||||
const STORAGE_KEY = 'numolus:doc:v2';
|
||||
/** Key used before the rename to numolus -- still read as a fallback. */
|
||||
const LEGACY_STORAGE_KEY = 'numbers-study:doc:v2';
|
||||
/** Where the document is persisted. Set by the document library
|
||||
* (src/store/library.ts) to write into IndexedDB; unset (tests, or before
|
||||
* the library has started) nothing is persisted. Called debounced with the
|
||||
* live reactive document -- the handler serialises it itself. */
|
||||
let persistHandler: ((doc: DocumentState) => void) | null = null;
|
||||
export function setPersistHandler(fn: ((doc: DocumentState) => void) | null) {
|
||||
persistHandler = fn;
|
||||
}
|
||||
|
||||
/** The part of a document that is *content* -- everything but the per-view
|
||||
* state (selection, active sheet/object). Used by undo history to tell an
|
||||
* edit from a mere cursor move, and by the library for the same distinction
|
||||
* when deciding whether a save changed anything other tabs care about. */
|
||||
export const contentFingerprint = (d: DocumentState) =>
|
||||
JSON.stringify({
|
||||
sheets: d.sheets,
|
||||
locale: d.locale,
|
||||
csvDelimiter: d.csvDelimiter,
|
||||
customFunctions: d.customFunctions,
|
||||
namedRanges: d.namedRanges,
|
||||
macros: d.macros,
|
||||
});
|
||||
|
||||
const CASCADE = 28; // px offset for successive inserted objects
|
||||
|
||||
@@ -590,18 +608,9 @@ function rewriteRefsForRename(
|
||||
return '=' + out;
|
||||
}
|
||||
|
||||
/** A blank document until the library (src/store/library.ts) loads the real
|
||||
* one from IndexedDB via replaceDocument() -- see main.ts. */
|
||||
function loadInitial(): DocumentState {
|
||||
try {
|
||||
const raw =
|
||||
localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (raw) {
|
||||
// validateDocument migrates v1, validates + *normalises* v2 (fills in
|
||||
// `view` and other defaults for documents saved by an older build).
|
||||
return validateDocument(JSON.parse(raw));
|
||||
}
|
||||
} catch {
|
||||
/* missing / corrupt / stale-schema -> clean fallback */
|
||||
}
|
||||
return createBlankDocument();
|
||||
}
|
||||
|
||||
@@ -654,13 +663,22 @@ function createStore() {
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function scheduleSave() {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(doc));
|
||||
} catch {
|
||||
/* quota / privacy mode -- ignore */
|
||||
}
|
||||
}, 250);
|
||||
persistTimer = setTimeout(flushSave, 250);
|
||||
}
|
||||
/** Write a pending save right now (page hidden, switching documents). */
|
||||
function flushSave() {
|
||||
if (persistTimer === undefined) return;
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
persistHandler?.(doc);
|
||||
}
|
||||
/** A change made here that hasn't been handed to persistence yet. */
|
||||
const hasPendingSave = () => persistTimer !== undefined;
|
||||
/** Drop a pending save -- the document was just loaded from where it
|
||||
* would be saved to. */
|
||||
function cancelSave() {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
}
|
||||
|
||||
// ---- undo / redo (debounced content snapshots) -----------------------
|
||||
@@ -686,15 +704,6 @@ function createStore() {
|
||||
const MAX_HISTORY = 100;
|
||||
const undoStack: DocumentState[] = [];
|
||||
const redoStack: DocumentState[] = [];
|
||||
const contentFingerprint = (d: DocumentState) =>
|
||||
JSON.stringify({
|
||||
sheets: d.sheets,
|
||||
locale: d.locale,
|
||||
csvDelimiter: d.csvDelimiter,
|
||||
customFunctions: d.customFunctions,
|
||||
namedRanges: d.namedRanges,
|
||||
macros: d.macros,
|
||||
});
|
||||
let historySnapshot = snapshot();
|
||||
let historyFingerprint = contentFingerprint(historySnapshot);
|
||||
let historyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -3142,6 +3151,9 @@ function createStore() {
|
||||
addTableFromCells,
|
||||
replaceTableCells,
|
||||
snapshot,
|
||||
flushSave,
|
||||
hasPendingSave,
|
||||
cancelSave,
|
||||
engine: () => wb,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Glue between the document store (one open document) and the document
|
||||
* library in IndexedDB (all documents in this browser):
|
||||
*
|
||||
* - which document is open: `?doc=<id>` in the URL, so every browser tab can
|
||||
* have its own document and a reload reopens the same one;
|
||||
* - persistence: the store's debounced save lands here and is written into
|
||||
* the library under the open document's id;
|
||||
* - other tabs: a BroadcastChannel tells them when a document's content, the
|
||||
* list or a title changed, so an open document list stays current and a
|
||||
* document open in two tabs follows the other tab's saves. Two tabs editing
|
||||
* the *same* document at the same moment still overwrite each other (last
|
||||
* save wins), so a hint is shown when that situation arises.
|
||||
*/
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useDocument, setPersistHandler, contentFingerprint } from './document';
|
||||
import type { DocumentState } from './types';
|
||||
import { DocumentLibrary, newDocId, type DocMeta } from '../io/library';
|
||||
import { validateDocument } from '../io/json';
|
||||
import { createBlankDocument, createSeedDocument } from '../data/seed';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
|
||||
/** The single-document localStorage keys used before the library existed --
|
||||
* read once and moved into the library as its first document. */
|
||||
const LEGACY_KEYS = ['numolus:doc:v2', 'numbers-study:doc:v2'];
|
||||
const CHANNEL = 'numolus:library';
|
||||
const DEFAULT_TITLE = 'Unbenannt';
|
||||
const APP_TITLE = 'numolus';
|
||||
|
||||
type Msg =
|
||||
| { type: 'list' }
|
||||
| { type: 'saved'; id: string }
|
||||
| { type: 'deleted'; id: string }
|
||||
| { type: 'hello'; id: string }
|
||||
| { type: 'here'; id: string };
|
||||
|
||||
function createLibrary() {
|
||||
const store = useDocument();
|
||||
const toasts = useToasts();
|
||||
|
||||
const state = reactive({
|
||||
ready: false,
|
||||
/** false = IndexedDB unavailable (private mode in some browsers, ...) */
|
||||
available: true,
|
||||
docs: [] as DocMeta[],
|
||||
currentId: null as string | null,
|
||||
});
|
||||
const current = computed(() => state.docs.find((d) => d.id === state.currentId) ?? null);
|
||||
const currentTitle = computed(() => current.value?.title ?? DEFAULT_TITLE);
|
||||
|
||||
let lib: DocumentLibrary | null = null;
|
||||
let channel: BroadcastChannel | null = null;
|
||||
/** content fingerprint of what the library holds for the open document */
|
||||
let savedContent = '';
|
||||
/** `updatedAt` of the open document as this tab last wrote or read it --
|
||||
* a differing value in the library means another tab saved since */
|
||||
let knownUpdatedAt = 0;
|
||||
/** writes run one after another, in order */
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
/** documents we've already warned about being open in another tab */
|
||||
const warnedShared = new Set<string>();
|
||||
|
||||
const post = (m: Msg) => channel?.postMessage(m);
|
||||
const enqueue = (job: () => Promise<void>) => {
|
||||
writeQueue = writeQueue.then(job).catch((err) => {
|
||||
console.error(err);
|
||||
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
|
||||
});
|
||||
return writeQueue;
|
||||
};
|
||||
|
||||
async function refreshList() {
|
||||
if (lib) state.docs = await lib.list();
|
||||
}
|
||||
|
||||
function uniqueTitle(base: string): string {
|
||||
const taken = new Set(state.docs.map((d) => d.title.toLowerCase()));
|
||||
if (!taken.has(base.toLowerCase())) return base;
|
||||
for (let n = 2; ; n++) {
|
||||
const t = `${base} ${n}`;
|
||||
if (!taken.has(t.toLowerCase())) return t;
|
||||
}
|
||||
}
|
||||
|
||||
function setUrl(id: string, push: boolean) {
|
||||
const url = new URL(location.href);
|
||||
if (url.searchParams.get('doc') === id) return;
|
||||
url.searchParams.set('doc', id);
|
||||
if (push) history.pushState(null, '', url);
|
||||
else history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
function updateWindowTitle() {
|
||||
document.title = `${currentTitle.value} – ${APP_TITLE}`;
|
||||
}
|
||||
|
||||
// ---- persistence ---------------------------------------------------
|
||||
function persist(doc: DocumentState) {
|
||||
const id = state.currentId;
|
||||
if (!lib || !id) return;
|
||||
const json = JSON.stringify(doc);
|
||||
const fp = contentFingerprint(doc);
|
||||
const changed = fp !== savedContent;
|
||||
savedContent = fp;
|
||||
void enqueue(async () => {
|
||||
const meta = (await lib!.getMeta(id)) ?? {
|
||||
id,
|
||||
title: currentTitle.value,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (!changed) {
|
||||
// only the cursor/active sheet moved: saved so reopening restores
|
||||
// it, but never over newer content another tab has written meanwhile
|
||||
if (meta.updatedAt !== knownUpdatedAt) return;
|
||||
await lib!.put(meta, json);
|
||||
return;
|
||||
}
|
||||
const updatedAt = Math.max(Date.now(), meta.updatedAt + 1);
|
||||
await lib!.put({ ...meta, updatedAt }, json);
|
||||
if (id === state.currentId) knownUpdatedAt = updatedAt;
|
||||
await refreshList();
|
||||
post({ type: 'saved', id });
|
||||
});
|
||||
}
|
||||
|
||||
/** Show `json` in the store as the open document `id`. */
|
||||
function show(id: string, json: string, updatedAt: number, keepView = false) {
|
||||
const next = validateDocument(JSON.parse(json));
|
||||
if (keepView) {
|
||||
// another tab's save: take its content, keep this tab's cursor/sheet
|
||||
if (next.sheets.some((s) => s.id === store.doc.activeSheetId)) {
|
||||
next.activeSheetId = store.doc.activeSheetId;
|
||||
next.activeObjectId = store.doc.activeObjectId;
|
||||
next.selection = store.doc.selection;
|
||||
}
|
||||
}
|
||||
state.currentId = id;
|
||||
store.replaceDocument(next);
|
||||
// it came from the library -- writing it straight back would only risk
|
||||
// overwriting a newer save from another tab
|
||||
store.cancelSave();
|
||||
savedContent = contentFingerprint(store.doc);
|
||||
knownUpdatedAt = updatedAt;
|
||||
updateWindowTitle();
|
||||
}
|
||||
|
||||
// ---- opening documents ---------------------------------------------
|
||||
async function open(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
||||
if (!lib) return false;
|
||||
if (id === state.currentId) return true;
|
||||
store.flushSave();
|
||||
await writeQueue;
|
||||
const [meta, json] = await Promise.all([lib.getMeta(id), lib.getContent(id)]);
|
||||
if (!meta || json === undefined) return false;
|
||||
try {
|
||||
show(id, json, meta.updatedAt);
|
||||
} catch (err) {
|
||||
toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
setUrl(id, opts.push ?? true);
|
||||
post({ type: 'hello', id });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Add a document to the library and open it. */
|
||||
async function create(doc: DocumentState, title: string): Promise<string | null> {
|
||||
if (!lib) {
|
||||
// no library: at least show it
|
||||
store.replaceDocument(doc);
|
||||
return null;
|
||||
}
|
||||
const id = newDocId();
|
||||
const now = Date.now();
|
||||
await enqueue(() =>
|
||||
lib!.put({ id, title: uniqueTitle(title.trim() || DEFAULT_TITLE), createdAt: now, updatedAt: now }, JSON.stringify(doc)),
|
||||
);
|
||||
await refreshList();
|
||||
post({ type: 'list' });
|
||||
await open(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
const createBlank = () => create(createBlankDocument(), DEFAULT_TITLE);
|
||||
const createSample = () => create(createSeedDocument(), 'Muster · Umsatzanalyse');
|
||||
|
||||
async function rename(id: string, title: string) {
|
||||
const t = title.trim();
|
||||
if (!lib || !t) return;
|
||||
await enqueue(() => lib!.patchMeta(id, { title: t }));
|
||||
await refreshList();
|
||||
if (id === state.currentId) updateWindowTitle();
|
||||
post({ type: 'list' });
|
||||
}
|
||||
|
||||
async function duplicate(id: string) {
|
||||
if (!lib) return;
|
||||
if (id === state.currentId) store.flushSave();
|
||||
await writeQueue;
|
||||
const [meta, json] = await Promise.all([lib.getMeta(id), lib.getContent(id)]);
|
||||
if (!meta || json === undefined) return;
|
||||
const now = Date.now();
|
||||
await enqueue(() =>
|
||||
lib!.put({ id: newDocId(), title: uniqueTitle(`${meta.title} (Kopie)`), createdAt: now, updatedAt: now }, json),
|
||||
);
|
||||
await refreshList();
|
||||
post({ type: 'list' });
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!lib) return;
|
||||
await enqueue(() => lib!.remove(id));
|
||||
await refreshList();
|
||||
post({ type: 'deleted', id });
|
||||
if (id === state.currentId) await openFallback();
|
||||
}
|
||||
|
||||
/** Most recently changed document, or a new blank one. */
|
||||
async function openFallback() {
|
||||
state.currentId = null;
|
||||
const next = state.docs[0];
|
||||
if (!next || !(await open(next.id, { push: false }))) await createBlank();
|
||||
}
|
||||
|
||||
// ---- other tabs ------------------------------------------------------
|
||||
async function onMessage(m: Msg) {
|
||||
if (m.type === 'list') {
|
||||
await refreshList();
|
||||
updateWindowTitle();
|
||||
} else if (m.type === 'saved') {
|
||||
await refreshList();
|
||||
// our own unsaved edit wins (it's written right after); otherwise
|
||||
// follow -- a pending save that's only a cursor move doesn't count
|
||||
const localEdit = store.hasPendingSave() && contentFingerprint(store.doc) !== savedContent;
|
||||
if (m.id === state.currentId && !localEdit && lib) {
|
||||
const [meta, json] = await Promise.all([lib.getMeta(m.id), lib.getContent(m.id)]);
|
||||
if (meta && json !== undefined && m.id === state.currentId) show(m.id, json, meta.updatedAt, true);
|
||||
}
|
||||
} else if (m.type === 'deleted') {
|
||||
await refreshList();
|
||||
if (m.id === state.currentId) {
|
||||
toasts.info('Dieses Dokument wurde in einem anderen Tab gelöscht.');
|
||||
await openFallback();
|
||||
}
|
||||
} else if (m.id === state.currentId) {
|
||||
// hello / here: the same document is open in another tab
|
||||
if (m.type === 'hello') post({ type: 'here', id: m.id });
|
||||
if (!warnedShared.has(m.id)) {
|
||||
warnedShared.add(m.id);
|
||||
toasts.info(
|
||||
'Dieses Dokument ist auch in einem anderen Tab geöffnet. Gleichzeitige Änderungen können sich überschreiben.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- start -----------------------------------------------------------
|
||||
async function migrateLegacy() {
|
||||
for (const key of LEGACY_KEYS) {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = localStorage.getItem(key);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const doc = validateDocument(JSON.parse(raw));
|
||||
const now = Date.now();
|
||||
await lib!.put(
|
||||
{ id: newDocId(), title: 'Mein Dokument', createdAt: now, updatedAt: now },
|
||||
JSON.stringify(doc),
|
||||
);
|
||||
} catch {
|
||||
continue; // corrupt -- leave it where it is
|
||||
}
|
||||
for (const k of LEGACY_KEYS) localStorage.removeItem(k);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
lib = await DocumentLibrary.open();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
state.available = false;
|
||||
state.ready = true;
|
||||
toasts.error('Dokumente können in diesem Browser nicht gespeichert werden (IndexedDB nicht verfügbar).');
|
||||
return;
|
||||
}
|
||||
await migrateLegacy();
|
||||
await refreshList();
|
||||
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
channel = new BroadcastChannel(CHANNEL);
|
||||
channel.onmessage = (e: MessageEvent<Msg>) => void onMessage(e.data);
|
||||
}
|
||||
setPersistHandler(persist);
|
||||
|
||||
const wanted = new URL(location.href).searchParams.get('doc');
|
||||
if (wanted && !(await open(wanted, { push: false }))) {
|
||||
toasts.error('Das angeforderte Dokument gibt es in diesem Browser nicht.');
|
||||
}
|
||||
if (!state.currentId) await openFallback();
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
const id = new URL(location.href).searchParams.get('doc');
|
||||
if (id && id !== state.currentId) void open(id, { push: false });
|
||||
});
|
||||
// don't lose the last 250 ms of typing when the tab goes away
|
||||
window.addEventListener('pagehide', () => store.flushSave());
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') store.flushSave();
|
||||
});
|
||||
state.ready = true;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
current,
|
||||
currentTitle,
|
||||
init,
|
||||
open,
|
||||
create,
|
||||
createBlank,
|
||||
createSample,
|
||||
rename,
|
||||
duplicate,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
let instance: ReturnType<typeof createLibrary> | null = null;
|
||||
|
||||
export function useLibrary() {
|
||||
if (!instance) instance = createLibrary();
|
||||
return instance;
|
||||
}
|
||||
Reference in New Issue
Block a user