From 768fd6584ca54e549e241444ab27f90ec9b2dd7c Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 17:57:45 +0200 Subject: [PATCH 1/7] Recents: stable-ID storage with rename, delete, and a sorted list (#434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries are now keyed by a generated ID (hyperaudio:doc:) with the display name in meta.name, instead of the name BEING the key. Renaming becomes a one-field update, saving a name that already exists gets a " (2)" suffix instead of silently overwriting, and cached media keys off meta.mediaKey so a rename never re-keys a large IndexedDB blob. Legacy .hyperaudio entries migrate in place the first time the list renders — same JSON, new key, name/mediaKey carried into meta (media stays under its old key). An entry that does not parse stays on its legacy key: still listed, still deletable, clicks stay safe (#410). The Recents rows gain hover/focus actions: rename (inline edit — Enter/blur commits, Escape cancels) and delete (two-step arm/confirm), where delete also removes the entry's cached media — previously orphaned blobs accumulated forever. Rows sort by last-updated (newest first; freshly migrated entries follow alphabetically), and the active row's highlight now survives re-renders. Saving updates the active entry in place; renames do not bump `updated`, so they don't reorder. Quota errors surface as a dismissible notice above the list instead of a blocking alert(), and the empty-state message now shows whenever no transcripts exist (it used to be suppressed by unrelated keys such as the transcribe prefs). Unit tests cover the pure helpers via a fake Storage; the storage e2e spec grows migration, ordering, rename, delete, and active-highlight coverage. --- __TEST__/e2e/storage.spec.mjs | 101 +++++- __TEST__/unit/storage.test.mjs | 136 ++++++++ css/hyperaudio-lite-editor.css | 74 +++++ index.html | 4 +- js/hyperaudio-lite-editor-storage.js | 462 +++++++++++++++++++++++---- 5 files changed, 707 insertions(+), 70 deletions(-) create mode 100644 __TEST__/unit/storage.test.mjs diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index 5601088..362e48f 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -1,8 +1,12 @@ -// Storage picker regression net (#410): saved projects are referenced by KEY -// STRING, not by storage.key(i) position — positional indices shift whenever -// any other module writes a key (the transcribe prefs do so on every toggle), -// which loaded the wrong entry or threw. Also: corrupted entries must not kill -// the click handler, and filenames must render as text, not markup. +// Storage picker regression net (#410) + the Recents storage model (#434): +// saved projects are referenced by KEY STRING, not by storage.key(i) position +// — positional indices shift whenever any other module writes a key (the +// transcribe prefs do so on every toggle), which loaded the wrong entry or +// threw. Corrupted entries must not kill the click handler, and filenames must +// render as text, not markup. Since #434, entries are keyed by stable ID with +// the display name in meta (legacy name-keyed entries migrate on first list +// render), rows can be renamed inline and deleted (two-step), and the list +// orders by last-updated. import { test, expect } from '@playwright/test'; const seed = (page) => page.evaluate(() => { @@ -59,6 +63,93 @@ test('a corrupted entry does not throw and the picker keeps working (#410)', asy expect(await page.evaluate(() => document.querySelector('#hypertranscript').textContent)).toContain('ALPHA'); }); +test('legacy name-keyed entries migrate to stable ID keys on first list render (#434)', async ({ page }) => { + await seed(page); + const r = await page.evaluate(() => ({ + legacyKeys: Object.keys(localStorage).filter((k) => k.endsWith('.hyperaudio')), + docKeys: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')), + names: [...document.querySelectorAll('.file-item')].map((a) => a.textContent).sort(), + })); + expect(r.legacyKeys).toEqual([]); + expect(r.docKeys.length).toBe(2); + expect(r.names).toEqual(['alpha', 'beta']); +}); + +test('rows order by last-updated, newest first; undated entries follow alphabetically (#434)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + const entry = (name, updated) => JSON.stringify({ + hypertranscript: '

x

', + video: 'https://example.com/a.mp3', summary: 's', topics: [], + meta: updated ? { name, updated } : { name }, + }); + localStorage.setItem('hyperaudio:doc:t1', entry('older', 1000)); + localStorage.setItem('hyperaudio:doc:t2', entry('newest', 3000)); + loadLocalStorageOptions(); + }); + const names = await page.evaluate(() => + [...document.querySelectorAll('.file-item')].map((a) => a.textContent)); + expect(names).toEqual(['newest', 'older', 'alpha', 'beta']); +}); + +test('rename via the row action edits the name in place; the key is untouched (#434)', async ({ page }) => { + await seed(page); + const keysBefore = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).sort()); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); + await row.hover(); + await row.locator('.recents-rename').click(); + const input = page.locator('.recents-rename-input'); + await input.fill('interview notes'); + await input.press('Enter'); + await expect(page.locator('.file-item', { hasText: 'interview notes' })).toHaveCount(1); + const after = await page.evaluate(() => ({ + keys: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).sort(), + names: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')) + .map((k) => JSON.parse(localStorage.getItem(k)).meta.name).sort(), + })); + expect(after.keys).toEqual(keysBefore); + expect(after.names).toEqual(['beta', 'interview notes']); +}); + +test('escape cancels a rename without changing the name (#434)', async ({ page }) => { + await seed(page); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); + await row.hover(); + await row.locator('.recents-rename').click(); + const input = page.locator('.recents-rename-input'); + await input.fill('should-not-stick'); + await input.press('Escape'); + const names = await page.evaluate(() => + [...document.querySelectorAll('.file-item')].map((a) => a.textContent).sort()); + expect(names).toEqual(['alpha', 'beta']); +}); + +test('delete is two-step and removes the entry (#434)', async ({ page }) => { + await seed(page); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); + await row.hover(); + const del = row.locator('.recents-delete'); + await del.click(); + // armed, not deleted + await expect(del).toHaveText('Delete?'); + expect(await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(2); + await del.click(); + await expect(page.locator('.file-item', { hasText: 'alpha' })).toHaveCount(0); + await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(1); + expect(await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(1); +}); + +test('clicking a row loads it and marks it active; the highlight survives a re-render (#434)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + await expect(page.locator('.file-item.active')).toHaveText('beta'); + await page.evaluate(() => loadLocalStorageOptions()); + await expect(page.locator('.file-item.active')).toHaveText('beta'); +}); + test('a filename containing markup renders as text (#410)', async ({ page }) => { await seed(page); await page.evaluate(() => { diff --git a/__TEST__/unit/storage.test.mjs b/__TEST__/unit/storage.test.mjs new file mode 100644 index 0000000..0632807 --- /dev/null +++ b/__TEST__/unit/storage.test.mjs @@ -0,0 +1,136 @@ +// Unit tests for the Recents storage model (#434): entries keyed by stable ID +// with the display name in meta, legacy name-keyed entries migrated in place, +// name collisions suffixed instead of overwriting, and the list ordered by +// last-updated. All helpers run against a fake Storage so no DOM is needed. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + isDocKey, + isLegacyKey, + entryName, + entryMediaKey, + uniqueEntryName, + listDocEntries, + migrateLegacyEntries, + renameTranscriptEntry, + deleteTranscriptEntry, +} = require('../../js/hyperaudio-lite-editor-storage.js'); + +function fakeStorage(init = {}) { + const map = new Map(Object.entries(init)); + return { + get length() { return map.size; }, + key: (i) => [...map.keys()][i] ?? null, + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => { map.set(k, String(v)); }, + removeItem: (k) => { map.delete(k); }, + }; +} + +const entry = (fields = {}) => JSON.stringify(Object.assign({ + hypertranscript: '

x

', + video: 'https://example.com/a.mp3', + summary: 's', + topics: [], +}, fields)); + +test('key classification: doc keys vs legacy keys vs unrelated keys', () => { + assert.ok(isDocKey('hyperaudio:doc:abc123')); + assert.ok(!isDocKey('alpha.hyperaudio')); + assert.ok(isLegacyKey('alpha.hyperaudio')); + assert.ok(!isLegacyKey('hyperaudio:doc:abc123')); + assert.ok(!isLegacyKey('hyperaudioTranscribePrefs')); + assert.ok(!isLegacyKey('.hyperaudio')); // name part must be non-empty (indexOf > 0) +}); + +test('migration: legacy entry moves to an ID key, name and mediaKey preserved', () => { + const s = fakeStorage({ 'interview.hyperaudio': entry() }); + migrateLegacyEntries(s); + assert.equal(s.getItem('interview.hyperaudio'), null); + const rows = listDocEntries(s); + assert.equal(rows.length, 1); + assert.ok(isDocKey(rows[0].key)); + const migrated = JSON.parse(s.getItem(rows[0].key)); + assert.equal(migrated.meta.name, 'interview'); + assert.equal(migrated.meta.mediaKey, 'interview'); // media stays under its legacy key + assert.equal(migrated.hypertranscript.includes('data-m'), true); +}); + +test('migration: an unparseable legacy entry stays on its legacy key and is still listed', () => { + const s = fakeStorage({ 'broken.hyperaudio': '{not json', 'ok.hyperaudio': entry() }); + migrateLegacyEntries(s); + assert.equal(s.getItem('broken.hyperaudio'), '{not json'); + const names = listDocEntries(s).map((r) => r.name).sort(); + assert.deepEqual(names, ['broken', 'ok']); +}); + +test('migration is idempotent', () => { + const s = fakeStorage({ 'a.hyperaudio': entry() }); + migrateLegacyEntries(s); + const keysAfterFirst = listDocEntries(s).map((r) => r.key); + migrateLegacyEntries(s); + assert.deepEqual(listDocEntries(s).map((r) => r.key), keysAfterFirst); +}); + +test('uniqueEntryName: suffixes instead of colliding, own key excluded', () => { + const s = fakeStorage({ + 'hyperaudio:doc:one': entry({ meta: { name: 'interview' } }), + 'hyperaudio:doc:two': entry({ meta: { name: 'interview (2)' } }), + }); + assert.equal(uniqueEntryName('fresh', s, null), 'fresh'); + assert.equal(uniqueEntryName('interview', s, null), 'interview (3)'); + // an entry keeping its own name is not a collision with itself + assert.equal(uniqueEntryName('interview', s, 'hyperaudio:doc:one'), 'interview'); +}); + +test('listDocEntries: newest-updated first; entries without timestamps sort last, alphabetically', () => { + const s = fakeStorage({ + 'hyperaudio:doc:a': entry({ meta: { name: 'older', updated: 1000 } }), + 'hyperaudio:doc:b': entry({ meta: { name: 'newest', updated: 3000 } }), + 'hyperaudio:doc:c': entry({ meta: { name: 'zeta' } }), + 'hyperaudio:doc:d': entry({ meta: { name: 'alpha' } }), + }); + assert.deepEqual(listDocEntries(s).map((r) => r.name), ['newest', 'older', 'alpha', 'zeta']); +}); + +test('rename: one-field update, de-duplicated, key untouched; empty name rejected', () => { + const s = fakeStorage({ + 'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1', updated: 42 } }), + 'hyperaudio:doc:two': entry({ meta: { name: 'taken' } }), + }); + assert.equal(renameTranscriptEntry('hyperaudio:doc:one', 'fresh', s), true); + let e = JSON.parse(s.getItem('hyperaudio:doc:one')); + assert.equal(e.meta.name, 'fresh'); + assert.equal(e.meta.mediaKey, 'm1'); // media key survives the rename + assert.equal(e.meta.updated, 42); // renaming must not reorder the list + + assert.equal(renameTranscriptEntry('hyperaudio:doc:one', 'taken', s), true); + e = JSON.parse(s.getItem('hyperaudio:doc:one')); + assert.equal(e.meta.name, 'taken (2)'); + + assert.equal(renameTranscriptEntry('hyperaudio:doc:one', ' ', s), false); + assert.equal(renameTranscriptEntry('hyperaudio:doc:missing', 'x', s), false); +}); + +test('delete: removes the entry, including an unparseable legacy one', () => { + const s = fakeStorage({ + 'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1' } }), + 'broken.hyperaudio': '{not json', + }); + deleteTranscriptEntry('hyperaudio:doc:one', s); + assert.equal(s.getItem('hyperaudio:doc:one'), null); + deleteTranscriptEntry('broken.hyperaudio', s); + assert.equal(s.getItem('broken.hyperaudio'), null); + assert.equal(listDocEntries(s).length, 0); +}); + +test('entryName / entryMediaKey fall back sensibly for malformed entries', () => { + assert.equal(entryName('alpha.hyperaudio', null), 'alpha'); + assert.equal(entryName('hyperaudio:doc:x', null), 'Untitled'); + assert.equal(entryMediaKey('alpha.hyperaudio', null), 'alpha'); + assert.equal(entryMediaKey('hyperaudio:doc:x', null), null); + assert.equal(entryMediaKey('hyperaudio:doc:x', { meta: { mediaKey: 'm' } }), 'm'); +}); diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index edfca73..2756af9 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -953,3 +953,77 @@ label[data-a11y-wired]:focus-visible { padding-left: 16px; } } + +/* Recents rows (#434): name + hover/focus actions (rename, delete). The + daisyUI menu lays li content out column-wise for submenus — force a row so + the action buttons sit beside the name, which truncates rather than wraps. */ +#file-picker .recents-row { + flex-direction: row; + align-items: center; + flex-wrap: nowrap; +} +#file-picker .recents-row .file-item { + display: block; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#file-picker .recents-actions { + display: flex; + align-items: center; + gap: 2px; + padding-right: 4px; + opacity: 0; +} +#file-picker .recents-row:hover .recents-actions, +#file-picker .recents-row:focus-within .recents-actions { + opacity: 1; +} +/* neutralise the global button chrome (border + grey fill) for the tiny + in-row actions; they read as quiet icons until hovered */ +#file-picker .recents-actions button { + display: inline-flex; + align-items: center; + border: none; + background: transparent; + margin: 0; + padding: 4px; + border-radius: 0.375rem; + color: oklch(var(--bc) / 0.55); + cursor: pointer; + font-size: 11px; + line-height: 1; +} +#file-picker .recents-actions button:hover { + border: none; + background-color: oklch(var(--b2)); + color: oklch(var(--bc)); +} +/* armed delete: text swaps in for the icon, in the error colour */ +#file-picker .recents-actions button.confirming, +#file-picker .recents-actions button.confirming:hover { + color: oklch(var(--er)); + font-weight: 600; +} +.recents-rename-input { + width: 100%; + box-sizing: border-box; + margin: 0; + padding: 2px 6px; + font-size: inherit; + font-family: inherit; + border: 1px solid oklch(var(--p)); + border-radius: 0.25rem; + background-color: oklch(var(--b1)); +} +/* quota / storage problems surface here instead of a blocking alert() */ +#recents-notice { + margin: 0 16px 8px; + padding: 8px 12px; + border-radius: 0.5rem; + background-color: oklch(var(--er) / 0.12); + color: oklch(var(--er)); + font-size: 12px; +} diff --git a/index.html b/index.html index b2684e2..5657189 100644 --- a/index.html +++ b/index.html @@ -108,7 +108,7 @@ } - + @@ -1018,7 +1018,7 @@

Load from Local Storage

- + diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index 5c4a6ad..56e6e1e 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -5,6 +5,8 @@ * @param {string} summary - the text of the summary * @param {array} topics - an array of topics * @param {string} captions - VTT format + * @param {object} meta - entry metadata: display name, media key, timestamps, + * caption-sync flag (see below) * @return {void} */ class HyperTranscriptStorage { @@ -18,9 +20,135 @@ class HyperTranscriptStorage { } } -// We should move these from global scope +/* + * Storage model (#434) + * + * Entries are keyed by a STABLE GENERATED ID (`hyperaudio:doc:`), never by + * their display name. The name lives in meta.name, so renaming is a one-field + * update and two entries may share a display name candidate (the second gets a + * " (2)" suffix) without one overwriting the other. Cached local media in + * IndexedDB is keyed by meta.mediaKey — the doc key for new entries — so a + * rename never has to re-key a (possibly large) media blob. + * + * meta: { + * name: display name shown in Recents, + * mediaKey: IndexedDB key of the cached local media (if any), + * created / updated: epoch ms; `updated` drives the list order, + * updateCaptionsFromTranscript: existing caption-sync flag, + * } + * + * LEGACY entries (`.hyperaudio`, where the key IS the name) are migrated + * in place the first time the list renders: same JSON, new key, name/mediaKey + * carried into meta (media stays under its old key via mediaKey). An entry + * that does not parse is left on its legacy key — still listed, still + * deletable, and the defensive read path keeps clicks from throwing (#410). + */ + const fileExtension = ".hyperaudio"; -let lastFilename = null; +const DOC_KEY_PREFIX = "hyperaudio:doc:"; +const MEDIA_DATABASE = "hyperaudioMedia"; +const MEDIA_STORE = "media"; + +// The storage key of the entry currently loaded in the editor (null when the +// document on screen has never been saved). Save updates this entry in place; +// delete clears it. +let activeDocKey = null; + +function isDocKey(key) { + return typeof key === "string" && key.startsWith(DOC_KEY_PREFIX); +} + +function isLegacyKey(key) { + return typeof key === "string" && !isDocKey(key) && key.indexOf(fileExtension) > 0; +} + +function legacyNameFromKey(key) { + return key.substring(0, key.lastIndexOf(fileExtension)); +} + +function newDocKey() { + return DOC_KEY_PREFIX + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); +} + +// Display name of an entry: meta.name, else (legacy) the name embedded in the +// key, else a fallback so a malformed entry still renders as a row. +function entryName(key, entry) { + if (entry && entry.meta && typeof entry.meta.name === "string" && entry.meta.name !== "") { + return entry.meta.name; + } + if (isLegacyKey(key)) { + return legacyNameFromKey(key); + } + return "Untitled"; +} + +// IndexedDB key of an entry's cached media. Migrated entries carry their +// legacy name here; unparseable legacy entries fall back to the key's name so +// deleting one still clears its media. +function entryMediaKey(key, entry) { + if (entry && entry.meta && typeof entry.meta.mediaKey === "string" && entry.meta.mediaKey !== "") { + return entry.meta.mediaKey; + } + return isLegacyKey(key) ? legacyNameFromKey(key) : null; +} + +// A display name not used by any other entry: "name", else "name (2)", "name +// (3)", ... `excludeKey` lets an entry keep (or re-save under) its own name. +function uniqueEntryName(desired, storage, excludeKey) { + const names = new Set(); + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === excludeKey) continue; + if (isDocKey(key) || isLegacyKey(key)) { + names.add(entryName(key, readTranscriptEntry(key, storage))); + } + } + if (!names.has(desired)) return desired; + let n = 2; + while (names.has(`${desired} (${n})`)) n++; + return `${desired} (${n})`; +} + +// All saved entries as {key, name, updated}, newest-updated first; entries +// with no timestamp (freshly migrated legacy) sort below, alphabetically. +function listDocEntries(storage) { + const rows = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (!isDocKey(key) && !isLegacyKey(key)) continue; + const entry = readTranscriptEntry(key, storage); + rows.push({ + key, + name: entryName(key, entry), + updated: (entry && entry.meta && entry.meta.updated) || 0, + }); + } + rows.sort((a, b) => (b.updated - a.updated) || a.name.localeCompare(b.name)); + return rows; +} + +// One-time upgrade of legacy name-keyed entries to ID-keyed entries. Runs +// every list render but is a no-op once nothing legacy-parseable remains. +function migrateLegacyEntries(storage) { + const legacyKeys = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (isLegacyKey(key)) legacyKeys.push(key); + } + legacyKeys.forEach((key) => { + const entry = readTranscriptEntry(key, storage); + if (!entry || typeof entry.hypertranscript !== "string") return; // leave it; still listed + deletable + const name = legacyNameFromKey(key); + entry.meta = Object.assign({}, entry.meta, { name, mediaKey: name }); + try { + storage.setItem(newDocKey(), JSON.stringify(entry)); + storage.removeItem(key); + } catch (e) { + // quota — keep the legacy key rather than risk losing the entry + console.error("Could not migrate saved transcript:", e); + } + }); +} /* * Completely remove the existing caption and insert a fresh, empty one. @@ -57,11 +185,13 @@ function resetCaptionTrack(videoDomId = 'hyperplayer', vttId = 'hyperplayer-vtt' /* * Render the HyperTranscript in the DOM + * @param {object} hypertranscriptstorage - the parsed entry + * @param {string} mediaKey - IndexedDB key for locally cached media * @return {void} */ function renderTranscript( hypertranscriptstorage, - key, + mediaKey, hypertranscriptDomId = 'hypertranscript', videoDomId = 'hyperplayer', vttId = 'hyperplayer-vtt', @@ -91,15 +221,13 @@ function renderTranscript( } // check to see if file is local - if (hypertranscriptstorage['video'].startsWith("http") === true) { + if (hypertranscriptstorage['video'].startsWith("http") === true) { document.getElementById(videoDomId).src = hypertranscriptstorage['video']; } else { //load from indexedDB - let databaseName = "hyperaudioMedia"; - let objectStoreName = "media"; - getMedia(databaseName, objectStoreName, key); + getMedia(MEDIA_DATABASE, MEDIA_STORE, mediaKey); } - + document.getElementById("summary").innerHTML = hypertranscriptstorage['summary']; document.getElementById("topics").innerHTML = getTopicsString(hypertranscriptstorage['topics']); @@ -128,10 +256,8 @@ function renderTranscript( updateCaptionsFromTranscript = true; } - //console.log(plainVtt); - captionCache = null; - + populateCaptionEditorFromVtt(plainVtt); } @@ -147,29 +273,26 @@ function renderTranscript( const itDownloadEvent = new CustomEvent('hyperaudioTranscriptLoaded'); document.dispatchEvent(itDownloadEvent); - + //maybe better called using hyperaudioInit event? if (captionMode !== true) { hyperaudio(); } else { transcriptRequiresInit = true; } - + } +// Prefill for the save dialog: the active entry's name if one is loaded, +// otherwise the media filename. function getLocalStorageSaveFilename(url){ - let filename = null; - - if (lastFilename === null) { - //by default just the media filename - filename = url.substring(url.lastIndexOf("/")+1); - lastFilename = filename; - } else { - // if it's been saved before this session, use the last filename - filename = lastFilename; + if (activeDocKey !== null) { + const entry = readTranscriptEntry(activeDocKey, window.localStorage); + if (entry !== null) { + return entryName(activeDocKey, entry); + } } - - return filename; + return url.substring(url.lastIndexOf("/") + 1); } function getTopicsString(topics) { @@ -196,7 +319,7 @@ function getMedia(databaseName, objectStoreName, id) { getRequest.onerror = function() { console.error("Error retrieving media:", getRequest.error); } - + getRequest.onsuccess = function() { const base64String = getRequest.result; // Base64 string @@ -212,8 +335,30 @@ function getMedia(databaseName, objectStoreName, id) { } } +// Remove an entry's cached media so deleted transcripts don't leave orphaned +// blobs behind (media is stored as base64 data URLs — the dominant quota +// consumer). Deleting an absent key is a harmless no-op. +function deleteMedia(databaseName, objectStoreName, id) { + if (typeof indexedDB === "undefined" || !id) return; + let openRequest = indexedDB.open(databaseName, 1); + openRequest.onupgradeneeded = function() { + let db = openRequest.result; + if (!db.objectStoreNames.contains(objectStoreName)) { + db.createObjectStore(objectStoreName); + } + } + openRequest.onerror = function() { + console.error("Error opening the database", openRequest.error); + } + openRequest.onsuccess = function() { + let db = openRequest.result; + let transaction = db.transaction(objectStoreName, "readwrite"); + transaction.objectStore(objectStoreName).delete(id); + } +} + function saveVideoFromBlobURL(filename, blobData, databaseName, objectStoreName) { - + // Open a connection to IndexedDB let openRequest = indexedDB.open(databaseName, 1); @@ -243,7 +388,7 @@ function saveVideoFromBlobURL(filename, blobData, databaseName, objectStoreName) request.onsuccess = function() { console.log("Video saved successfully!"); } - } + } } function initializeDatabase(database, objectStoreName) { @@ -270,10 +415,13 @@ function initializeDatabase(database, objectStoreName) { /* - * Save the current HyperTranscript in the local storage - * @param {string} filename - the name of the transcript file - * @param {string} hypertranscriptDomId - the id of the hypertranscript dom element - * @param {string} videoDomId - the id of the video dom element + * Save the current HyperTranscript in the local storage. + * + * Updates the ACTIVE entry in place when one is loaded (the given name then + * renames it); otherwise creates a new entry under a fresh ID. Names are + * de-duplicated with a " (2)" suffix rather than overwriting (#434). + * + * @param {string} filename - the display name for the entry * @return {void} */ @@ -293,16 +441,28 @@ function saveHyperTranscriptToLocalStorage( hypertranscript = transcriptCache.innerHTML; } - let video = document.getElementById(videoDomId).src; + const existing = activeDocKey !== null ? readTranscriptEntry(activeDocKey, storage) : null; + const docKey = existing !== null ? activeDocKey : newDocKey(); + const prevMeta = (existing !== null && existing.meta) ? existing.meta : {}; + const desiredName = (typeof filename === "string" && filename.trim() !== "") + ? filename.trim() + : (prevMeta.name || "Untitled"); + const now = Date.now(); + const meta = { + updateCaptionsFromTranscript, + name: uniqueEntryName(desiredName, storage, docKey), + mediaKey: prevMeta.mediaKey || docKey, + created: prevMeta.created || now, + updated: now, + }; + // if media url begins with blob it means it's locally cached only for the session // we need to save the media to indexdb so that we can retrieve outside the session if (video.startsWith("blob:") === true) { - let objectStoreName = "media"; - let databaseName = "hyperaudioMedia"; - initializeDatabase(databaseName, objectStoreName) + initializeDatabase(MEDIA_DATABASE, MEDIA_STORE) .then(() => { let blobURL = video; @@ -313,7 +473,7 @@ function saveHyperTranscriptToLocalStorage( let blobData = "not defined"; reader.onloadend = function() { blobData = reader.result; - saveVideoFromBlobURL(filename, blobData, databaseName, objectStoreName); + saveVideoFromBlobURL(meta.mediaKey, blobData, MEDIA_DATABASE, MEDIA_STORE); } reader.readAsDataURL(videoBlob); }) @@ -329,23 +489,75 @@ function saveHyperTranscriptToLocalStorage( let summary = document.getElementById("summary").innerHTML; let topics = document.getElementById("topics").innerHTML.split(", "); let captions = document.getElementById(vttId).src; - let meta = {"updateCaptionsFromTranscript": updateCaptionsFromTranscript}; let hypertranscriptstorage = new HyperTranscriptStorage(hypertranscript, video, summary, topics, captions, meta); try { - storage.setItem(filename + fileExtension, JSON.stringify(hypertranscriptstorage)); + storage.setItem(docKey, JSON.stringify(hypertranscriptstorage)); + activeDocKey = docKey; } catch (error) { if (error.name === 'QuotaExceededError') { console.error('Storage quota exceeded. Unable to save transcript:', error.message); - // You could add user notification here - alert('Local Storage full - please clear some space to save new transcripts'); + showStorageNotice('Browser storage is full — delete an entry from Recents to make room.'); } else { console.error('Error saving transcript:', error); } } } +/* + * Rename an entry (meta.name only — the storage key and any cached media are + * untouched). The name is de-duplicated against other entries. `updated` is + * deliberately NOT bumped: renaming should not reorder the list. + * @return {boolean} whether the rename was applied + */ +function renameTranscriptEntry(fileKey, newName, storage = window.localStorage) { + const entry = readTranscriptEntry(fileKey, storage); + if (entry === null) return false; + const name = String(newName).trim(); + if (name === "") return false; + if (name === entryName(fileKey, entry)) return true; + entry.meta = Object.assign({}, entry.meta, { name: uniqueEntryName(name, storage, fileKey) }); + try { + storage.setItem(fileKey, JSON.stringify(entry)); + } catch (error) { + console.error('Error renaming transcript:', error); + return false; + } + return true; +} + +/* + * Delete an entry and its cached media. Works for unparseable legacy entries + * too (their media key is derived from the legacy name). + */ +function deleteTranscriptEntry(fileKey, storage = window.localStorage) { + const entry = readTranscriptEntry(fileKey, storage); + const mediaKey = entryMediaKey(fileKey, entry); + storage.removeItem(fileKey); + deleteMedia(MEDIA_DATABASE, MEDIA_STORE, mediaKey); + if (activeDocKey === fileKey) { + activeDocKey = null; + } +} + +// Non-blocking notice above the Recents list (quota problems etc.) — replaces +// the old blocking alert(). Auto-dismisses. +function showStorageNotice(message) { + const picker = document.querySelector('#file-picker'); + if (picker === null || picker.parentElement === null) return; + let el = document.getElementById('recents-notice'); + if (el === null) { + el = document.createElement('div'); + el.id = 'recents-notice'; + el.setAttribute('role', 'alert'); + picker.parentElement.insertBefore(el, picker); + } + el.textContent = message; + clearTimeout(showStorageNotice._timer); + showStorageNotice._timer = setTimeout(() => { el.remove(); }, 8000); +} + // Escape text/keys interpolated into picker markup (#410) — a saved filename // containing < or " must not break (or script) the list. function escapeStorageMarkup(text) { @@ -356,8 +568,13 @@ function escapeStorageMarkup(text) { .replace(/"/g, '"'); } +const RECENTS_RENAME_SVG = ''; +const RECENTS_DELETE_SVG = ''; + function loadLocalStorageOptions(storage = window.localStorage) { + migrateLegacyEntries(storage); + let fileSelect = document.querySelector("#load-localstorage-filename"); let filePicker = document.querySelector("#file-picker"); @@ -369,23 +586,36 @@ function loadLocalStorageOptions(storage = window.localStorage) { // ANY key is written — and other modules write keys at arbitrary times // (prefs on every toggle) — so a positional index resolved at click time // could load a different entry than the one listed. - for (let i = 0; i < storage.length; i++) { - if (storage.key(i).indexOf(fileExtension) > 0) { - const key = storage.key(i); - const filename = escapeStorageMarkup(key.substring(0, key.lastIndexOf(fileExtension))); - const keyAttr = escapeStorageMarkup(key); - fileSelect.insertAdjacentHTML("beforeend", ``); - filePicker.insertAdjacentHTML("beforeend", `
  • ${filename}
  • `); - } - } + const rows = listDocEntries(storage); + rows.forEach(({ key, name }) => { + const keyAttr = escapeStorageMarkup(key); + const nameHtml = escapeStorageMarkup(name); + fileSelect.insertAdjacentHTML("beforeend", ``); + filePicker.insertAdjacentHTML("beforeend", + `
  • ${nameHtml}` + + `` + + `` + + `` + + `
  • `); + }); setFileSelectListeners(); - if (storage.length === 0) { + if (rows.length === 0) { // opacity 0.75 (not 0.55) so the composited grey still meets the 4.5:1 // contrast ratio on the white card (#402) filePicker.insertAdjacentHTML("beforeend", `
  • No files saved.
  • `); } + + markActiveRecentsRow(); +} + +// Highlight the row of the entry currently loaded in the editor (survives +// re-renders, unlike the click-time class toggle alone). +function markActiveRecentsRow() { + document.querySelectorAll('#file-picker .file-item').forEach((el) => { + el.classList.toggle('active', el.getAttribute('data-key') === activeDocKey); + }); } function setFileSelectListeners() { @@ -397,28 +627,115 @@ function setFileSelectListeners() { file.removeEventListener('mouseover', fileSelectHandleHover); file.addEventListener('mouseover', fileSelectHandleHover); }); -} - -function fileSelectHandleClick(event) { - loadHyperTranscriptFromLocalStorage(event.target.getAttribute("data-key")); - let files = document.querySelectorAll('.file-item'); + document.querySelectorAll('.recents-rename').forEach((btn) => { + btn.removeEventListener('click', recentsRenameHandleClick); + btn.addEventListener('click', recentsRenameHandleClick); + }); - files.forEach(file => { - file.classList.remove("active"); + document.querySelectorAll('.recents-delete').forEach((btn) => { + btn.removeEventListener('click', recentsDeleteHandleClick); + btn.addEventListener('click', recentsDeleteHandleClick); }); +} + +function fileSelectHandleClick(event) { + // a rename input lives inside the row's ; its clicks are not loads + if (event.target.classList && event.target.classList.contains('recents-rename-input')) { + return; + } + loadHyperTranscriptFromLocalStorage(event.currentTarget.getAttribute("data-key")); - event.target.classList.add("active"); + markActiveRecentsRow(); event.preventDefault(); return false; } function fileSelectHandleHover(event) { - loadSummaryFromLocalStorage(event.target.getAttribute("data-key"), event.target); + loadSummaryFromLocalStorage(event.currentTarget.getAttribute("data-key"), event.currentTarget); event.preventDefault(); return false; } +/* ---- Recents row actions: rename (inline edit) and delete (two-step) ---- */ + +function findRecentsItem(fileKey) { + return [...document.querySelectorAll('#file-picker .file-item')] + .find((el) => el.getAttribute('data-key') === fileKey) || null; +} + +function recentsRenameHandleClick(event) { + event.preventDefault(); + event.stopPropagation(); + startRecentsRename(event.currentTarget.getAttribute('data-key')); +} + +// Swap the row label for a text input; Enter/blur commits, Escape cancels. +// Re-rendering the list restores normal rows in every exit path. +function startRecentsRename(fileKey, storage = window.localStorage) { + const item = findRecentsItem(fileKey); + if (item === null) return; + const currentName = entryName(fileKey, readTranscriptEntry(fileKey, storage)); + + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentName; + input.className = 'recents-rename-input'; + input.setAttribute('aria-label', 'New name'); + item.textContent = ''; + item.appendChild(input); + input.focus(); + input.select(); + + let finished = false; + const finish = (commit) => { + if (finished) return; + finished = true; + if (commit) { + renameTranscriptEntry(fileKey, input.value, storage); + if (activeDocKey === fileKey) { + const saveInput = document.querySelector('#save-localstorage-filename'); + if (saveInput !== null) { + saveInput.value = entryName(fileKey, readTranscriptEntry(fileKey, storage)); + } + } + } + loadLocalStorageOptions(storage); + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') finish(true); + if (e.key === 'Escape') finish(false); + }); + input.addEventListener('blur', () => finish(true)); + input.addEventListener('click', (e) => e.stopPropagation()); +} + +// First click arms the button ("Delete?"), second click within the window +// deletes; the timeout restores the button without re-rendering the list (a +// re-render could interrupt a rename in progress on another row). +function recentsDeleteHandleClick(event) { + event.preventDefault(); + event.stopPropagation(); + const btn = event.currentTarget; + + if (btn.dataset.confirming !== 'true') { + btn.dataset.confirming = 'true'; + btn.classList.add('confirming'); + btn.textContent = 'Delete?'; + btn._resetTimer = setTimeout(() => { + btn.dataset.confirming = 'false'; + btn.classList.remove('confirming'); + btn.innerHTML = RECENTS_DELETE_SVG; + }, 4000); + return; + } + + clearTimeout(btn._resetTimer); + deleteTranscriptEntry(btn.getAttribute('data-key')); + loadLocalStorageOptions(); +} + // Read an entry by its key string. A corrupted value must not throw out of the // click handler (that permanently broke the picker), and an entry missing the // expected fields must not half-populate the editor (renderTranscript reads @@ -440,10 +757,10 @@ function loadHyperTranscriptFromLocalStorage(fileKey, storage = window.localStor && typeof hypertranscriptstorage.hypertranscript === 'string' && typeof hypertranscriptstorage.video === 'string') { - lastFilename = fileKey.substring(0, fileKey.lastIndexOf(fileExtension)); - renderTranscript(hypertranscriptstorage, lastFilename); + activeDocKey = fileKey; + renderTranscript(hypertranscriptstorage, entryMediaKey(fileKey, hypertranscriptstorage)); - document.querySelector('#save-localstorage-filename').value = lastFilename; + document.querySelector('#save-localstorage-filename').value = entryName(fileKey, hypertranscriptstorage); } else if (hypertranscriptstorage) { console.warn(`Saved entry "${fileKey}" is missing transcript/video fields — not loading.`); } @@ -456,4 +773,23 @@ function loadSummaryFromLocalStorage(fileKey, target, storage = window.localStor if (hypertranscriptstorage && hypertranscriptstorage.summary !== undefined) { target.setAttribute("title", hypertranscriptstorage.summary + "\n\nTopics: " + getTopicsString(hypertranscriptstorage.topics)); } -} \ No newline at end of file +} + +// Export pure helpers for the unit lane (#434); the file only declares +// functions at load time, so requiring it in Node is safe (deleteMedia guards +// on indexedDB being present). +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + isDocKey, + isLegacyKey, + entryName, + entryMediaKey, + uniqueEntryName, + listDocEntries, + migrateLegacyEntries, + renameTranscriptEntry, + deleteTranscriptEntry, + readTranscriptEntry, + escapeStorageMarkup, + }; +} From 79e7501649dcd7d920589c849befff24f0d64fb5 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 18:04:17 +0200 Subject: [PATCH 2/7] Recents: sort by last edit, with creation date standing in when never edited An entry that has never been edited since creation sorts by its creation date (updated || created); migration now stamps created (the true creation date was never recorded, so migration time is the proxy) so legacy entries order by date from then on instead of pooling alphabetically at the bottom. Ties and entries with no date at all still break alphabetically. --- __TEST__/e2e/storage.spec.mjs | 16 +++++++++------- __TEST__/unit/storage.test.mjs | 11 +++++++---- js/hyperaudio-lite-editor-storage.js | 13 +++++++++---- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index 362e48f..37614fb 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -75,21 +75,23 @@ test('legacy name-keyed entries migrate to stable ID keys on first list render ( expect(r.names).toEqual(['alpha', 'beta']); }); -test('rows order by last-updated, newest first; undated entries follow alphabetically (#434)', async ({ page }) => { - await seed(page); +test('rows order by last edit, falling back to creation date; migrated entries carry a date (#434)', async ({ page }) => { + await seed(page); // alpha + beta migrate with created = migration time await page.evaluate(() => { - const entry = (name, updated) => JSON.stringify({ + const entry = (name, meta) => JSON.stringify({ hypertranscript: '

    x

    ', video: 'https://example.com/a.mp3', summary: 's', topics: [], - meta: updated ? { name, updated } : { name }, + meta: Object.assign({ name }, meta), }); - localStorage.setItem('hyperaudio:doc:t1', entry('older', 1000)); - localStorage.setItem('hyperaudio:doc:t2', entry('newest', 3000)); + const now = Date.now(); + localStorage.setItem('hyperaudio:doc:t1', entry('edited-recently', { updated: now + 100000 })); + localStorage.setItem('hyperaudio:doc:t2', entry('created-recently', { created: now + 50000 })); // never edited loadLocalStorageOptions(); }); const names = await page.evaluate(() => [...document.querySelectorAll('.file-item')].map((a) => a.textContent)); - expect(names).toEqual(['newest', 'older', 'alpha', 'beta']); + // migrated alpha/beta share a creation stamp → tie broken alphabetically + expect(names).toEqual(['edited-recently', 'created-recently', 'alpha', 'beta']); }); test('rename via the row action edits the name in place; the key is untouched (#434)', async ({ page }) => { diff --git a/__TEST__/unit/storage.test.mjs b/__TEST__/unit/storage.test.mjs index 0632807..e4457ae 100644 --- a/__TEST__/unit/storage.test.mjs +++ b/__TEST__/unit/storage.test.mjs @@ -56,6 +56,7 @@ test('migration: legacy entry moves to an ID key, name and mediaKey preserved', const migrated = JSON.parse(s.getItem(rows[0].key)); assert.equal(migrated.meta.name, 'interview'); assert.equal(migrated.meta.mediaKey, 'interview'); // media stays under its legacy key + assert.ok(migrated.meta.created > 0); // stamped so the entry sorts by date from now on assert.equal(migrated.hypertranscript.includes('data-m'), true); }); @@ -86,14 +87,16 @@ test('uniqueEntryName: suffixes instead of colliding, own key excluded', () => { assert.equal(uniqueEntryName('interview', s, 'hyperaudio:doc:one'), 'interview'); }); -test('listDocEntries: newest-updated first; entries without timestamps sort last, alphabetically', () => { +test('listDocEntries: last-edited first, creation date stands in when never edited', () => { const s = fakeStorage({ 'hyperaudio:doc:a': entry({ meta: { name: 'older', updated: 1000 } }), 'hyperaudio:doc:b': entry({ meta: { name: 'newest', updated: 3000 } }), - 'hyperaudio:doc:c': entry({ meta: { name: 'zeta' } }), - 'hyperaudio:doc:d': entry({ meta: { name: 'alpha' } }), + 'hyperaudio:doc:c': entry({ meta: { name: 'created-only', created: 2000 } }), // never edited → creation date + 'hyperaudio:doc:d': entry({ meta: { name: 'zeta' } }), // no dates at all → + 'hyperaudio:doc:e': entry({ meta: { name: 'alpha' } }), // bottom, alphabetical }); - assert.deepEqual(listDocEntries(s).map((r) => r.name), ['newest', 'older', 'alpha', 'zeta']); + assert.deepEqual(listDocEntries(s).map((r) => r.name), + ['newest', 'created-only', 'older', 'alpha', 'zeta']); }); test('rename: one-field update, de-duplicated, key untouched; empty name rejected', () => { diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index 56e6e1e..c4fffee 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -109,18 +109,21 @@ function uniqueEntryName(desired, storage, excludeKey) { return `${desired} (${n})`; } -// All saved entries as {key, name, updated}, newest-updated first; entries -// with no timestamp (freshly migrated legacy) sort below, alphabetically. +// All saved entries as {key, name, updated}, last-edited first — an entry +// never edited since creation sorts by its creation date (save stamps both, +// migration stamps created). Ties, and entries with no date at all, break +// alphabetically. function listDocEntries(storage) { const rows = []; for (let i = 0; i < storage.length; i++) { const key = storage.key(i); if (!isDocKey(key) && !isLegacyKey(key)) continue; const entry = readTranscriptEntry(key, storage); + const meta = (entry && entry.meta) || {}; rows.push({ key, name: entryName(key, entry), - updated: (entry && entry.meta && entry.meta.updated) || 0, + updated: meta.updated || meta.created || 0, }); } rows.sort((a, b) => (b.updated - a.updated) || a.name.localeCompare(b.name)); @@ -139,7 +142,9 @@ function migrateLegacyEntries(storage) { const entry = readTranscriptEntry(key, storage); if (!entry || typeof entry.hypertranscript !== "string") return; // leave it; still listed + deletable const name = legacyNameFromKey(key); - entry.meta = Object.assign({}, entry.meta, { name, mediaKey: name }); + // The true creation date was never recorded — stamp migration time as the + // proxy so the entry sorts by date (updated || created) from here on. + entry.meta = Object.assign({}, entry.meta, { name, mediaKey: name, created: Date.now() }); try { storage.setItem(newDocKey(), JSON.stringify(entry)); storage.removeItem(key); From d465061519e08c49354181534796d7c4cb4d13de Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 18:21:14 +0200 Subject: [PATCH 3/7] Recents: auto-add every transcription/import, autosave edits, one-time disclosure (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every new document now lands in Recents automatically: hyperaudioInit is the hook — all five engines and the JSON/SRT/VTT import paths dispatch it, and neither the initial demo nor a Recents load does, so auto-add can never duplicate an existing entry. The entry is named after its media (local file's real name via the #426 mediaRef stamp; decoded URL basename for remote), always as a NEW entry — a repeat transcription coexists via the " (2)" suffix instead of overwriting. Edits autosave to the active entry, debounced 2s, from both the transcript (contenteditable) and the caption editor inputs. Autosaves skip re-encoding the media blob when document+source are unchanged, so typing doesn't rewrite a large IndexedDB entry every pause. Caption data is only stored when the track actually holds a data: URL — at auto-add time the track has been reset and not yet regenerated, and storing the empty src would have broken the load path (it now falls into its regenerate branch instead). The first time something autosaves, a one-time dismissible info notice explains that transcripts stay in this browser on this device and are managed in Recents — disclosure and control (rename/delete) rather than an upfront permission gate, which would mostly retrain users to click through it and, if declined, reintroduce the lost-transcription foot-gun this exists to fix. --- __TEST__/e2e/storage.spec.mjs | 62 ++++++++++++ __TEST__/unit/storage.test.mjs | 10 ++ css/hyperaudio-lite-editor.css | 30 +++++- js/hyperaudio-lite-editor-storage.js | 136 +++++++++++++++++++++++++-- 4 files changed, 229 insertions(+), 9 deletions(-) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index 37614fb..07433a8 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -152,6 +152,68 @@ test('clicking a row loads it and marks it active; the highlight survives a re-r await expect(page.locator('.file-item.active')).toHaveText('beta'); }); +test('a new transcription auto-saves to Recents named after its media; repeats get a suffix (#435)', async ({ page }) => { + await seed(page); + const transcribe = () => page.evaluate(() => { + document.querySelector('#hyperplayer').src = 'https://example.com/media/clip.mp4'; + document.querySelector('#hypertranscript').innerHTML = + '

    FRESH

    '; + document.dispatchEvent(new CustomEvent('hyperaudioInit')); + }); + await transcribe(); + await expect(page.locator('.file-item', { hasText: 'clip.mp4' })).toHaveCount(1); + // the new entry is active and sits first (newest updated) + await expect(page.locator('.file-item.active')).toHaveText('clip.mp4'); + expect(await page.evaluate(() => document.querySelector('.file-item').textContent)).toBe('clip.mp4'); + // a second transcription of the same media coexists rather than overwriting + await transcribe(); + const names = await page.evaluate(() => + [...document.querySelectorAll('.file-item')].map((a) => a.textContent)); + expect(names).toContain('clip.mp4'); + expect(names).toContain('clip.mp4 (2)'); +}); + +test('the autosave disclosure shows once ever, is info-toned, and dismisses (#435)', async ({ page }) => { + await seed(page); // clears localStorage, so the flag is unset + const transcribe = () => page.evaluate(() => { + document.querySelector('#hyperplayer').src = 'https://example.com/media/clip.mp4'; + document.querySelector('#hypertranscript').innerHTML = + '

    X

    '; + document.dispatchEvent(new CustomEvent('hyperaudioInit')); + }); + await transcribe(); + const notice = page.locator('#recents-notice'); + await expect(notice).toHaveClass(/notice-info/); + await expect(notice).toContainText('on this device only'); + await notice.locator('.recents-notice-dismiss').click(); + await expect(notice).toHaveCount(0); + await transcribe(); + await expect(notice).toHaveCount(0); // never again +}); + +test('edits autosave (debounced) to the active entry and bump its updated stamp (#435)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + const before = await page.evaluate(() => { + const key = Object.keys(localStorage).find((k) => + k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta'); + return { key, updated: JSON.parse(localStorage.getItem(key)).meta.updated || 0 }; + }); + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'EDITED '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.waitForTimeout(2600); // past the 2s debounce + const after = await page.evaluate((key) => JSON.parse(localStorage.getItem(key)), before.key); + expect(after.hypertranscript).toContain('EDITED'); + expect(after.meta.name).toBe('beta'); // autosave keeps the name + expect(after.meta.updated).toBeGreaterThan(before.updated); +}); + test('a filename containing markup renders as text (#410)', async ({ page }) => { await seed(page); await page.evaluate(() => { diff --git a/__TEST__/unit/storage.test.mjs b/__TEST__/unit/storage.test.mjs index e4457ae..7ac125d 100644 --- a/__TEST__/unit/storage.test.mjs +++ b/__TEST__/unit/storage.test.mjs @@ -17,6 +17,7 @@ const { migrateLegacyEntries, renameTranscriptEntry, deleteTranscriptEntry, + mediaNameFromRef, } = require('../../js/hyperaudio-lite-editor-storage.js'); function fakeStorage(init = {}) { @@ -130,6 +131,15 @@ test('delete: removes the entry, including an unparseable legacy one', () => { assert.equal(listDocEntries(s).length, 0); }); +test('mediaNameFromRef: URL basename (decoded), plain filename passthrough, Untitled fallback (#435)', () => { + assert.equal(mediaNameFromRef('https://example.com/media/clip.mp4'), 'clip.mp4'); + assert.equal(mediaNameFromRef('https://example.com/media/My%20Interview.mp3?token=1#t=10'), 'My Interview.mp3'); + assert.equal(mediaNameFromRef('https://example.com/'), 'Untitled'); // no basename in the path + assert.equal(mediaNameFromRef('interview.mp4'), 'interview.mp4'); // a local file's real name + assert.equal(mediaNameFromRef(''), 'Untitled'); + assert.equal(mediaNameFromRef(null), 'Untitled'); +}); + test('entryName / entryMediaKey fall back sensibly for malformed entries', () => { assert.equal(entryName('alpha.hyperaudio', null), 'alpha'); assert.equal(entryName('hyperaudio:doc:x', null), 'Untitled'); diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index 2756af9..cf2876d 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -1018,12 +1018,38 @@ label[data-a11y-wired]:focus-visible { border-radius: 0.25rem; background-color: oklch(var(--b1)); } -/* quota / storage problems surface here instead of a blocking alert() */ +/* notices above the Recents list: quota problems (error tone, auto-dismiss) + and the one-time autosave disclosure (info tone, sticky until ✕) — both + replace what used to be a blocking alert() */ #recents-notice { + display: flex; + align-items: flex-start; + gap: 8px; margin: 0 16px 8px; padding: 8px 12px; border-radius: 0.5rem; + font-size: 12px; +} +#recents-notice.notice-error { background-color: oklch(var(--er) / 0.12); color: oklch(var(--er)); - font-size: 12px; +} +#recents-notice.notice-info { + background-color: oklch(var(--b2)); + color: oklch(var(--bc) / 0.8); +} +#recents-notice .recents-notice-dismiss { + margin: 0 0 0 auto; + padding: 0 2px; + border: none; + background: transparent; + color: inherit; + font-size: 11px; + line-height: 1.4; + cursor: pointer; +} +#recents-notice .recents-notice-dismiss:hover { + border: none; + background: transparent; + opacity: 0.7; } diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index c4fffee..f9cd960 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -54,6 +54,10 @@ const MEDIA_STORE = "media"; // delete clears it. let activeDocKey = null; +// docKey + '|' + blob-URL of the media most recently written to IndexedDB, so +// debounced autosaves skip re-encoding an unchanged blob (see the save path). +let savedMediaStamp = null; + function isDocKey(key) { return typeof key === "string" && key.startsWith(DOC_KEY_PREFIX); } @@ -465,8 +469,12 @@ function saveHyperTranscriptToLocalStorage( // if media url begins with blob it means it's locally cached only for the session // we need to save the media to indexdb so that we can retrieve outside the session + // — but only once per document+source: debounced edit autosaves must not + // re-encode and re-write a large media blob on every pause in typing. - if (video.startsWith("blob:") === true) { + const mediaStamp = docKey + '|' + video; + if (video.startsWith("blob:") === true && savedMediaStamp !== mediaStamp) { + savedMediaStamp = mediaStamp; initializeDatabase(MEDIA_DATABASE, MEDIA_STORE) .then(() => { let blobURL = video; @@ -493,7 +501,14 @@ function saveHyperTranscriptToLocalStorage( let summary = document.getElementById("summary").innerHTML; let topics = document.getElementById("topics").innerHTML.split(", "); + // Only store real caption data. At auto-add time (hyperaudioInit) the track + // has been reset and not yet regenerated, so its src is empty — storing that + // would break the load path; leaving captions undefined makes load fall into + // its regenerate branch instead. let captions = document.getElementById(vttId).src; + if (typeof captions !== 'string' || captions.indexOf('data:') !== 0) { + captions = undefined; + } let hypertranscriptstorage = new HyperTranscriptStorage(hypertranscript, video, summary, topics, captions, meta); @@ -546,21 +561,127 @@ function deleteTranscriptEntry(fileKey, storage = window.localStorage) { } } -// Non-blocking notice above the Recents list (quota problems etc.) — replaces -// the old blocking alert(). Auto-dismisses. -function showStorageNotice(message) { +// Non-blocking notice above the Recents list — replaces the old blocking +// alert(). Error tone auto-dismisses; opts {tone:'info', sticky:true} gives a +// neutral note that stays until its ✕ is clicked (the autosave disclosure). +function showStorageNotice(message, opts = {}) { const picker = document.querySelector('#file-picker'); if (picker === null || picker.parentElement === null) return; let el = document.getElementById('recents-notice'); if (el === null) { el = document.createElement('div'); el.id = 'recents-notice'; - el.setAttribute('role', 'alert'); picker.parentElement.insertBefore(el, picker); } - el.textContent = message; + el.setAttribute('role', opts.tone === 'info' ? 'status' : 'alert'); + el.className = opts.tone === 'info' ? 'notice-info' : 'notice-error'; + el.textContent = ''; + el.appendChild(document.createTextNode(message)); + const dismiss = document.createElement('button'); + dismiss.type = 'button'; + dismiss.className = 'recents-notice-dismiss'; + dismiss.setAttribute('aria-label', 'Dismiss'); + dismiss.textContent = '✕'; + dismiss.addEventListener('click', () => { el.remove(); }); + el.appendChild(dismiss); clearTimeout(showStorageNotice._timer); - showStorageNotice._timer = setTimeout(() => { el.remove(); }, 8000); + if (opts.sticky !== true) { + showStorageNotice._timer = setTimeout(() => { el.remove(); }, 8000); + } +} + +/* ---------------------------------------------------------------------------- + * Autosave (#435): every new transcription or import lands in Recents + * automatically, and edits autosave to the active entry. + * + * hyperaudioInit is the "a new document just landed" moment — all five + * transcription engines and the JSON/SRT/VTT import paths dispatch it, and + * neither the initial demo transcript nor a Recents load does, so listening + * here can never duplicate an existing entry. The entry is named after its + * media; edits then autosave debounced to the same entry. + * ------------------------------------------------------------------------- */ + +const AUTOSAVE_NOTICE_FLAG = 'hyperaudioAutosaveNoticeShown'; +const AUTOSAVE_DEBOUNCE_MS = 2000; + +// Display name for the media the player holds: an http(s) src wins (fresh +// remote URL beats a stale stamp — same preference as guessMediaSrc in +// editor-core), then the stamped mediaRef (a local file's real name, or the +// original URL for remote/HLS — #426). +function mediaDisplayName() { + const player = document.querySelector('#hyperplayer'); + if (player === null) return 'Untitled'; + const ref = (/^https?:/i.test(player.src) ? player.src : player.dataset.mediaRef) || ''; + return mediaNameFromRef(ref); +} + +// Pure part of the above: URL → decoded basename of its path; a plain string +// is already a filename; empty → 'Untitled'. +function mediaNameFromRef(ref) { + if (!ref) return 'Untitled'; + if (/^https?:/i.test(ref)) { + try { + const path = new URL(ref).pathname; + const base = decodeURIComponent(path.substring(path.lastIndexOf('/') + 1)); + return base !== '' ? base : 'Untitled'; + } catch (e) { + return 'Untitled'; + } + } + return ref; +} + +// One-time disclosure the first time something autosaves: storage is local to +// this browser/device, and Recents is where to manage it. Informational, not +// a permission gate — shown once ever (flag persists), dismissible. +function maybeShowAutosaveNotice(storage = window.localStorage) { + try { + if (storage.getItem(AUTOSAVE_NOTICE_FLAG) !== null) return; + storage.setItem(AUTOSAVE_NOTICE_FLAG, String(Date.now())); + } catch (e) { + return; + } + showStorageNotice( + 'Transcripts are saved to Recents automatically — stored in your browser, on this device only.', + { tone: 'info', sticky: true } + ); +} + +let autosaveTimer = null; + +function scheduleAutosave() { + if (activeDocKey === null) return; // nothing has landed yet this session + clearTimeout(autosaveTimer); + autosaveTimer = setTimeout(() => { + if (activeDocKey === null) return; // deleted while the timer was pending + const entry = readTranscriptEntry(activeDocKey, window.localStorage); + saveHyperTranscriptToLocalStorage(entry !== null ? entryName(activeDocKey, entry) : undefined); + loadLocalStorageOptions(); // reflect the new "updated" order + }, AUTOSAVE_DEBOUNCE_MS); +} + +if (typeof document !== 'undefined') { + // A new transcription/import: always a NEW entry (never overwrite whatever + // was active before), named after its media. + window.document.addEventListener('hyperaudioInit', () => { + activeDocKey = null; + saveHyperTranscriptToLocalStorage(mediaDisplayName()); + loadLocalStorageOptions(); + const saveInput = document.querySelector('#save-localstorage-filename'); + if (saveInput !== null && activeDocKey !== null) { + saveInput.value = entryName(activeDocKey, readTranscriptEntry(activeDocKey, window.localStorage)); + } + maybeShowAutosaveNotice(); + }, false); + + // Debounced autosave of edits — transcript (contenteditable) and caption + // editor inputs both bubble input events. + document.addEventListener('input', (event) => { + const target = event.target; + if (target && target.closest && target.closest('#hypertranscript, #caption-editor') !== null) { + scheduleAutosave(); + } + }); } // Escape text/keys interpolated into picker markup (#410) — a saved filename @@ -796,5 +917,6 @@ if (typeof module !== 'undefined' && module.exports) { deleteTranscriptEntry, readTranscriptEntry, escapeStorageMarkup, + mediaNameFromRef, }; } From c660c4ac85c40fbe06bfb85e74ec76df584f854b Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 18:51:05 +0200 Subject: [PATCH 4/7] Recents polish: align rows with the notice, calm the active style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu's default 8px padding put row pills 8px left of the notice box — both now sit on a 16px inset. The active row swaps daisyUI's near-black menu pill (suddenly prominent now that auto-add marks the new entry active immediately) for a quiet base-200 fill + semibold. --- css/hyperaudio-lite-editor.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index cf2876d..ac4d0ae 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -1053,3 +1053,19 @@ label[data-a11y-wired]:focus-visible { background: transparent; opacity: 0.7; } + +/* Line the Recents content up on one 16px inset: the menu's default 8px + padding put row pills 8px left of the notice box above them. */ +#file-picker { + padding: 0 16px 8px; +} + +/* The active row: daisyUI's menu .active is a near-black pill, which reads as + alarming now that auto-add marks the new entry active immediately. A quiet + base-200 fill + weight says "current" without shouting. */ +#file-picker .file-item.active, +#file-picker .file-item.active:hover { + background-color: oklch(var(--b2)); + color: inherit; + font-weight: 600; +} From 703177f8943c598040c76b62f0dbcbe3c6855c67 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 18:51:05 +0200 Subject: [PATCH 5/7] Auto-add must not capture the previous document's captions/summary/topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engines dispatch hyperaudioInit BEFORE regenerating captions, so at auto-add time the caption track — and the summary/topics panels — still hold the PREVIOUS document's content (the intro demo on a fresh session). The save guard only rejected an empty track, so a stale but valid data: URL was stamped into fresh entries, and loading one replayed the demo's captions. An auto-added entry now stores no derived state at all: captions regenerate from the transcript on load, and the first edit-autosave captures the real ones. Also: the row hover tooltip is only set when an entry actually has a summary or topics (it used to render just "Topics:" for entries with neither, plus a literal "..." placeholder before first hover). --- __TEST__/e2e/storage.spec.mjs | 24 ++++++++++++++ js/hyperaudio-lite-editor-storage.js | 47 +++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index 07433a8..04bbd3e 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -191,6 +191,30 @@ test('the autosave disclosure shows once ever, is info-toned, and dismisses (#43 await expect(notice).toHaveCount(0); // never again }); +test('auto-add never captures the previous document\'s captions/summary/topics (#435)', async ({ page }) => { + await seed(page); + const saved = await page.evaluate(() => { + // the state the engines leave at hyperaudioInit time: the caption track, + // summary, and topics still belong to the PREVIOUS document (the intro + // demo on a fresh session) — regeneration happens after the event + document.getElementById('hyperplayer-vtt').src = + 'data:text/vtt;charset=utf-8,' + encodeURIComponent('WEBVTT\n\n00:00.000 --> 00:01.000\nSTALE DEMO CUE'); + document.getElementById('summary').innerHTML = 'stale demo summary'; + document.getElementById('topics').innerHTML = 'stale, demo, topics'; + document.querySelector('#hyperplayer').src = 'https://example.com/media/clip.mp4'; + document.querySelector('#hypertranscript').innerHTML = + '

    FRESH

    '; + document.dispatchEvent(new CustomEvent('hyperaudioInit')); + const key = Object.keys(localStorage).find((k) => + k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'clip.mp4'); + return JSON.parse(localStorage.getItem(key)); + }); + expect(saved.captions).toBeUndefined(); // load regenerates from the transcript instead + expect(saved.summary).toBe(''); + expect(saved.topics).toEqual([]); + expect(saved.hypertranscript).toContain('FRESH'); +}); + test('edits autosave (debounced) to the active entry and bump its updated stamp (#435)', async ({ page }) => { await seed(page); await page.evaluate(() => { diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index f9cd960..574fd08 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -58,6 +58,14 @@ let activeDocKey = null; // debounced autosaves skip re-encoding an unchanged blob (see the save path). let savedMediaStamp = null; +// True only while the auto-add save runs (hyperaudioInit). The engines dispatch +// that event BEFORE regenerating captions, so the caption track — and the +// summary/topics panels — still hold the PREVIOUS document's content at that +// moment; capturing them stamped the intro demo's captions into fresh entries. +// An auto-added entry stores no derived state: captions regenerate from the +// transcript on load, and the first edit-autosave captures the real ones. +let suppressDerivedCapture = false; + function isDocKey(key) { return typeof key === "string" && key.startsWith(DOC_KEY_PREFIX); } @@ -501,14 +509,19 @@ function saveHyperTranscriptToLocalStorage( let summary = document.getElementById("summary").innerHTML; let topics = document.getElementById("topics").innerHTML.split(", "); - // Only store real caption data. At auto-add time (hyperaudioInit) the track - // has been reset and not yet regenerated, so its src is empty — storing that - // would break the load path; leaving captions undefined makes load fall into - // its regenerate branch instead. + // Only store real caption data (an empty track src would break the load + // path; captions left undefined make load fall into its regenerate branch). let captions = document.getElementById(vttId).src; if (typeof captions !== 'string' || captions.indexOf('data:') !== 0) { captions = undefined; } + // At auto-add time the track/summary/topics still belong to the PREVIOUS + // document (see suppressDerivedCapture) — store none of it. + if (suppressDerivedCapture === true) { + captions = undefined; + summary = ""; + topics = []; + } let hypertranscriptstorage = new HyperTranscriptStorage(hypertranscript, video, summary, topics, captions, meta); @@ -665,7 +678,12 @@ if (typeof document !== 'undefined') { // was active before), named after its media. window.document.addEventListener('hyperaudioInit', () => { activeDocKey = null; - saveHyperTranscriptToLocalStorage(mediaDisplayName()); + suppressDerivedCapture = true; + try { + saveHyperTranscriptToLocalStorage(mediaDisplayName()); + } finally { + suppressDerivedCapture = false; + } loadLocalStorageOptions(); const saveInput = document.querySelector('#save-localstorage-filename'); if (saveInput !== null && activeDocKey !== null) { @@ -718,7 +736,7 @@ function loadLocalStorageOptions(storage = window.localStorage) { const nameHtml = escapeStorageMarkup(name); fileSelect.insertAdjacentHTML("beforeend", ``); filePicker.insertAdjacentHTML("beforeend", - `
  • ${nameHtml}` + + `
  • ${nameHtml}` + `` + `` + `` + @@ -892,12 +910,25 @@ function loadHyperTranscriptFromLocalStorage(fileKey, storage = window.localStor } } +// Tooltip preview on hover — only when there is actually something to show. +// Unconditionally setting it gave entries with no summary/topics a stray +// tooltip reading just "Topics:". function loadSummaryFromLocalStorage(fileKey, target, storage = window.localStorage){ let hypertranscriptstorage = readTranscriptEntry(fileKey, storage); + if (hypertranscriptstorage === null) return; + + const summary = typeof hypertranscriptstorage.summary === 'string' + ? hypertranscriptstorage.summary.trim() : ''; + const topics = getTopicsString(hypertranscriptstorage.topics); + const parts = []; + if (summary !== '') parts.push(summary); + if (topics !== '') parts.push('Topics: ' + topics); - if (hypertranscriptstorage && hypertranscriptstorage.summary !== undefined) { - target.setAttribute("title", hypertranscriptstorage.summary + "\n\nTopics: " + getTopicsString(hypertranscriptstorage.topics)); + if (parts.length > 0) { + target.setAttribute("title", parts.join("\n\n")); + } else { + target.removeAttribute("title"); } } From 753ecab9a2dc7f7b9d4456e6c8f4dd722e2eae50 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 18:52:12 +0200 Subject: [PATCH 6/7] =?UTF-8?q?Point=20the=20changed-file=20cache-busters?= =?UTF-8?q?=20at=200.9.0=20=E2=80=94=20the=20Recents=20rework=20warrants?= =?UTF-8?q?=20a=20minor=20version=20bump,=20not=20another=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 5657189..e28b7c1 100644 --- a/index.html +++ b/index.html @@ -108,7 +108,7 @@ } - + @@ -1018,7 +1018,7 @@

    Load from Local Storage

    - + From 3b29045321d55924c57be26bf8cb4369eab01d4d Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 19:26:05 +0200 Subject: [PATCH 7/7] Deleting the loaded entry offers Restore; stop serialising data: media into entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting the entry that is loaded keeps the document on screen (the only undo there is) but silently stopped autosave — the old lost-work foot-gun through a side door. A sticky notice now says so and carries a Restore action: one click re-saves the on-screen document as a fresh entry under its old name, media included (a data: src goes back into IndexedDB; a blob: src re-saves through the normal path). The offer is withdrawn as soon as the screen holds a different document — a new transcription or another entry loaded — since restoring would then save the wrong content under the old name. This is also the groundwork for removing the explicit save/load dialogs (#436): recovery no longer needs them. Also: a document loaded from Recents has a base64 data: URL as its player src, and every save serialised that whole payload into the localStorage JSON — with debounced autosave that meant potentially megabytes rewritten on every pause in typing, straight toward the quota. The media already lives in IndexedDB under meta.mediaKey; saves now store an "indexeddb:" marker, which the load path treats like any non-http value. --- __TEST__/e2e/storage.spec.mjs | 47 +++++++++++++++++++++ css/hyperaudio-lite-editor.css | 23 +++++++++++ js/hyperaudio-lite-editor-storage.js | 62 +++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index 04bbd3e..c193574 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -141,6 +141,53 @@ test('delete is two-step and removes the entry (#434)', async ({ page }) => { expect(await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(1); }); +test('deleting the loaded entry offers Restore; restoring re-saves the on-screen doc (#434)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); + await row.hover(); + const del = row.locator('.recents-delete'); + await del.click(); + await del.click(); // confirm + await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(0); + const notice = page.locator('#recents-notice'); + await expect(notice).toContainText('no longer being saved'); + // the transcript is still on screen + expect(await page.evaluate(() => document.querySelector('#hypertranscript').textContent)).toContain('BETA'); + await notice.locator('.recents-notice-action').click(); + await expect(notice).toHaveCount(0); + await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(1); + await expect(page.locator('.file-item.active')).toHaveText('beta'); + const restored = await page.evaluate(() => { + const key = Object.keys(localStorage).find((k) => + k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta'); + return JSON.parse(localStorage.getItem(key)); + }); + expect(restored.hypertranscript).toContain('BETA'); +}); + +test('a pending Restore is withdrawn when the screen holds a different document (#434)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); + await row.hover(); + const del = row.locator('.recents-delete'); + await del.click(); + await del.click(); + await expect(page.locator('#recents-notice')).toContainText('no longer being saved'); + // loading another entry invalidates restore-from-screen + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'alpha').click(); + }); + await expect(page.locator('#recents-notice')).toHaveCount(0); +}); + test('clicking a row loads it and marks it active; the highlight survives a re-render (#434)', async ({ page }) => { await seed(page); await page.evaluate(() => { diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index ac4d0ae..31dcd17 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -1069,3 +1069,26 @@ label[data-a11y-wired]:focus-visible { color: inherit; font-weight: 600; } + +/* notice action (e.g. Restore after deleting the loaded entry): a quiet + primary-colored text button; when present it takes the right-push role and + the ✕ tucks in beside it */ +#recents-notice .recents-notice-action { + margin: 0 0 0 auto; + padding: 0 2px; + border: none; + background: transparent; + color: oklch(var(--p)); + font-size: 12px; + font-weight: 600; + line-height: 1.4; + cursor: pointer; +} +#recents-notice .recents-notice-action:hover { + border: none; + background: transparent; + text-decoration: underline; +} +#recents-notice .recents-notice-action + .recents-notice-dismiss { + margin-left: 4px; +} diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index 574fd08..4a1f220 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -460,6 +460,14 @@ function saveHyperTranscriptToLocalStorage( let video = document.getElementById(videoDomId).src; + // A doc loaded from Recents carries a base64 data: URL as its src (getMedia + // sets it directly). Never serialise that payload into the localStorage JSON + // — the media already lives in IndexedDB under meta.mediaKey. Store a marker + // instead; the load path treats any non-http value the same way (→ getMedia). + if (video.indexOf("data:") === 0) { + video = "indexeddb:"; + } + const existing = activeDocKey !== null ? readTranscriptEntry(activeDocKey, storage) : null; const docKey = existing !== null ? activeDocKey : newDocKey(); const prevMeta = (existing !== null && existing.meta) ? existing.meta : {}; @@ -590,6 +598,15 @@ function showStorageNotice(message, opts = {}) { el.className = opts.tone === 'info' ? 'notice-info' : 'notice-error'; el.textContent = ''; el.appendChild(document.createTextNode(message)); + el.dataset.hasAction = opts.action ? 'true' : 'false'; + if (opts.action) { + const action = document.createElement('button'); + action.type = 'button'; + action.className = 'recents-notice-action'; + action.textContent = opts.action.label; + action.addEventListener('click', () => { el.remove(); opts.action.handler(); }); + el.appendChild(action); + } const dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.className = 'recents-notice-dismiss'; @@ -603,6 +620,15 @@ function showStorageNotice(message, opts = {}) { } } +// A pending Restore offers to re-save the ON-SCREEN document; once the screen +// holds something else (new transcription, another entry loaded) that offer +// would save the wrong content under the old name — withdraw it. Only notices +// carrying an action are removed; the one-time disclosure stays. +function hideRestoreNotice() { + const el = document.getElementById('recents-notice'); + if (el !== null && el.dataset.hasAction === 'true') el.remove(); +} + /* ---------------------------------------------------------------------------- * Autosave (#435): every new transcription or import lands in Recents * automatically, and edits autosave to the active entry. @@ -677,6 +703,7 @@ if (typeof document !== 'undefined') { // A new transcription/import: always a NEW entry (never overwrite whatever // was active before), named after its media. window.document.addEventListener('hyperaudioInit', () => { + hideRestoreNotice(); activeDocKey = null; suppressDerivedCapture = true; try { @@ -876,7 +903,39 @@ function recentsDeleteHandleClick(event) { } clearTimeout(btn._resetTimer); - deleteTranscriptEntry(btn.getAttribute('data-key')); + const fileKey = btn.getAttribute('data-key'); + const wasActive = activeDocKey === fileKey; + const name = entryName(fileKey, readTranscriptEntry(fileKey, window.localStorage)); + deleteTranscriptEntry(fileKey); + loadLocalStorageOptions(); + + // Deleting the LOADED entry leaves the document on screen (the only undo + // there is), but autosave stops with it — say so, and offer the undo. + if (wasActive) { + showStorageNotice('Removed from Recents. The transcript is still on screen but no longer being saved.', { + tone: 'info', + sticky: true, + action: { label: 'Restore', handler: () => restoreDeletedTranscript(name) }, + }); + } +} + +// Undo for deleting the loaded entry: re-save the on-screen document as a +// fresh entry under its old name. Its media blob was deleted with the entry, +// so put the media back too — a data: src (loaded from IndexedDB) is written +// directly; a blob: src re-saves through the normal path (stamp reset). +function restoreDeletedTranscript(name) { + activeDocKey = null; + savedMediaStamp = null; + saveHyperTranscriptToLocalStorage(name); + if (activeDocKey !== null) { + const player = document.querySelector('#hyperplayer'); + if (player !== null && player.src.indexOf('data:') === 0) { + const entry = readTranscriptEntry(activeDocKey, window.localStorage); + const mediaKey = entry && entry.meta && entry.meta.mediaKey; + if (mediaKey) saveVideoFromBlobURL(mediaKey, player.src, MEDIA_DATABASE, MEDIA_STORE); + } + } loadLocalStorageOptions(); } @@ -901,6 +960,7 @@ function loadHyperTranscriptFromLocalStorage(fileKey, storage = window.localStor && typeof hypertranscriptstorage.hypertranscript === 'string' && typeof hypertranscriptstorage.video === 'string') { + hideRestoreNotice(); activeDocKey = fileKey; renderTranscript(hypertranscriptstorage, entryMediaKey(fileKey, hypertranscriptstorage));