diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index 5601088..c193574 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,228 @@ 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 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, meta) => JSON.stringify({ + hypertranscript: '

x

', + video: 'https://example.com/a.mp3', summary: 's', topics: [], + meta: Object.assign({ name }, meta), + }); + 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)); + // 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 }) => { + 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('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(() => { + [...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 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('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(() => { + [...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 new file mode 100644 index 0000000..7ac125d --- /dev/null +++ b/__TEST__/unit/storage.test.mjs @@ -0,0 +1,149 @@ +// 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, + mediaNameFromRef, +} = 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.ok(migrated.meta.created > 0); // stamped so the entry sorts by date from now on + 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: 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: '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', 'created-only', '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('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'); + 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..31dcd17 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -953,3 +953,142 @@ 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)); +} +/* 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)); +} +#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; +} + +/* 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; +} + +/* 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/index.html b/index.html index b2684e2..e28b7c1 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..4a1f220 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,152 @@ 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; + +// 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; + +// 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); +} + +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}, 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: meta.updated || meta.created || 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); + // 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); + } 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 +202,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 +238,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 +273,8 @@ function renderTranscript( updateCaptionsFromTranscript = true; } - //console.log(plainVtt); - captionCache = null; - + populateCaptionEditorFromVtt(plainVtt); } @@ -147,29 +290,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 +336,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 +352,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 +405,7 @@ function saveVideoFromBlobURL(filename, blobData, databaseName, objectStoreName) request.onsuccess = function() { console.log("Video saved successfully!"); } - } + } } function initializeDatabase(database, objectStoreName) { @@ -270,10 +432,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 +458,40 @@ function saveHyperTranscriptToLocalStorage( hypertranscript = transcriptCache.innerHTML; } - 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 : {}; + 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 + // — 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) { - let objectStoreName = "media"; - let databaseName = "hyperaudioMedia"; - initializeDatabase(databaseName, objectStoreName) + const mediaStamp = docKey + '|' + video; + if (video.startsWith("blob:") === true && savedMediaStamp !== mediaStamp) { + savedMediaStamp = mediaStamp; + initializeDatabase(MEDIA_DATABASE, MEDIA_STORE) .then(() => { let blobURL = video; @@ -313,7 +502,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); }) @@ -328,24 +517,218 @@ function saveHyperTranscriptToLocalStorage( let summary = document.getElementById("summary").innerHTML; let topics = document.getElementById("topics").innerHTML.split(", "); + // 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; - let meta = {"updateCaptionsFromTranscript": updateCaptionsFromTranscript}; + 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); 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 — 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'; + picker.parentElement.insertBefore(el, picker); + } + el.setAttribute('role', opts.tone === 'info' ? 'status' : 'alert'); + 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'; + dismiss.setAttribute('aria-label', 'Dismiss'); + dismiss.textContent = '✕'; + dismiss.addEventListener('click', () => { el.remove(); }); + el.appendChild(dismiss); + clearTimeout(showStorageNotice._timer); + if (opts.sticky !== true) { + showStorageNotice._timer = setTimeout(() => { el.remove(); }, 8000); + } +} + +// 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. + * + * 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', () => { + hideRestoreNotice(); + activeDocKey = null; + suppressDerivedCapture = true; + try { + saveHyperTranscriptToLocalStorage(mediaDisplayName()); + } finally { + suppressDerivedCapture = false; + } + 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 // containing < or " must not break (or script) the list. function escapeStorageMarkup(text) { @@ -356,8 +739,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 +757,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 +798,147 @@ 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); + 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(); +} + // 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,20 +960,54 @@ function loadHyperTranscriptFromLocalStorage(fileKey, storage = window.localStor && typeof hypertranscriptstorage.hypertranscript === 'string' && typeof hypertranscriptstorage.video === 'string') { - lastFilename = fileKey.substring(0, fileKey.lastIndexOf(fileExtension)); - renderTranscript(hypertranscriptstorage, lastFilename); + hideRestoreNotice(); + 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.`); } } +// 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"); } -} \ 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, + mediaNameFromRef, + }; +}