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:
1 parent
2d2b321bad
commit
ef8d219cdd
9 files changed
+174
-3
No files matched your search
@@ -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.
|
||||
|
||||
[](https://edentext.app)
|
||||
Sample File from https://sample-files.com/
|
||||
|
||||
+35
-1
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user