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: '
';
+ 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