feat: the autosave keeps the last three versions of the document

One version every five minutes goes into an IndexedDB store, pictures
inline, and both file menus list them by time; a click puts that text
back and leaves the page setup, the styles and the zones alone. The
browser copy is one document deep, so a bad edit had nothing behind it.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:13 +02:00
1 parent 2d2b321bad
commit ef8d219cdd
9 files changed
+174 -3

No files matched your search

+2 -1
View File
@@ -30,7 +30,8 @@ EdenText is a web-based, powerful word processor for everything from quick notes
> EdenText is young, in **beta** and actively developed — more features are on
> 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.
> you care about. The browser copy also keeps the last three versions of the open
> document — File ▸ Earlier versions puts one back.
[![EdenText editing a multi-column document](docs/screenshot.png)](https://edentext.app)
Sample File from https://sample-files.com/
+35 -1
View File
@@ -46,6 +46,7 @@
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';
@@ -68,7 +69,7 @@
import { OPEN_THESAURUS_EVENT } from './lib/spell/thesaurus';
import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte';
import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte';
import { t } from './lib/i18n/i18n.svelte';
import { t, locale } from './lib/i18n/i18n.svelte';
import { withShortcut } from './lib/i18n/shortcut';
import { DEFAULT_SHORTCUTS, matchesEvent, shortcutHint } from './lib/editor/shortcuts';
import { OPEN_LINK_DIALOG_EVENT } from './lib/editor/extensions/link';
@@ -1040,6 +1041,29 @@
}
}
// 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 = [];
@@ -1317,6 +1341,8 @@
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}
@@ -1454,6 +1480,14 @@
<span class="theme-option-hint">{t().app.clearRecentFiles}</span>
</button>
{/if}
{#if snapshots().length}
<div class="theme-heading">{t().app.versions}</div>
{#each snapshots() as at (at)}
<button class="theme-option" onclick={() => handleRestoreSnapshot(at)} role="menuitem">
<span class="recent-name">{snapshotLabel(at)}</span>
</button>
{/each}
{/if}
<button class="theme-option" onclick={() => { exportMenuOpen = false; passwordSetOpen = true; }} role="menuitem">
<span>{t().password.menu}</span>
{#if docProtected}<span class="theme-option-hint">{t().password.menuOn}</span>{/if}
+12 -1
View File
@@ -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,
recentFiles = [], onOpenRecent, onForgetRecent, snapshots = [], onRestoreSnapshot,
}: {
editor: Editor | null;
tick: number;
@@ -118,6 +118,8 @@
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;
@@ -248,6 +250,15 @@
<span class="menu-sub">{t().app.clearRecentFiles}</span>
</button>
{/if}
{#if snapshots.length}
<hr />
{#each snapshots as s (s.at)}
<button onclick={() => { closeMenu(); onRestoreSnapshot?.(s.at); }}>
<Icon name="undo" size={16} />{s.label}
<span class="menu-sub">{t().app.versions}</span>
</button>
{/each}
{/if}
<hr />
<button onclick={() => run(onPrint)} disabled={!editor || pdfBusy}>
<Icon name="print" size={16} />{t().app.print}
+4
View File
@@ -1053,6 +1053,7 @@ 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',
@@ -1096,6 +1097,9 @@ 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.',
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) =>
+4
View File
@@ -1059,6 +1059,7 @@ 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',
@@ -1102,6 +1103,9 @@ 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.',
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) =>
+1
View File
@@ -39,6 +39,7 @@ 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:<key>` 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.
- **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.
+4
View File
@@ -1,5 +1,6 @@
import { t } from '../i18n/i18n.svelte';
import { stashImages, putImages, restoreImages } from './imageStore';
import { keepSnapshot } from './snapshots.svelte';
const STORAGE_KEY = 'edentext-doc';
// Set while a stored document is being handed to the editor, cleared once the editor
@@ -36,6 +37,9 @@ async function write(): Promise<void> {
const { json: slim, blobs } = stashImages(json);
const stashed = await putImages(blobs);
store(stashed ? slim : json);
// Every few minutes one version is kept whole, pictures included — the store the
// localStorage copy is swept against holds only what the open document still uses.
void keepSnapshot(json);
// Pending until stored, so a flush during the round trip still has it; a newer
// document handed in meanwhile stays pending for the write queued behind.
if (pending === json) pending = null;
+58
View File
@@ -0,0 +1,58 @@
import { openDb, idbRequest } from './idb';
// Earlier versions of the autosaved document, in IndexedDB. The browser copy is one
// document deep: a bad edit, a failed load or a swept picture would otherwise take it
// with no way back. A snapshot holds the document's text — margins, styles and the
// header/footer belong to the open document and are left where they are.
const DB_NAME = 'edentext-snapshots';
const STORE = 'snapshots';
// Three is what a session is worth; each holds the pictures inline, unlike the
// localStorage copy, so the store pays for them.
const KEEP = 3;
const EVERY_MS = 5 * 60_000;
let list = $state<number[]>([]);
let lastAt = 0;
/** When each kept version was taken, newest first. */
export function snapshots(): number[] {
return list;
}
async function withDb<T>(run: (db: IDBDatabase) => Promise<T>): Promise<T | null> {
if (typeof indexedDB === 'undefined') return null;
try {
const db = await openDb(DB_NAME, STORE);
const out = await run(db);
db.close();
return out;
} catch {
return null;
}
}
function keys(db: IDBDatabase): Promise<IDBValidKey[]> {
return idbRequest<IDBValidKey[]>(db, STORE, 'readonly', (s) => s.getAllKeys());
}
/** Take one, at most every EVERY_MS, and drop everything past the newest KEEP. */
export async function keepSnapshot(json: object): Promise<void> {
const at = Date.now();
if (at - lastAt < EVERY_MS) return;
lastAt = at;
await withDb(async (db) => {
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<void> {
await withDb(async (db) => { list = (await keys(db)).map(Number).sort((a, b) => b - a); });
}
export async function readSnapshot(at: number): Promise<object | null> {
return withDb((db) => idbRequest<object | undefined>(db, STORE, 'readonly', (s) => s.get(at)))
.then((doc) => doc ?? null);
}
+54
View File
@@ -0,0 +1,54 @@
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<number, unknown>();
const req = (result: unknown) => {
const r: Record<string, unknown> = { 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<string, unknown> = { result: db };
queueMicrotask(() => {
(r.onupgradeneeded as (() => void) | undefined)?.();
(r.onsuccess as (() => void) | undefined)?.();
});
return r;
},
};
}
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 now = vi.spyOn(Date, 'now');
const at = (min: number) => 1_800_000_000_000 + min * 60_000;
now.mockReturnValue(at(0));
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)]);
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(idb.data.size).toBe(3);
expect(await readSnapshot(at(12))).toEqual({ type: 'doc', v: 12 });
expect(await readSnapshot(at(0))).toBeNull();
});
});