diff --git a/CLAUDE.md b/CLAUDE.md index f261c19..5ab8914 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ npm run test:smoke # boots the dist/ build headless (tests/smoke/run.mjs); BRO npm run test:dom # pagination + editing in the real browser (tests/dom/run.mjs); same BROWSER npm run test:layout # page counts vs LibreOffice, layout invariants, page starts vs baseline.json (tests/layout/) npm run test:monkey # random editing, body + header/footer, under invariants: schema, undo/redo, the saved file reads back +npm run test:tabs # two tabs, two documents: each keeps its own across reloads (tests/tabs/) npm run test:coverage # vitest + v8 coverage over src/ → coverage/index.html npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.md) node scripts/make-thesaurus.mjs; node scripts/collect-licenses.mjs # re-vendor public/thesaurus/ (MyThes); regenerate public/licenses.txt @@ -31,7 +32,7 @@ Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom v `corpus.test.ts` round-trips the committed `tests/corpus/` documents (by `make-fixtures.mjs` + Word re-saves in `corpus/word/`, never our exporter); `fuzz-roundtrip.test.ts` round-trips seeded random documents under random export options (`fuzzDoc.ts`, `fuzzOptions.ts`) through both formats and validates every export against the schemas (`schemaValidate.ts`; `FUZZ_SEEDS=500` widens it); `schema-validation.test.ts` validates the `kitchenSink.ts` exports against the vendored schemas in `tests/schemas/` (self-skips without `xmllint`); `package-lint.test.ts` checks the semantic invariants schemas can't express (unique ids, dangling references); `tests/unit/` holds fast helper tests; -the four browser runs (`tests/smoke/`, `tests/dom/`, `tests/layout/`, `tests/monkey/`) share `tests/browser.mjs`. All test tooling stays a `devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`. +the five browser runs (`tests/smoke/`, `tests/dom/`, `tests/layout/`, `tests/monkey/`, `tests/tabs/`) share `tests/browser.mjs`. All test tooling stays a `devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`. ## Rules diff --git a/package.json b/package.json index 1d77ecd..660b035 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:dom": "node tests/dom/run.mjs", "test:layout": "node tests/layout/run.mjs", "test:monkey": "node tests/monkey/run.mjs", + "test:tabs": "node tests/tabs/run.mjs", "test:coverage": "vitest run --coverage", "test:parity": "node tests/render-parity/run.mjs", "test:parity:fixtures": "node tests/render-parity/make-fixtures.mjs" diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 0773bee..cce9245 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -38,8 +38,22 @@ 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 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. +**Document scope (`docScope.ts`).** Every key below that belongs to the *document* is +written through `docKey()`, which appends `@` — that is how two tabs edit two +documents. The **first document keeps the empty id**, so its keys read exactly as this +list spells them and no earlier version needs migrating. The id comes out of +`sessionStorage` (per tab, survives its reloads) or, for a fresh tab, from the most +recently used document that no live tab holds; `edentext-live@` is that marker — +epoch ms while held, negated on `pagehide`, refreshed every 60 s and abandoned after 10. +Only the holding tab ever writes its own marker, so no two tabs race over one key. +`pruneOldDocuments()` (from `main.ts`) drops all but the newest ten, never one a tab +still holds and never the first. Duplicating a tab copies its `sessionStorage`, so the +copy lands on the same document — the one case still shared. The app's own keys (theme, +zoom, chrome, AutoCorrect, AutoText, word completion, recent files/fonts/chars, markup +view, user dictionary, number recognition) stay unscoped. + +- **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, across documents too — which is why the store is the one thing **not** scoped per document. Each save sweeps the keys no stored document names any more, found by scanning localStorage for `idb:` references rather than by a per-document list, which would go stale for every document no tab has opened since. 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 database (`snapshots.ts`, one per document via `docKey`, as `edentext-fonts` is) — 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 stored documents, and a snapshot is none of them. **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 0438af7..b509f91 100644 --- a/src/lib/storage/autosave.ts +++ b/src/lib/storage/autosave.ts @@ -1,15 +1,16 @@ import { t, locale } from '../i18n/i18n.svelte'; import { stashImages, putImages, restoreImages } from './imageStore'; import { keepSnapshot, listSnapshots, readSnapshot } from './snapshots'; +import { docKey } from './docScope'; -const STORAGE_KEY = 'edentext-doc'; +const STORAGE_KEY = docKey('edentext-doc'); // Set while a stored document is being handed to the editor, cleared once the editor // has actually come up (markDocumentLoaded). Finding it still set at load time means // the last attempt hung or threw — reloading would only freeze again. -const BOOT_KEY = 'edentext-doc-loading'; +const BOOT_KEY = docKey('edentext-doc-loading'); // Where such a document is parked instead of being loaded, so nothing is lost and // it can still be pulled out of localStorage. -const BROKEN_KEY = 'edentext-doc-broken'; +const BROKEN_KEY = docKey('edentext-doc-broken'); const DEBOUNCE_MS = 1000; let timeout: ReturnType | null = null; diff --git a/src/lib/storage/docProperties.ts b/src/lib/storage/docProperties.ts index bc33ce9..981efca 100644 --- a/src/lib/storage/docProperties.ts +++ b/src/lib/storage/docProperties.ts @@ -1,3 +1,5 @@ +import { docKey } from './docScope'; + // The document's descriptive metadata — LibreOffice's File ▸ Properties, Word's // File ▸ Info. Written to ODF meta.xml and DOCX docProps/core.xml, read back on import. @@ -12,7 +14,7 @@ export type DocProperties = { description: string; }; -const KEY = 'edentext-doc-properties'; +const KEY = docKey('edentext-doc-properties'); export const EMPTY_DOC_PROPERTIES: DocProperties = { title: '', subject: '', author: '', keywords: '', description: '', diff --git a/src/lib/storage/docScope.ts b/src/lib/storage/docScope.ts new file mode 100644 index 0000000..09feabd --- /dev/null +++ b/src/lib/storage/docScope.ts @@ -0,0 +1,109 @@ +// Which document this tab edits. Every document-scoped key hangs off the id, so two +// tabs never write over each other; the first document keeps the empty id, and with it +// the bare key names every earlier version wrote. + +// Duplicating a tab copies its sessionStorage, so the copy lands on the same document. +// ponytail: telling a duplicate from a reload needs the navigation type — worth +// measuring per browser before anything relies on it. +const TAB_KEY = 'edentext-tab-doc'; +// One marker per document: epoch ms while a tab holds it, the same stamp negated once +// the tab lets go. Only ever written by the tab that holds it, so no two tabs race over +// one key, and the stamp doubles as "when was this document last used". +const LIVE = 'edentext-live@'; +// A hidden tab's timers are throttled to about a minute, so a marker is only abandoned +// well past that. +const BEAT_MS = 60_000; +const STALE_MS = 10 * 60_000; +const MAX_DOCS = 10; + +function markers(): Record { + const out: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(LIVE)) continue; + const at = Number(localStorage.getItem(key)); + if (at) out[key.slice(LIVE.length)] = at; + } + return out; +} + +/** + * The document a fresh tab opens: the one used last that no live tab holds, else a new + * one. The empty id is the first document — a browser that has never run this editor + * has no marker at all, and so does one that ran every earlier version. + */ +export function pickSlot(marks: Record, now: number): string { + const free = Object.entries(marks) + .filter(([, at]) => at < 0 || now - at > STALE_MS) + .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))[0]; + if (free) return free[0]; + if (!Object.keys(marks).length) return ''; + return `d${now.toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`; +} + +function mark(id: string, at: number): void { + try { + localStorage.setItem(LIVE + id, String(at)); + } catch { /* a full localStorage costs the marker, not the document */ } +} + +function resolve(): string { + try { + // The tab's own slot, unconditionally: after a reload — or a crash — it has to get + // its document back, and nothing but this tab knows which one that was. + const mine = sessionStorage.getItem(TAB_KEY); + const id = mine ?? pickSlot(markers(), Date.now()); + if (mine === null) sessionStorage.setItem(TAB_KEY, id); + // Claimed here rather than from main.ts: the storage modules read their keys while + // the import graph is walked, long before the first statement of main.ts runs. + mark(id, Date.now()); + return id; + } catch { + return ''; + } +} + +export const docId = resolve(); + +/** The key this tab's document keeps `name` under. */ +export function docKey(name: string): string { + return docId === '' ? name : `${name}@${docId}`; +} + +/** Keep this tab's marker fresh, and sign it off when the tab goes away. */ +export function startTabPresence(): void { + setInterval(() => mark(docId, Date.now()), BEAT_MS); + addEventListener('pagehide', () => mark(docId, -Date.now())); + // A page revived from the back/forward cache is holding its document again. + addEventListener('pageshow', () => mark(docId, Date.now())); +} + +function dropDocument(id: string): void { + const suffix = `@${id}`; + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.endsWith(suffix)) keys.push(key); + } + for (const key of keys) localStorage.removeItem(key); + // Its pictures are swept the next time any document saves — the store is shared. + if (typeof indexedDB === 'undefined') return; + indexedDB.deleteDatabase(`edentext-snapshots${suffix}`); + indexedDB.deleteDatabase(`edentext-fonts${suffix}`); +} + +/** + * Keep the newest MAX_DOCS documents. A document a tab still holds is never dropped, + * however old it is, and neither is the first one: its keys carry no id to sweep by. + */ +export function pruneOldDocuments(): void { + const now = Date.now(); + const old = Object.entries(markers()) + .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) + .slice(MAX_DOCS); + for (const [id, at] of old) { + if (id === '' || (at > 0 && now - at <= STALE_MS)) continue; + dropDocument(id); + localStorage.removeItem(LIVE + id); + } +} diff --git a/src/lib/storage/documentLanguage.ts b/src/lib/storage/documentLanguage.ts index 4da4be7..3f1a71f 100644 --- a/src/lib/storage/documentLanguage.ts +++ b/src/lib/storage/documentLanguage.ts @@ -3,6 +3,7 @@ // the default paragraph style). 'none' disables checking. import { resolveBrowserLocale } from '../i18n/config'; +import { docKey } from './docScope'; export const NO_LANGUAGE = 'none'; @@ -22,7 +23,7 @@ export const LANGUAGES: LanguageDef[] = [ { code: 'de', label: 'Deutsch', odf: { language: 'de', country: 'DE' } }, ]; -const KEY = 'edentext-doc-language'; +const KEY = docKey('edentext-doc-language'); export function findLanguage(code: DocumentLanguage): LanguageDef | undefined { return LANGUAGES.find((l) => l.code === code); diff --git a/src/lib/storage/documentName.ts b/src/lib/storage/documentName.ts index 1f3d68e..4c38e26 100644 --- a/src/lib/storage/documentName.ts +++ b/src/lib/storage/documentName.ts @@ -1,7 +1,9 @@ +import { docKey } from './docScope'; + // The user-visible document name (without the .odt extension). Drives the // suggested filename on save; empty falls back to the heading-derived name. -const KEY = 'edentext-doc-name'; +const KEY = docKey('edentext-doc-name'); export function loadDocName(): string { return localStorage.getItem(KEY) ?? ''; @@ -39,7 +41,7 @@ export type DocumentFormat = 'odt' | 'docx'; // The format the open document round-trips in. Absent at the .odt default, so only a // document that came in as .docx writes anything. -const FORMAT_KEY = 'edentext-doc-format'; +const FORMAT_KEY = docKey('edentext-doc-format'); export function loadDocFormat(): DocumentFormat { return localStorage.getItem(FORMAT_KEY) === 'docx' ? 'docx' : 'odt'; @@ -52,7 +54,7 @@ export function saveDocFormat(format: DocumentFormat): void { // Whether the open document is password-protected. The password itself is never // stored, so after a reload this is what makes the first save ask for it again. -const PROTECTED_KEY = 'edentext-doc-protected'; +const PROTECTED_KEY = docKey('edentext-doc-protected'); export function loadDocProtected(): boolean { return localStorage.getItem(PROTECTED_KEY) === '1'; diff --git a/src/lib/storage/embeddedFontStore.ts b/src/lib/storage/embeddedFontStore.ts index 18e5f14..3ae89d2 100644 --- a/src/lib/storage/embeddedFontStore.ts +++ b/src/lib/storage/embeddedFontStore.ts @@ -1,10 +1,11 @@ import type { EmbeddedFont } from '../fonts/embeddedFonts'; import { openDb, idbRequest } from './idb'; +import { docKey } from './docScope'; // The current document's embedded fonts, persisted in IndexedDB (not localStorage, whose // ~5 MB quota is already tight for images). One record, replaced on each open, so storage // stays bounded to the open document; on app start it is re-registered via FontFace. -const DB_NAME = 'edentext-fonts'; +const DB_NAME = docKey('edentext-fonts'); const STORE = 'fonts'; const KEY = 'current'; diff --git a/src/lib/storage/foldMarks.ts b/src/lib/storage/foldMarks.ts index 1aa7a23..da2ba5d 100644 --- a/src/lib/storage/foldMarks.ts +++ b/src/lib/storage/foldMarks.ts @@ -1,8 +1,10 @@ +import { docKey } from './docScope'; + // Fold and punch marks in the left margin (DIN 5008 letter sheets). Off by default — // neither word processor prints any — so a fresh document writes nothing. They ride the // master page's header on export, like the watermark, as named /VML lines. -const KEY = 'edentext-fold-marks'; +const KEY = docKey('edentext-fold-marks'); /** Distances from the top paper edge, in mm (DIN 5008: fold marks and punch mark). */ export const FOLD_MARK_MM = [87, 192]; diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 46dc476..7ff32d0 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -6,6 +6,7 @@ import type { PageMargins } from './pageMargins'; import type { PageFormat } from './pageFormat'; import type { Orientation } from './pageOrientation'; import type { NoteNumFormat } from './noteSettings'; +import { docKey } from './docScope'; export type HfZone = 'header' | 'footer'; export type HfVariant = 'default' | 'first' | 'even'; @@ -73,7 +74,7 @@ export function hfSetIsEmpty(s: HfSet): boolean { // Sections past the first, in order. Persisted whole rather than per zone like // section 1's, whose six docs each have their own key. -const EXTRA_KEY = 'edentext-hf-sections'; +const EXTRA_KEY = docKey('edentext-hf-sections'); export function loadExtraHfSections(): HfSet[] { const raw = localStorage.getItem(EXTRA_KEY); @@ -92,14 +93,14 @@ export function saveExtraHfSections(sections: HfSet[]): void { } const KEYS: Record> = { - header: { default: 'edentext-header', first: 'edentext-header-first', even: 'edentext-header-even' }, - footer: { default: 'edentext-footer', first: 'edentext-footer-first', even: 'edentext-footer-even' }, + header: { default: docKey('edentext-header'), first: docKey('edentext-header-first'), even: docKey('edentext-header-even') }, + footer: { default: docKey('edentext-footer'), first: docKey('edentext-footer-first'), even: docKey('edentext-footer-even') }, }; // Whether page 1 uses its own header/footer (Word w:titlePg / ODF header-first). -const DIFFERENT_FIRST_KEY = 'edentext-hf-different-first'; +const DIFFERENT_FIRST_KEY = docKey('edentext-hf-different-first'); // Whether even pages use their own header/footer (Word w:evenAndOddHeaders / ODF header-left). -const DIFFERENT_ODD_EVEN_KEY = 'edentext-hf-odd-even'; +const DIFFERENT_ODD_EVEN_KEY = docKey('edentext-hf-odd-even'); export function loadDifferentFirstPage(): boolean { return localStorage.getItem(DIFFERENT_FIRST_KEY) === 'true'; @@ -130,7 +131,7 @@ export type HfDistances = { header: number; footer: number }; export const DEFAULT_HF_DISTANCES: HfDistances = { header: HF_DISTANCE_CM, footer: HF_DISTANCE_CM }; -const DIST_KEY = 'edentext-hf-distances'; +const DIST_KEY = docKey('edentext-hf-distances'); const DIST_MIN = 0; const DIST_MAX = 10; diff --git a/src/lib/storage/hyphenation.ts b/src/lib/storage/hyphenation.ts index ce7ce5c..bf85442 100644 --- a/src/lib/storage/hyphenation.ts +++ b/src/lib/storage/hyphenation.ts @@ -1,9 +1,11 @@ +import { docKey } from './docScope'; + // Automatic hyphenation for the whole document: LibreOffice's Format ▸ Paragraph ▸ // Text Flow on the default paragraph style, Word's Layout ▸ Hyphenation. Off in both, // so off here. ODF carries it as `fo:hyphenate` on that style, Word as // `w:autoHyphenation` in settings.xml. -const KEY = 'edentext-hyphenation'; +const KEY = docKey('edentext-hyphenation'); export const DEFAULT_HYPHENATION = false; diff --git a/src/lib/storage/imageStore.ts b/src/lib/storage/imageStore.ts index a51e6de..37be852 100644 --- a/src/lib/storage/imageStore.ts +++ b/src/lib/storage/imageStore.ts @@ -56,7 +56,19 @@ export function stashImages(json: object): { json: object; blobs: Map { + const out = new Set(); + for (let i = 0; i < localStorage.length; i++) { + const raw = localStorage.getItem(localStorage.key(i)!) ?? ''; + for (const [, key] of raw.matchAll(/idb:([a-z0-9]+)/g)) out.add(key); + } + return out; +} + +/** Write the pictures and drop every key no document references any more. */ export async function putImages(blobs: Map): Promise { if (!blobs.size && !inUse) return true; if (typeof indexedDB === 'undefined') return false; @@ -67,8 +79,10 @@ export async function putImages(blobs: Map): Promise { for (const [key, src] of blobs) { if (!known.includes(key)) await idbRequest(db, STORE, 'readwrite', (s) => s.put(src, key)); } + // Not from the stored copy yet, so the sweeper's own pictures come from `blobs`. + const live = referenced(); for (const key of known) { - if (!blobs.has(String(key))) await idbRequest(db, STORE, 'readwrite', (s) => s.delete(key)); + if (!blobs.has(String(key)) && !live.has(String(key))) await idbRequest(db, STORE, 'readwrite', (s) => s.delete(key)); } db.close(); return true; diff --git a/src/lib/storage/lineNumbering.ts b/src/lib/storage/lineNumbering.ts index 87d1cb9..9eafbe2 100644 --- a/src/lib/storage/lineNumbering.ts +++ b/src/lib/storage/lineNumbering.ts @@ -1,9 +1,11 @@ +import { docKey } from './docScope'; + // LibreOffice's Tools ▸ Line Numbering / Word's Layout ▸ Line Numbers. Off by default in // both, so a fresh document writes nothing and neither importer stamps it on a file that // declares none. ODF keeps it as one in office:styles, // Word as in every w:sectPr (probed). -const KEY = 'edentext-line-numbering'; +const KEY = docKey('edentext-line-numbering'); export type LineNumbering = { on: boolean; diff --git a/src/lib/storage/noteSettings.ts b/src/lib/storage/noteSettings.ts index 0d4c2ea..05c00e4 100644 --- a/src/lib/storage/noteSettings.ts +++ b/src/lib/storage/noteSettings.ts @@ -1,6 +1,7 @@ import type { OrderedTypeDef } from '../utils/orderedListTypes'; +import { docKey } from './docScope'; -const KEY = 'edentext-notes'; +const KEY = docKey('edentext-notes'); // How footnotes and endnotes are numbered and where they sit. Every default here was // read out of a document LibreOffice saved (docs/architecture/notes.md) — an diff --git a/src/lib/storage/pageDecor.ts b/src/lib/storage/pageDecor.ts index 9a50d5c..071b391 100644 --- a/src/lib/storage/pageDecor.ts +++ b/src/lib/storage/pageDecor.ts @@ -1,10 +1,12 @@ +import { docKey } from './docScope'; + // The page's own decoration: LibreOffice's Format ▸ Page Style ▸ Area and Borders, and // its Format ▸ Watermark. All three are page-level and all three ride the page layout // (the watermark rides the master page's header, where both word processors keep it), // so they share one key. Every field is absent by default — a fresh document decorates // nothing, and neither importer stamps a decoration on a file that has none. -const KEY = 'edentext-page-decor'; +const KEY = docKey('edentext-page-decor'); // A page border is one rule around the whole page, as both word processors draw it: one // width and colour for all four sides, offset from the text by `paddingCm`. diff --git a/src/lib/storage/pageFormat.ts b/src/lib/storage/pageFormat.ts index 3cd91c4..863e7c6 100644 --- a/src/lib/storage/pageFormat.ts +++ b/src/lib/storage/pageFormat.ts @@ -1,5 +1,6 @@ import { PX_PER_CM } from './pageMargins'; import type { Orientation } from './pageOrientation'; +import { docKey } from './docScope'; export type PageFormat = | 'A3' | 'A4' | 'A5' | 'A6' @@ -7,7 +8,7 @@ export type PageFormat = | 'jisB4' | 'jisB5' | 'letter' | 'legal' | 'tabloid' | 'executive' | 'folio' | 'statement'; -const KEY = 'edentext-page-format'; +const KEY = docKey('edentext-page-format'); // Portrait page dimensions in cm per format (width < height). Landscape swaps them. // Grouped A / ISO-B / JIS-B / US, matching the Word/LibreOffice paper menus. diff --git a/src/lib/storage/pageMargins.ts b/src/lib/storage/pageMargins.ts index 27af1e7..e69afa1 100644 --- a/src/lib/storage/pageMargins.ts +++ b/src/lib/storage/pageMargins.ts @@ -1,3 +1,5 @@ +import { docKey } from './docScope'; + // cm. `mirrored` = ODF style:page-usage="mirrored" / Word's mirror margins: left and // right are the inner/outer pair, and an even (left-hand) page swaps them. export type PageMargins = { @@ -19,7 +21,7 @@ export function marginAxisLabel(axis: MarginAxis, m: PageMargins): MarginAxis | return axis === 'left' ? 'inner' : 'outer'; } -const KEY = 'edentext-page-margins'; +const KEY = docKey('edentext-page-margins'); // LibreOffice Writer's default page margins (Word uses 2.54cm all round). Only a new // document gets these — an imported one always adopts its own page geometry. diff --git a/src/lib/storage/pageNumbering.ts b/src/lib/storage/pageNumbering.ts index af89b06..6467140 100644 --- a/src/lib/storage/pageNumbering.ts +++ b/src/lib/storage/pageNumbering.ts @@ -1,4 +1,5 @@ import type { NoteNumFormat } from './noteSettings'; +import { docKey } from './docScope'; // How the page-number field counts: Word's Insert ▸ Page Number ▸ Format Page Numbers, // LibreOffice's page style ▸ Layout Settings. ODF keeps the format on the page layout @@ -7,7 +8,7 @@ import type { NoteNumFormat } from './noteSettings'; export type PageNumbering = { format: NoteNumFormat; start: number }; -const KEY = 'edentext-page-numbering'; +const KEY = docKey('edentext-page-numbering'); export const DEFAULT_PAGE_NUMBERING: PageNumbering = { format: '1', start: 1 }; diff --git a/src/lib/storage/pageOrientation.ts b/src/lib/storage/pageOrientation.ts index 2109c11..5ab1d9b 100644 --- a/src/lib/storage/pageOrientation.ts +++ b/src/lib/storage/pageOrientation.ts @@ -1,6 +1,8 @@ +import { docKey } from './docScope'; + export type Orientation = 'portrait' | 'landscape'; -const KEY = 'edentext-page-orientation'; +const KEY = docKey('edentext-page-orientation'); export function loadOrientation(): Orientation { return localStorage.getItem(KEY) === 'landscape' ? 'landscape' : 'portrait'; diff --git a/src/lib/storage/snapshots.ts b/src/lib/storage/snapshots.ts index bc304f8..b166ce1 100644 --- a/src/lib/storage/snapshots.ts +++ b/src/lib/storage/snapshots.ts @@ -1,10 +1,11 @@ import { openDb, idbRequest } from './idb'; +import { docKey } from './docScope'; // 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 DB_NAME = docKey('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. diff --git a/src/lib/storage/spacingModel.ts b/src/lib/storage/spacingModel.ts index eb3828f..abf7688 100644 --- a/src/lib/storage/spacingModel.ts +++ b/src/lib/storage/spacingModel.ts @@ -1,9 +1,11 @@ +import { docKey } from './docScope'; + // How the space between two blocks is measured. LibreOffice adds the upper block's // space-below to the lower one's space-above ('add', its native ODF behaviour) but takes // only the larger of the two for a Word document ('max') — probed, and per document. export type SpacingModel = 'add' | 'max'; -const KEY = 'edentext-spacing-model'; +const KEY = docKey('edentext-spacing-model'); export function loadSpacingModel(): SpacingModel { return localStorage.getItem(KEY) === 'max' ? 'max' : 'add'; @@ -18,7 +20,7 @@ export function saveSpacingModel(m: SpacingModel): void { // other way round). Off, the block that opens a page loses its space above however the // page broke — probed: the same document renders its heading at 25.4mm with the option // on and at 20.0mm with it off. On is the default both products write. -const AT_START_KEY = 'edentext-spacing-at-page-start'; +const AT_START_KEY = docKey('edentext-spacing-at-page-start'); export function loadSpacingAtPageStart(): boolean { return localStorage.getItem(AT_START_KEY) !== 'false'; diff --git a/src/lib/storage/tabInterval.ts b/src/lib/storage/tabInterval.ts index a596518..409ada7 100644 --- a/src/lib/storage/tabInterval.ts +++ b/src/lib/storage/tabInterval.ts @@ -1,4 +1,6 @@ -const KEY = 'edentext-tab-interval'; +import { docKey } from './docScope'; + +const KEY = docKey('edentext-tab-interval'); // The grid every tab past the last custom stop falls on (Word w:defaultTabStop, ODF // style:tab-stop-distance). LibreOffice's default for a new document. diff --git a/src/lib/storage/trackChanges.svelte.ts b/src/lib/storage/trackChanges.svelte.ts index 1a8bf48..6f5fc1b 100644 --- a/src/lib/storage/trackChanges.svelte.ts +++ b/src/lib/storage/trackChanges.svelte.ts @@ -1,9 +1,11 @@ +import { docKey } from './docScope'; + // Whether the editor records revisions, as a reactive singleton (same shape as // autoCorrect.svelte.ts): the extension reads recordChanges() on every transaction, the // Review tab flips it. Off by default, as in both word processors — and a document that // records nothing writes no revision registry. -const KEY = 'edentext-record-changes'; +const KEY = docKey('edentext-record-changes'); let current = $state(localStorage.getItem(KEY) === 'true'); diff --git a/src/lib/storage/writingMode.ts b/src/lib/storage/writingMode.ts index 6bd7f2a..6a0776b 100644 --- a/src/lib/storage/writingMode.ts +++ b/src/lib/storage/writingMode.ts @@ -1,8 +1,10 @@ +import { docKey } from './docScope'; + // The page's text direction: ODF style:writing-mode="rl-tb" / Word's w:bidi. A // right-to-left page fills its columns from the right and sets the body's base // direction, so bidi resolves a Hebrew or Arabic line the way the file means it. -const KEY = 'edentext-page-rtl'; +const KEY = docKey('edentext-page-rtl'); export function loadPageRtl(): boolean { return localStorage.getItem(KEY) === 'true'; diff --git a/src/lib/styles/sheet.svelte.ts b/src/lib/styles/sheet.svelte.ts index d766849..4a652be 100644 --- a/src/lib/styles/sheet.svelte.ts +++ b/src/lib/styles/sheet.svelte.ts @@ -5,8 +5,9 @@ import { builtinStyleSheet, DEFAULT_STYLE, mergeStoredSheet, STYLE_SHEET_VERSION, type Style, type StyleFamily, type StyleSheet } from './styleSheet'; import type { TableStyle } from './tableStyles'; import type { ListStyle } from './listStyles'; +import { docKey } from '../storage/docScope'; -const STORAGE_KEY = 'edentext-styles'; +const STORAGE_KEY = docKey('edentext-styles'); function load(): StyleSheet { const raw = localStorage.getItem(STORAGE_KEY); diff --git a/src/main.ts b/src/main.ts index 226541c..238dd52 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,12 @@ import App from './App.svelte'; import './styles/global.css'; import { loadTheme, applyTheme } from './lib/storage/theme'; import { locale } from './lib/i18n/i18n.svelte'; +import { startTabPresence, pruneOldDocuments } from './lib/storage/docScope'; + +// This tab holds its document while it lives; the ones no tab has held for a while +// and that fell out of the newest few are dropped here. +startTabPresence(); +pruneOldDocuments(); // Apply saved theme before mount to prevent flash of wrong theme applyTheme(loadTheme()); diff --git a/tests/fakeIdb.ts b/tests/fakeIdb.ts index 4f21cf0..bd2d4f8 100644 --- a/tests/fakeIdb.ts +++ b/tests/fakeIdb.ts @@ -1,16 +1,16 @@ // A two-call IndexedDB in memory — what openDb/idbRequest (storage/idb.ts) ask for. export function fakeIndexedDb() { - const data = new Map(); + 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)), + put: (v: unknown, k: IDBValidKey) => { data.set(k, v); return req(undefined); }, + get: (k: IDBValidKey) => req(data.get(k)), getAllKeys: () => req([...data.keys()]), - delete: (k: number) => { data.delete(k); return req(undefined); }, + delete: (k: IDBValidKey) => { data.delete(k); return req(undefined); }, }; const db = { transaction: () => ({ objectStore: () => store }), createObjectStore: () => {}, close: () => {} }; return { diff --git a/tests/tabs/run.mjs b/tests/tabs/run.mjs new file mode 100644 index 0000000..099c633 --- /dev/null +++ b/tests/tabs/run.mjs @@ -0,0 +1,71 @@ +// Several tabs, several documents: the one thing jsdom cannot show, since it has no +// second tab. Two pages in one browser context share localStorage and have their own +// sessionStorage each — exactly what two tabs of the same browser are. +import { checker, previewServer, openApp } from '../browser.mjs'; + +const PORT = +(process.env.TABS_PORT ?? 4183); +const URL = `http://localhost:${PORT}/`; +const { check, failures } = checker(); +const server = await previewServer(PORT); +const { browser, page, pageErrors } = await openApp(PORT); +// openApp's page has a context of its own; the tabs need to share one. +await page.close(); +const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 }, locale: 'en-US' }); + +const settle = (p) => p.waitForTimeout(1500); +const text = (p) => p.textContent('.tiptap'); + +async function openTab() { + const p = await ctx.newPage(); + p.on('dialog', (d) => d.accept()); + p.on('pageerror', (err) => pageErrors.push(String(err))); + await p.goto(URL, { waitUntil: 'load' }); + await p.waitForSelector('.tiptap', { timeout: 15_000 }); + return p; +} + +async function type(p, s) { + await p.click('.tiptap p'); + await p.keyboard.type(s); + await settle(p); +} + +try { + const a = await openTab(); + await a.evaluate(() => localStorage.clear()); + await a.reload({ waitUntil: 'load' }); + await a.waitForSelector('.tiptap'); + await type(a, 'Alpha'); + + const b = await openTab(); + check(!(await text(b)).includes('Alpha'), 'a second tab starts on its own empty document'); + await type(b, 'Beta'); + check((await text(a)).includes('Alpha') && !(await text(a)).includes('Beta'), 'the first tab keeps its own text'); + + const keys = await a.evaluate(() => Object.fromEntries(Object.keys(localStorage) + .filter((k) => k.startsWith('edentext-doc')) + .map((k) => [k, localStorage.getItem(k) ?? '']))); + const scoped = Object.keys(keys).find((k) => k.startsWith('edentext-doc@')); + check(keys['edentext-doc']?.includes('Alpha'), 'the first document keeps the unsuffixed key'); + check(!!scoped && keys[scoped].includes('Beta'), `the second document has its own key (${scoped})`); + + for (const p of [a, b]) { + await p.reload({ waitUntil: 'load' }); + await p.waitForSelector('.tiptap'); + await settle(p); + } + check((await text(a)).includes('Alpha') && (await text(b)).includes('Beta'), 'both tabs keep their document across a reload'); + + // A closed tab signs its document off, so the next fresh tab takes it up again. + await b.close(); + const c = await openTab(); + await settle(c); + check((await text(c)).includes('Beta'), 'a new tab takes up the document the closed tab held'); +} catch (err) { + check(false, `tabs run threw: ${err.message ?? err}`); +} finally { + check(pageErrors.length === 0, pageErrors.length ? `no uncaught page errors — got: ${pageErrors.join(' | ')}` : 'no uncaught page errors'); + await browser.close(); + if (server) process.kill(-server.pid); +} +process.exit(failures.length ? 1 : 0); diff --git a/tests/unit/doc-scope.test.ts b/tests/unit/doc-scope.test.ts new file mode 100644 index 0000000..f782eb4 --- /dev/null +++ b/tests/unit/doc-scope.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { pickSlot } from '../../src/lib/storage/docScope'; + +const NOW = 1_700_000_000_000; +const MIN = 60_000; + +describe('pickSlot', () => { + it('gives the first tab of any browser the unsuffixed slot', () => { + expect(pickSlot({}, NOW)).toBe(''); + }); + + it('mints a new document while every one is held', () => { + const id = pickSlot({ '': NOW - 1000 }, NOW); + expect(id).not.toBe(''); + expect(pickSlot({ '': NOW - 1000, [id]: NOW }, NOW)).not.toBe(id); + }); + + it('takes up the most recent document no tab holds', () => { + // Negative = a tab signed off; the stamp still says how recently it was used. + expect(pickSlot({ '': -(NOW - 9 * MIN), d2: -(NOW - 2 * MIN) }, NOW)).toBe('d2'); + }); + + it('counts a marker nobody has refreshed in ten minutes as abandoned', () => { + expect(pickSlot({ '': NOW - 11 * MIN, d2: NOW - 1 * MIN }, NOW)).toBe(''); + }); +}); diff --git a/tests/unit/image-store.test.ts b/tests/unit/image-store.test.ts index 2f6e8a6..603dd99 100644 --- a/tests/unit/image-store.test.ts +++ b/tests/unit/image-store.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; -import { stashImages, IDB_SRC } from '../../src/lib/storage/imageStore'; +import { describe, it, expect, vi } from 'vitest'; +import { stashImages, putImages, IDB_SRC } from '../../src/lib/storage/imageStore'; +import { fakeIndexedDb } from '../fakeIdb'; const big = (fill: string) => `data:image/png;base64,${fill.repeat(5000)}`; @@ -45,3 +46,17 @@ describe('stashImages', () => { expect(blobs.size).toBe(2); }); }); + +describe('putImages', () => { + it('sweeps what no document names any more, and nothing else', async () => { + const idb = fakeIndexedDb(); + vi.stubGlobal('indexedDB', { open: idb.open }); + localStorage.clear(); + // A second document, stored by another tab and not open here. + localStorage.setItem('edentext-doc@d2', JSON.stringify({ attrs: { src: `${IDB_SRC}theirs` } })); + idb.data.set('theirs', big('B')); + idb.data.set('orphan', big('C')); + await putImages(new Map([['mine', big('A')]])); + expect([...idb.data.keys()].sort()).toEqual(['mine', 'theirs']); + }); +});