diff --git a/README.md b/README.md
index 922aff5..ca4df3a 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ EdenText is a web-based, powerful word processor for everything from quick notes
> the way. It is tested — the full suite plus LibreOffice round-trip checks run
> on every commit — but expect occasional bugs, and keep backups of documents
> you care about. The browser copy also keeps the last three versions of the open
-> document — File ▸ Earlier versions puts one back.
+> document, and offers them if it ever fails to load one.
[](https://edentext.app)
Sample File from https://sample-files.com/
diff --git a/src/App.svelte b/src/App.svelte
index 7880bca..540195d 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -46,7 +46,6 @@
import { loadLineNumbering, saveLineNumbering, DEFAULT_LINE_NUMBERING, type LineNumbering } from './lib/storage/lineNumbering';
import { loadFoldMarks, saveFoldMarks } from './lib/storage/foldMarks';
import { printMarkup } from './lib/storage/printMarkup.svelte';
- import { snapshots, loadSnapshots, readSnapshot } from './lib/storage/snapshots.svelte';
import { commentsInPane, changesInPane, markupAttrs, setShowChanges, setShowComments } from './lib/storage/markup.svelte';
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
import { setTableLanguage } from './lib/storage/tableOptions.svelte';
@@ -1071,29 +1070,6 @@
}
}
- // The versions the autosave keeps aside, and the reader's label for one.
- void loadSnapshots();
- function snapshotLabel(at: number): string {
- return new Date(at).toLocaleString(locale(), { dateStyle: 'short', timeStyle: 'short' });
- }
-
- // Put an earlier version's text back. The page setup, the styles and the zones
- // belong to the open document, so only the text is replaced.
- async function handleRestoreSnapshot(at: number) {
- exportMenuOpen = false;
- if (!editor || !confirm(t().dialogs.confirmRestore(snapshotLabel(at)))) return;
- const json = await readSnapshot(at);
- if (!json) {
- alert(t().dialogs.snapshotGone);
- void loadSnapshots();
- return;
- }
- loadContent(json as Content);
- documentEpoch++;
- resetHistory();
- editor.commands.focus();
- }
-
function handleForgetRecent() {
forgetRecentFiles();
recentFiles = [];
@@ -1371,8 +1347,6 @@
recentFiles={recentFiles}
onOpenRecent={(id) => { const f = recentFiles.find((r) => r.id === id); if (f) void handleOpenRecent(f); }}
onForgetRecent={handleForgetRecent}
- snapshots={snapshots().map((at) => ({ at, label: snapshotLabel(at) }))}
- onRestoreSnapshot={(at) => void handleRestoreSnapshot(at)}
onExportPdf={handleExportPdf}
onPrintPdf={handlePrintPdf}
onPrint={handlePrint}
@@ -1510,14 +1484,6 @@
{t().app.clearRecentFiles}
{/if}
- {#if snapshots().length}
-
{t().app.versions}
- {#each snapshots() as at (at)}
- handleRestoreSnapshot(at)} role="menuitem">
- {snapshotLabel(at)}
-
- {/each}
- {/if}
{ exportMenuOpen = false; passwordSetOpen = true; }} role="menuitem">
{t().password.menu}
{#if docProtected}{t().password.menuOn} {/if}
diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte
index 4b65d11..be1d7e7 100644
--- a/src/lib/components/ribbon/Ribbon.svelte
+++ b/src/lib/components/ribbon/Ribbon.svelte
@@ -72,7 +72,7 @@
hasPassword = false,
onNew, onNewFromTemplate, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onProtect, onAutoCorrect, onAutoText, onNewComment,
navigatorOpen = false, onToggleNavigator,
- recentFiles = [], onOpenRecent, onForgetRecent, snapshots = [], onRestoreSnapshot,
+ recentFiles = [], onOpenRecent, onForgetRecent,
}: {
editor: Editor | null;
tick: number;
@@ -118,8 +118,6 @@
onSaveDocx?: () => void;
onSaveTemplate?: () => void;
recentFiles?: { id: string; name: string }[];
- snapshots?: { at: number; label: string }[];
- onRestoreSnapshot?: (at: number) => void;
onOpenRecent?: (id: string) => void;
onForgetRecent?: () => void;
onExportPdf?: () => void;
@@ -250,15 +248,6 @@
{/if}
- {#if snapshots.length}
-
- {#each snapshots as s (s.at)}
- { closeMenu(); onRestoreSnapshot?.(s.at); }}>
- {s.label}
-
-
- {/each}
- {/if}
run(onPrint)} disabled={!editor || pdfBusy}>
{t().app.print}
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index 8bd28c7..ce2832f 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -1053,7 +1053,6 @@ const de: Messages = {
vectorHint: 'Scharf, aber Probleme mit Tabellen',
template: 'Vorlage',
templateHint: '.ott / .dotx — öffnet sich als neues Dokument',
- versions: 'Frühere Fassungen',
recentFiles: 'Zuletzt verwendet',
clearRecentFiles: 'Liste leeren',
print: 'Drucken',
@@ -1097,9 +1096,8 @@ const de: Messages = {
couldNotPrintPdf: 'Druck als PDF nicht möglich.',
autosaveQuota:
'Das Dokument ist zu groß, um automatisch gespeichert zu werden (Speicherlimit des Browsers erreicht). Das passiert meist bei großen eingebetteten Bildern. Speichere es als .odt-Datei, um deine Arbeit zu sichern.',
- confirmRestore: (when) =>
- `Fassung von ${when} wiederherstellen? Der aktuelle Text wird dadurch ersetzt; Seitenformat, Vorlagen und Kopfzeile bleiben, wie sie sind.`,
- snapshotGone: 'Diese Fassung ist nicht mehr im Browser-Speicher.',
+ openSnapshot: (when: string) =>
+ `Das Dokument konnte nicht geladen werden. Stattdessen die vom Editor gesicherte Fassung von ${when} öffnen?`,
documentNotLoaded:
'Das zuletzt bearbeitete Dokument hat den Start des Editors blockiert. Trotzdem noch einmal laden?\n\nAbbrechen startet mit einem neuen, leeren Dokument; das alte bleibt im Browser-Speicher unter „edentext-doc-broken“ erhalten.',
remoteImagesDropped: (n) =>
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index cfcb41a..e4f5452 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -1059,7 +1059,6 @@ const en = {
vectorHint: 'Sharp, but problems with tables',
template: 'Template',
templateHint: '.ott / .dotx — opens as a new document',
- versions: 'Earlier versions',
recentFiles: 'Recent files',
clearRecentFiles: 'Clear the list',
print: 'Print',
@@ -1103,9 +1102,8 @@ const en = {
couldNotPrintPdf: 'Could not print to PDF.',
autosaveQuota:
'The document is too large to save automatically (browser storage limit reached). This usually happens with large embedded images. Save it as an .odt file to keep your work.',
- confirmRestore: (when: string) =>
- `Restore the version from ${when}? It replaces the current text; page setup, styles and the header stay as they are.`,
- snapshotGone: 'That version is no longer in the browser storage.',
+ openSnapshot: (when: string) =>
+ `The document could not be loaded. Open the version the editor kept at ${when} instead?`,
documentNotLoaded:
'The last document stopped the editor from starting. Load it again anyway?\n\nCancel starts a new, empty document; the old one is kept in the browser storage under "edentext-doc-broken".',
remoteImagesDropped: (n: number) =>
diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md
index 954f964..0773bee 100644
--- a/src/lib/storage/CLAUDE.md
+++ b/src/lib/storage/CLAUDE.md
@@ -38,8 +38,8 @@ Both margins and orientation are passed into the ODT export so the exported docu
All keys share the `edentext-` prefix — a namespace, not a description: on GitHub Pages every repo of an account shares one origin, so a generic prefix would collide.
-- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
-- **Earlier versions:** the `edentext-snapshots` IndexedDB store (`snapshots.svelte.ts`) — up to three earlier autosaves, one taken at most every five minutes, keyed by their timestamp. They hold the document **whole**, pictures inline: the image store is swept against the open document, so an older version could not point into it. Both file menus list them; restoring puts only the **text** back, since the page setup, the styles and the zones belong to the open document. A reactive singleton, so the list a menu shows is the one the last autosave wrote.
+- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the kept versions are offered before the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
+- **Earlier versions:** the `edentext-snapshots` IndexedDB store (`snapshots.ts`) — up to three earlier autosaves, one taken at most every five minutes, keyed by their timestamp. They hold the document **whole**, pictures inline: the image store is swept against the open document, so an older version could not point into it. **Nothing in the UI points at them**: they are offered only where the browser copy has failed the reader — `loadDocument`'s recovery question, newest first, one confirm each (the newest may be the one that broke). A version taken up starts under the boot flag like any other document, so one that freezes the editor again brings the question back.
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. A file without a handle is never recorded (nothing could reopen it — no File System Access API, or a template), so the list simply stays empty in Firefox/Safari/Brave; a startup prune drops entries whose handle is gone. Reopening asks for `read` permission again (the API drops it on reload) — a declined prompt keeps the entry for the next try, only a moved or deleted file drops it (with a message). Save re-prompts for write on its own (Save-As fallback in `App.svelte`).
- **Document protected:** `edentext-doc-protected` — `'1'` while the open document is password-protected; the password itself is never stored, so after a reload the first save asks for it again or lets the protection go (`documentName.ts`).
- **Document format:** `edentext-doc-format` — `'docx'` while the open document round-trips as .docx; absent = .odt (`documentName.ts`). Set by the format a file was opened in, and by the extension picked in Save As.
diff --git a/src/lib/storage/autosave.ts b/src/lib/storage/autosave.ts
index 2248a24..0438af7 100644
--- a/src/lib/storage/autosave.ts
+++ b/src/lib/storage/autosave.ts
@@ -1,6 +1,6 @@
-import { t } from '../i18n/i18n.svelte';
+import { t, locale } from '../i18n/i18n.svelte';
import { stashImages, putImages, restoreImages } from './imageStore';
-import { keepSnapshot } from './snapshots.svelte';
+import { keepSnapshot, listSnapshots, readSnapshot } from './snapshots';
const STORAGE_KEY = 'edentext-doc';
// Set while a stored document is being handed to the editor, cleared once the editor
@@ -72,16 +72,37 @@ if (typeof document !== 'undefined') {
addEventListener('pagehide', flushDocument);
}
+// A document that cannot be loaded is the one place the kept versions are offered:
+// nothing in the UI points at them, so this is where a reader meets them. Newest first,
+// one question each — the newest may be the one that broke.
+async function offerSnapshot(): Promise {
+ for (const at of await listSnapshots()) {
+ const when = new Date(at).toLocaleString(locale(), { dateStyle: 'short', timeStyle: 'short' });
+ if (!confirm(t().dialogs.openSnapshot(when))) continue;
+ const doc = await readSnapshot(at);
+ if (doc) return doc;
+ }
+ return null;
+}
+
export async function loadDocument(): Promise {
const raw = localStorage.getItem(STORAGE_KEY);
if (localStorage.getItem(BOOT_KEY)) {
localStorage.removeItem(BOOT_KEY);
// The user decides: a reload that cut a slow start short is the usual cause, and
- // another try costs nothing a reload would not fix. Given up, the document is parked.
- if (raw && !confirm(t().dialogs.documentNotLoaded)) {
- localStorage.setItem(BROKEN_KEY, raw);
- localStorage.removeItem(STORAGE_KEY);
- return null;
+ // another try costs nothing a reload would not fix. Given up, the document is parked
+ // and the versions the autosave kept aside are offered in its place.
+ if (!raw || !confirm(t().dialogs.documentNotLoaded)) {
+ if (raw) {
+ localStorage.setItem(BROKEN_KEY, raw);
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ const rescued = await offerSnapshot();
+ if (!rescued) return null;
+ // Under the same flag as any other document: one that freezes the editor again
+ // brings this question back instead of repeating the freeze.
+ localStorage.setItem(BOOT_KEY, '1');
+ return rescued;
}
}
if (!raw) return null;
diff --git a/src/lib/storage/snapshots.svelte.ts b/src/lib/storage/snapshots.ts
similarity index 87%
rename from src/lib/storage/snapshots.svelte.ts
rename to src/lib/storage/snapshots.ts
index 3a3e6d7..bc304f8 100644
--- a/src/lib/storage/snapshots.svelte.ts
+++ b/src/lib/storage/snapshots.ts
@@ -11,14 +11,8 @@ const STORE = 'snapshots';
const KEEP = 3;
const EVERY_MS = 5 * 60_000;
-let list = $state([]);
let lastAt = 0;
-/** When each kept version was taken, newest first. */
-export function snapshots(): number[] {
- return list;
-}
-
async function withDb(run: (db: IDBDatabase) => Promise): Promise {
if (typeof indexedDB === 'undefined') return null;
try {
@@ -44,12 +38,12 @@ export async function keepSnapshot(json: object): Promise {
await idbRequest(db, STORE, 'readwrite', (s) => s.put(json, at));
const all = (await keys(db)).map(Number).sort((a, b) => b - a);
for (const old of all.slice(KEEP)) await idbRequest(db, STORE, 'readwrite', (s) => s.delete(old));
- list = all.slice(0, KEEP);
});
}
-export async function loadSnapshots(): Promise {
- await withDb(async (db) => { list = (await keys(db)).map(Number).sort((a, b) => b - a); });
+/** When each kept version was taken, newest first. */
+export async function listSnapshots(): Promise {
+ return (await withDb(async (db) => (await keys(db)).map(Number).sort((a, b) => b - a))) ?? [];
}
export async function readSnapshot(at: number): Promise {
diff --git a/tests/fakeIdb.ts b/tests/fakeIdb.ts
new file mode 100644
index 0000000..4f21cf0
--- /dev/null
+++ b/tests/fakeIdb.ts
@@ -0,0 +1,27 @@
+// A two-call IndexedDB in memory — what openDb/idbRequest (storage/idb.ts) ask for.
+export function fakeIndexedDb() {
+ const data = new Map();
+ const req = (result: unknown) => {
+ const r: Record = { result };
+ queueMicrotask(() => (r.onsuccess as (() => void) | undefined)?.());
+ return r;
+ };
+ const store = {
+ put: (v: unknown, k: number) => { data.set(k, v); return req(undefined); },
+ get: (k: number) => req(data.get(k)),
+ getAllKeys: () => req([...data.keys()]),
+ delete: (k: number) => { data.delete(k); return req(undefined); },
+ };
+ const db = { transaction: () => ({ objectStore: () => store }), createObjectStore: () => {}, close: () => {} };
+ return {
+ data,
+ open: () => {
+ const r: Record = { result: db };
+ queueMicrotask(() => {
+ (r.onupgradeneeded as (() => void) | undefined)?.();
+ (r.onsuccess as (() => void) | undefined)?.();
+ });
+ return r;
+ },
+ };
+}
diff --git a/tests/unit/autosave-recovery.test.ts b/tests/unit/autosave-recovery.test.ts
index 056820f..75a12bf 100644
--- a/tests/unit/autosave-recovery.test.ts
+++ b/tests/unit/autosave-recovery.test.ts
@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { loadDocument, markDocumentLoaded, saveDocument } from '../../src/lib/storage/autosave';
+import { keepSnapshot } from '../../src/lib/storage/snapshots';
+import { fakeIndexedDb } from '../fakeIdb';
// A document that freezes the editor would be reloaded from localStorage forever, so
// loading raises a boot flag that only a completed startup clears. Loading is async
@@ -48,6 +50,43 @@ describe('autosave crash recovery', () => {
});
});
+// Nothing in the UI points at the kept versions: a document that cannot be loaded is
+// where they are offered, one question each, newest first.
+describe('a document that cannot be loaded falls back on a kept version', () => {
+ // A version is kept at most every five minutes, and an earlier test in this file has
+ // already taken one — so each case sets its own clock well past that window.
+ let clock = 2_000_000_000_000;
+ beforeEach(() => {
+ localStorage.clear();
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { void cb; return 0; });
+ vi.stubGlobal('alert', () => {});
+ vi.stubGlobal('indexedDB', { open: fakeIndexedDb().open });
+ vi.spyOn(Date, 'now').mockReturnValue((clock += 600_000));
+ });
+
+ it('offers the version once the reader gives up on the document', async () => {
+ await keepSnapshot({ type: 'doc', content: [{ type: 'paragraph' }] });
+ localStorage.setItem('edentext-doc', '{"type":"doc"}');
+ await loadDocument(); // raises the boot flag, then the "editor hangs"
+ // First question: another try, declined. Second: the kept version, taken.
+ let asked = 0;
+ vi.stubGlobal('confirm', () => asked++ > 0);
+ expect(await loadDocument()).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] });
+ expect(asked).toBe(2);
+ // The parked document stays where it was, and the version starts under the flag.
+ expect(localStorage.getItem('edentext-doc-broken')).toBe('{"type":"doc"}');
+ expect(localStorage.getItem('edentext-doc-loading')).toBe('1');
+ });
+
+ it('starts empty when every version is turned down', async () => {
+ await keepSnapshot({ type: 'doc', content: [] });
+ localStorage.setItem('edentext-doc', '{"type":"doc"}');
+ await loadDocument();
+ vi.stubGlobal('confirm', () => false);
+ expect(await loadDocument()).toBeNull();
+ });
+});
+
// The debounce is a second; a tab closed inside it would lose that second. Hiding
// the page writes what is pending at once, without the IndexedDB round trip.
describe('autosave flush on pagehide', () => {
diff --git a/tests/unit/snapshots.test.ts b/tests/unit/snapshots.test.ts
index 2324596..1b4966a 100644
--- a/tests/unit/snapshots.test.ts
+++ b/tests/unit/snapshots.test.ts
@@ -1,38 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
-
-// A two-call IndexedDB in memory — what openDb/idbRequest (storage/idb.ts) ask for.
-function fakeIndexedDb() {
- const data = new Map();
- const req = (result: unknown) => {
- const r: Record = { result };
- queueMicrotask(() => (r.onsuccess as (() => void) | undefined)?.());
- return r;
- };
- const store = {
- put: (v: unknown, k: number) => { data.set(k, v); return req(undefined); },
- get: (k: number) => req(data.get(k)),
- getAllKeys: () => req([...data.keys()]),
- delete: (k: number) => { data.delete(k); return req(undefined); },
- };
- const db = { transaction: () => ({ objectStore: () => store }), createObjectStore: () => {}, close: () => {} };
- return {
- data,
- open: () => {
- const r: Record = { result: db };
- queueMicrotask(() => {
- (r.onupgradeneeded as (() => void) | undefined)?.();
- (r.onsuccess as (() => void) | undefined)?.();
- });
- return r;
- },
- };
-}
+import { fakeIndexedDb } from '../fakeIdb';
describe('the autosave keeps a few earlier versions', () => {
it('takes one every few minutes and holds on to the newest three', async () => {
const idb = fakeIndexedDb();
vi.stubGlobal('indexedDB', { open: idb.open });
- const { keepSnapshot, snapshots, readSnapshot } = await import('../../src/lib/storage/snapshots.svelte');
+ const { keepSnapshot, listSnapshots, readSnapshot } = await import('../../src/lib/storage/snapshots');
const now = vi.spyOn(Date, 'now');
const at = (min: number) => 1_800_000_000_000 + min * 60_000;
@@ -40,13 +13,13 @@ describe('the autosave keeps a few earlier versions', () => {
await keepSnapshot({ type: 'doc', v: 1 });
now.mockReturnValue(at(1));
await keepSnapshot({ type: 'doc', v: 2 }); // a minute on: still the same version
- expect(snapshots()).toEqual([at(0)]);
+ expect(await listSnapshots()).toEqual([at(0)]);
for (const m of [6, 12, 18]) {
now.mockReturnValue(at(m));
await keepSnapshot({ type: 'doc', v: m });
}
- expect(snapshots()).toEqual([at(18), at(12), at(6)]);
+ expect(await listSnapshots()).toEqual([at(18), at(12), at(6)]);
expect(idb.data.size).toBe(3);
expect(await readSnapshot(at(12))).toEqual({ type: 'doc', v: 12 });
expect(await readSnapshot(at(0))).toBeNull();