diff --git a/src/App.svelte b/src/App.svelte index 5b10879..5aeb4b5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -11,7 +11,7 @@ import { buildOdt, deriveFilename } from './lib/export/odt'; import { exportPdf, printPdf, printRaster } from './lib/export/pdf'; import { supportsFsAccess, saveOdt, saveAsOdt, saveDocx, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile'; - import { loadRecentFiles, rememberRecentFile, readRecentFile, forgetRecentFiles, type RecentFile } from './lib/storage/recentFiles'; + import { loadRecentFiles, rememberRecentFile, readRecentFile, forgetRecentFile, forgetRecentFiles, pruneRecentFiles, type RecentFile } from './lib/storage/recentFiles'; import { importOdt } from './lib/import/odt'; import { importDocx } from './lib/import/docx'; import { convertUnsupportedImages } from './lib/import/imageFormats'; @@ -474,10 +474,12 @@ // The file the document is saved to (File System Access API). Session-only: a // reload restores the doc from localStorage but the first Save re-prompts. let fileHandle: FileSystemFileHandle | null = $state(null); - // Word's and LibreOffice's recent-documents list; a click reopens the file itself - // where the browser can hand back its handle. - let recentFiles: RecentFile[] = $state(loadRecentFiles()); + // Word's and LibreOffice's recent-documents list; a click reopens the file itself. + // Only a browser with the File System Access API has handles to reopen from, so + // elsewhere the list stays empty; a startup prune drops entries whose handle is gone. const fsSupported = supportsFsAccess(); + let recentFiles: RecentFile[] = $state(fsSupported ? loadRecentFiles() : []); + if (fsSupported) void pruneRecentFiles().then((list) => (recentFiles = list)); let fileInput: HTMLInputElement | null = $state(null); let pdfBusy = $state(false); let docxBusy = $state(false); @@ -828,17 +830,19 @@ } } - // Reopen a file from the recent list. The File System Access API re-prompts for - // permission after a reload, and a file that has moved or been deleted is gone — - // either way the entry is dropped rather than left to fail again. + // Reopen a file from the recent list. Only a file that is really gone drops the + // entry; a permission prompt declined or dismissed keeps it for the next try. async function handleOpenRecent(entry: RecentFile) { exportMenuOpen = false; try { const r = await readRecentFile(entry.id); - if (!r) { + if (r === 'gone') { alert(t().dialogs.recentUnavailable(entry.name)); - recentFiles = recentFiles.filter((f) => f.id !== entry.id); - localStorage.setItem('edentext-recent-files', JSON.stringify(recentFiles)); + recentFiles = forgetRecentFile(entry.id); + return; + } + if (r === 'denied') { + alert(t().dialogs.recentDenied(entry.name)); return; } await applyImport(r.bytes, r.handle, r.name); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index e6bedc7..1aa03b9 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -916,7 +916,9 @@ const de: Messages = { couldNotExportDocx: 'Export nach Word (.docx) nicht möglich.', couldNotExportPdf: 'PDF-Export nicht möglich.', recentUnavailable: (name) => - `„${name}“ konnte nicht erneut geöffnet werden — die Datei wurde vielleicht verschoben, oder der Browser hat keine Berechtigung mehr dafür. Öffne sie stattdessen über den Dateidialog.`, + `„${name}“ konnte nicht erneut geöffnet werden — die Datei wurde offenbar verschoben oder gelöscht. Öffne sie stattdessen über den Dateidialog.`, + recentDenied: (name) => + `Der Browser hat das Lesen von „${name}“ nicht erlaubt. Erlaube den Zugriff im Berechtigungsdialog, oder öffne die Datei über den Dateidialog.`, couldNotPrint: 'Drucken nicht möglich.', couldNotPrintPdf: 'Druck als PDF nicht möglich.', autosaveQuota: diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index dacde6d..1f6bf22 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -922,7 +922,9 @@ const en = { couldNotExportDocx: 'Could not export to Word (.docx).', couldNotExportPdf: 'Could not export to PDF.', recentUnavailable: (name: string) => - `"${name}" could not be opened again — it may have moved, or the browser no longer has permission for it. Open it from the file picker instead.`, + `"${name}" could not be opened again — the file seems to have been moved or deleted. Open it from the file picker instead.`, + recentDenied: (name: string) => + `The browser did not allow reading "${name}". Allow access in its permission prompt, or open the file from the file picker.`, couldNotPrint: 'Could not print.', couldNotPrintPdf: 'Could not print to PDF.', autosaveQuota: diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 4a7e199..6a89fcd 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -28,7 +28,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`). 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: it moves to `edentext-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. **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. -- **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. Reopening re-asks for permission (the API drops it on reload); a refusal, a moved file or a browser with no picker leaves the entry unopenable, and it is dropped with a message instead of failing again. +- **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`). - **Theme:** `edentext-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`. - **Toolbar expanded:** `edentext-toolbar-expanded` — boolean string. - **Chrome:** `edentext-chrome` — `'modern' | 'ribbon'` ('classic' read as the modern island's legacy stored name); absent = ribbon. diff --git a/src/lib/storage/recentFiles.ts b/src/lib/storage/recentFiles.ts index ba472ec..3262c08 100644 --- a/src/lib/storage/recentFiles.ts +++ b/src/lib/storage/recentFiles.ts @@ -1,10 +1,8 @@ import { openDb, idbRequest } from './idb'; -// The files this browser has opened or saved, most recent first. The name and time -// go in localStorage so the menu can be drawn synchronously; the FileSystemFileHandle -// that actually reopens one is structured-cloneable but not JSON, so it lives in -// IndexedDB beside it. A browser without the File System Access API keeps the names -// only — there is nothing to reopen from, and the entry says so. +// The files this browser can reopen, most recent first. Name and time go in +// localStorage so the menu draws synchronously; the FileSystemFileHandle that reopens +// one is structured-cloneable but not JSON, so it lives in IndexedDB beside it. const KEY = 'edentext-recent-files'; const DB_NAME = 'edentext-recent'; const STORE = 'handles'; @@ -35,26 +33,44 @@ function write(list: RecentFile[]): void { } /** - * Record a file as just used. A handle already in the list keeps its id — the same - * file opened twice is one entry, moved to the top — and one without a handle is - * matched on its name, which is all a browser with no picker gives us. + * Record a file as just used. Without a handle nothing can ever reopen the entry + * (no File System Access API, or a template), so none is recorded. A handle already + * in the list keeps its id — the same file opened twice is one entry, moved to the top. */ export async function rememberRecentFile(name: string, handle: FileSystemFileHandle | null): Promise { const list = loadRecentFiles(); - const existing = handle ? await findByHandle(list, handle) : list.find((f) => f.name === name); + if (!handle) return list; + const existing = (await findByHandle(list, handle)) ?? list.find((f) => f.name === name); const id = existing?.id ?? `f${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`; const next = [{ id, name, at: Date.now() }, ...list.filter((f) => f.id !== id)].slice(0, MAX); write(next); - if (handle) await putHandle(id, handle); + await putHandle(id, handle); await sweepHandles(next); return next; } +/** Drop one entry (a dead one, on a failed reopen) and its stored handle. */ +export function forgetRecentFile(id: string): RecentFile[] { + const next = loadRecentFiles().filter((f) => f.id !== id); + write(next); + void sweepHandles(next); + return next; +} + export function forgetRecentFiles(): void { write([]); void sweepHandles([]); } +/** Drop every entry with no stored handle — nothing can reopen those. Run at startup. */ +export async function pruneRecentFiles(): Promise { + const list = loadRecentFiles(); + const kept: RecentFile[] = []; + for (const f of list) if (await getHandle(f.id)) kept.push(f); + if (kept.length !== list.length) write(kept); + return kept; +} + // isSameEntry is the only way to compare two handles; it is async, so this walks. async function findByHandle(list: RecentFile[], handle: FileSystemFileHandle): Promise { for (const f of list) { @@ -98,21 +114,26 @@ async function sweepHandles(list: RecentFile[]): Promise { } /** - * The bytes of a recent file, or null when it can't be reopened — the browser has no - * handle for it, the file is gone, or the user declined the permission prompt the - * File System Access API requires after a reload. + * The bytes of a recent file. 'gone' = no handle, or the file no longer exists — the + * entry is dead; 'denied' = read permission (re-asked after a reload) was not granted + * this time — the entry stays. Opening needs only 'read'; Save re-prompts on its own. */ -export async function readRecentFile(id: string): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> { +export async function readRecentFile(id: string): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | 'gone' | 'denied'> { const handle = await getHandle(id); - if (!handle) return null; + if (!handle) return 'gone'; type Permissioned = FileSystemFileHandle & { queryPermission?: (d: { mode: string }) => Promise; requestPermission?: (d: { mode: string }) => Promise; }; const h = handle as Permissioned; - let state = (await h.queryPermission?.({ mode: 'readwrite' })) ?? 'granted'; - if (state === 'prompt') state = (await h.requestPermission?.({ mode: 'readwrite' })) ?? 'denied'; - if (state !== 'granted') return null; - const file = await handle.getFile(); - return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name }; + let state = (await h.queryPermission?.({ mode: 'read' })) ?? 'granted'; + if (state === 'prompt') state = (await h.requestPermission?.({ mode: 'read' })) ?? 'denied'; + if (state !== 'granted') return 'denied'; + try { + const file = await handle.getFile(); + return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name }; + } catch (err) { + if ((err as DOMException)?.name === 'NotFoundError') return 'gone'; + throw err; + } } diff --git a/tests/unit/recent-files.test.ts b/tests/unit/recent-files.test.ts index 0a3f1a7..3d45328 100644 --- a/tests/unit/recent-files.test.ts +++ b/tests/unit/recent-files.test.ts @@ -1,11 +1,14 @@ -// The recent-files list (storage/recentFiles.ts): dedupe by name, newest first, -// capped at eight, and tolerant of corrupt localStorage. jsdom has no IndexedDB, -// so the handle side no-ops — exactly the browser-without-picker path. +// The recent-files list (storage/recentFiles.ts): dedupe, newest first, capped at +// eight, tolerant of corrupt localStorage, and never recording a handle-less file — +// nothing could reopen it. jsdom has no IndexedDB, so the handle store no-ops. import { describe, it, expect, beforeEach } from 'vitest'; -import { loadRecentFiles, rememberRecentFile, forgetRecentFiles } from '../../src/lib/storage/recentFiles'; +import { loadRecentFiles, rememberRecentFile, forgetRecentFile, forgetRecentFiles, readRecentFile } from '../../src/lib/storage/recentFiles'; const KEY = 'edentext-recent-files'; +// A stand-in for a picker handle; without IndexedDB it is matched by name only. +const stubHandle = () => ({ isSameEntry: async () => false }) as unknown as FileSystemFileHandle; + describe('recent files', () => { beforeEach(() => localStorage.clear()); @@ -19,20 +22,26 @@ describe('recent files', () => { expect(loadRecentFiles().map((f) => f.name)).toEqual(['ok.odt']); }); - it('remembers newest first and dedupes a handleless file by name, keeping its id', async () => { + it('records nothing without a handle — there is nothing to reopen from', async () => { await rememberRecentFile('a.odt', null); - await rememberRecentFile('b.odt', null); + expect(loadRecentFiles()).toEqual([]); + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it('remembers newest first and dedupes a same-named file, keeping its id', async () => { + await rememberRecentFile('a.odt', stubHandle()); + await rememberRecentFile('b.odt', stubHandle()); expect(loadRecentFiles().map((f) => f.name)).toEqual(['b.odt', 'a.odt']); const idA = loadRecentFiles()[1].id; - await rememberRecentFile('a.odt', null); + await rememberRecentFile('a.odt', stubHandle()); const list = loadRecentFiles(); expect(list.map((f) => f.name)).toEqual(['a.odt', 'b.odt']); expect(list[0].id).toBe(idA); }); it('keeps at most eight entries, dropping the oldest', async () => { - for (let i = 1; i <= 10; i++) await rememberRecentFile(`f${i}.odt`, null); + for (let i = 1; i <= 10; i++) await rememberRecentFile(`f${i}.odt`, stubHandle()); const names = loadRecentFiles().map((f) => f.name); expect(names).toHaveLength(8); expect(names[0]).toBe('f10.odt'); @@ -40,10 +49,17 @@ describe('recent files', () => { expect(names).not.toContain('f2.odt'); }); - it('forgetRecentFiles clears the list and removes the key', async () => { - await rememberRecentFile('a.odt', null); + it('forgetRecentFile drops one entry, forgetRecentFiles removes the key', async () => { + await rememberRecentFile('a.odt', stubHandle()); + await rememberRecentFile('b.odt', stubHandle()); + const idB = loadRecentFiles()[0].id; + expect(forgetRecentFile(idB).map((f) => f.name)).toEqual(['a.odt']); forgetRecentFiles(); expect(loadRecentFiles()).toEqual([]); expect(localStorage.getItem(KEY)).toBeNull(); }); + + it('readRecentFile reports a missing handle as gone', async () => { + expect(await readRecentFile('nope')).toBe('gone'); + }); });