From b256ff1be525fea2cf9b64ae6c64f36fcf4baa1a Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Mon, 3 Aug 2026 12:13:14 +0200 Subject: [PATCH] Project library over OPFS: per-project working copies, silent Save, and the side-panel UI (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side panel returns as a project library over OPFS, and saving becomes a silent commit — never a download. Storage (hyperaudio-save.js): - Every project lives in work// with a library.json index at the OPFS root (name, starred, timestamps, media meta, summary/topics). Identity is OPFS-native generated ids — none of the file↔workdir matching hazards; re-opening the same .hyperaudio makes a second entry. - Each project dir holds TWO states: saved.json, committed by Save (⌘S / the navbar button) as a SILENT OPFS write, and draft.json, the debounced autosave scratch. Dirty = lastDraftAt > lastSavedAt, so switching projects, closing or crashing loses nothing while the project honestly stays dirty until a real Save. A future format revision records each manual save as a version. - Export Project (.hyperaudio) is a new FILE menu item and the only path that downloads. The native bridge still receives the container on Save, and no-OPFS browsers fall back to the download-as-save. - Switching asks nothing and loses nothing: the outgoing draft flushes to its own directory first. The discard-on-switch dialogs die with the single work slot that required them. - The #450 Web Lock becomes per-project (hyperaudio:project:): two tabs edit different projects, the same project in a second tab gets the guarded banner and queues for promotion. Index writes serialize under an origin-global lock; a BroadcastChannel keeps other tabs' panels honest. - Boot restores the most recently edited project (draft first) — the index replaces the localStorage boot hint — and requests navigator.storage.persist(). The interim single-slot work/ layout (never released) migrates once. The quit guard narrows to the one loss case left: a deleted project's document living only on screen. Panel (hyperaudio-library.js, resurrected from pre-#451 history): - Rows over the index: last-edited order, Starred pinned with the #440 heading swap, current-project highlight, kebab menu (Info, star, inline rename, duplicate, armed two-step delete), delete-current's Restore undo. Rename IS the title Save and Export use — index, stored state files and live session all updated. - Row-height square kebab hover, row-level hover state, 4px row gaps, and a hover popout floated right of the panel (1s dwell) with the full name, summary and topics. - Info moved from the controls row into the kebab: the modal is project-bound — name, media file + duration, stored provenance, summary and topics in uniform sections. apply() rebuilds the Transcription section from stored provenance; it previously kept showing the last engine run regardless of project. Remaining transcription-report gaps are tracked in #457. Fixed along the way: gather() now preserves class="speaker" via a targeted sanitizer instead of the editor's blanket class strip — every save/autosave had been demoting speaker labels to plain words, so paragraphs lost their speaker names and restored labels lost their styling and the Speakers toggle. Latent since the Phase A writer. Tests: 60 unit / 71 e2e green. Note for future specs: page.waitForFunction with an ASYNC predicate resolves immediately (the pending Promise is truthy) — use pollPage in helpers.mjs; several phantom races traced back to exactly that. Closes #456. --- __TEST__/e2e/a11y.spec.mjs | 15 +- __TEST__/e2e/helpers.mjs | 13 + __TEST__/e2e/library.spec.mjs | 350 ++++++++ __TEST__/e2e/project-save.spec.mjs | 315 ++++++-- __TEST__/unit/hyperaudio-save.test.mjs | 46 ++ css/hyperaudio-lite-editor.css | 324 +++++++- index.html | 56 +- js/hyperaudio-library.js | 452 +++++++++++ js/hyperaudio-save.js | 1018 +++++++++++++++++++----- 9 files changed, 2266 insertions(+), 323 deletions(-) create mode 100644 __TEST__/e2e/library.spec.mjs create mode 100644 js/hyperaudio-library.js diff --git a/__TEST__/e2e/a11y.spec.mjs b/__TEST__/e2e/a11y.spec.mjs index 69cb0f40..ae71da20 100644 --- a/__TEST__/e2e/a11y.spec.mjs +++ b/__TEST__/e2e/a11y.spec.mjs @@ -79,11 +79,11 @@ test('transcribe modal (Local and Cloud tabs) has no #402-class violations', asy test('modal label-buttons are keyboard-operable; toggles are out of the tab order', async ({ page }) => { const r = await page.evaluate(() => { - const infoBtn = document.getElementById('info-btn'); - const toggle = document.getElementById('info-modal'); + const gapsBtn = document.getElementById('remove-gaps-btn'); + const toggle = document.getElementById('remove-gaps-modal'); return { - btnTabbable: infoBtn.tabIndex === 0, - btnWired: infoBtn.dataset.a11yWired === '1', + btnTabbable: gapsBtn.tabIndex === 0, + btnWired: gapsBtn.dataset.a11yWired === '1', toggleHidden: toggle.getAttribute('aria-hidden') === 'true', toggleUntabbable: toggle.tabIndex === -1, }; @@ -91,8 +91,9 @@ test('modal label-buttons are keyboard-operable; toggles are out of the tab orde expect(r).toEqual({ btnTabbable: true, btnWired: true, toggleHidden: true, toggleUntabbable: true }); // Enter on the focused label-button opens the modal (was impossible before — - // labels aren't natively keyboard-activatable) - await page.focus('#info-btn'); + // labels aren't natively keyboard-activatable). The info button, the + // previous example here, moved into the project kebab menu (#456). + await page.focus('#remove-gaps-btn'); await page.keyboard.press('Enter'); - expect(await page.evaluate(() => document.getElementById('info-modal').checked)).toBe(true); + expect(await page.evaluate(() => document.getElementById('remove-gaps-modal').checked)).toBe(true); }); diff --git a/__TEST__/e2e/helpers.mjs b/__TEST__/e2e/helpers.mjs index 30c57254..9789413e 100644 --- a/__TEST__/e2e/helpers.mjs +++ b/__TEST__/e2e/helpers.mjs @@ -95,3 +95,16 @@ export const ISSUE_371_WORDS = [ [30560, 80], [30800, 400], [31200, 80], [31360, 80], [31520, 80], [31680, 80], [31920, 80], [32160, 80], [32400, 80], [32640, 80], [32800, 80], [33120, 720], ]; + +// Await an ASYNC in-page condition by polling page.evaluate (which properly +// awaits async functions). page.waitForFunction must NOT be given an async +// predicate: it treats the returned pending Promise as truthy and resolves +// immediately — a whole class of #456 test races traced back to that. +export async function pollPage(page, fn, arg, { timeout = 10000, interval = 100 } = {}) { + const deadline = Date.now() + timeout; + for (;;) { + if (await page.evaluate(fn, arg)) return; + if (Date.now() > deadline) throw new Error('pollPage: condition not met within ' + timeout + 'ms'); + await page.waitForTimeout(interval); + } +} diff --git a/__TEST__/e2e/library.spec.mjs b/__TEST__/e2e/library.spec.mjs new file mode 100644 index 00000000..0b0ebda6 --- /dev/null +++ b/__TEST__/e2e/library.spec.mjs @@ -0,0 +1,350 @@ +// The project library panel (#456; js/hyperaudio-library.js over the +// HyperaudioSave.library API). Drives the shipped editor end to end: rows +// over the OPFS index, dialog-free switching that loses nothing, star/rename/ +// duplicate/delete via the kebab menu, delete-current's Restore undo, and the +// most-recently-edited boot restore. +import { test, expect } from '@playwright/test'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import { ladderWav, pollPage } from './helpers.mjs'; + +const require = createRequire(import.meta.url); +const save = require('../../js/hyperaudio-save.js'); +const JSZip = require('jszip'); + +async function buildFixture(title) { + const state = { + generatorVersion: 'e2e', + created: '2026-07-10T09:00:00Z', + modified: '2026-07-10T11:30:00Z', + media: { + kind: 'original', path: 'media/tone.wav', url: null, filename: 'tone.wav', + mimeType: 'audio/wav', durationSeconds: 2, sizeBytes: 0, + }, + options: { + gapRemoval: { enabled: false, thresholdMs: 500, bufferMs: 100 }, + updateCaptionsFromTranscript: true, + view: { showSpeakers: true, showTimecodes: false }, + }, + texts: { title, language: 'it', summary: 'summary of ' + title, topics: [] }, + provenance: { engine: 'deepgram', model: 'model of ' + title, transcribedAt: '2026-07-10T08:55:00Z' }, + hasOriginal: false, + transcript: { + words: [ + { start: 0.32, end: 0.84, text: 'Benvenuti' }, + { start: 1.1, end: 1.5, text: 'a' }, + ], + paragraphs: [{ speaker: 'Maria', start: 0.32, end: 1.5 }], + }, + }; + return save.zipProject({ + json: save.serializeProjectJson(save.buildProjectJson(state)), + html: '

Benvenuti

', + media: { name: 'tone.wav', data: ladderWav(2) }, + }, JSZip, 'nodebuffer'); +} + +// Open a titled fixture through the module's hidden input, then wait for its +// row to arrive AND become the active (current) one. +async function openProject(page, testInfo, title) { + const fixturePath = testInfo.outputPath(title.replace(/\s+/g, '-') + '.hyperaudio'); + fs.writeFileSync(fixturePath, await buildFixture(title)); + await page.evaluate(() => { document.getElementById('project-open-input').value = ''; }); + await page.setInputFiles('#project-open-input', fixturePath); + await expect(activeRow(page)).toHaveText(title); +} + +const row = (page, title) => page.locator('#file-picker .file-item', { hasText: title }); +const activeRow = (page) => page.locator('#file-picker .file-item.active'); +const rowTitles = (page) => page.evaluate(() => + [...document.querySelectorAll('#file-picker .file-item')].map((el) => el.textContent)); + +async function openKebab(page, title) { + const item = row(page, title); + await item.hover(); + await item.locator('..').locator('.recents-kebab').click(); + await expect(page.locator('#recents-menu')).toBeVisible(); +} + +const readLibraryState = (page) => page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text()); + const work = await root.getDirectoryHandle('work'); + const dirs = []; + for await (const [name, handle] of work.entries()) { + if (handle.kind === 'directory') dirs.push(name); + } + return { projects: lib.projects, dirs, current: window.HyperaudioSave.library.currentId() }; +}); + +test.beforeEach(async ({ page }) => { + await page.goto('/index.html'); + await page.waitForSelector('#hypertranscript [data-m]'); +}); + +test('empty library: the panel says so under its Recents heading', async ({ page }) => { + await expect(page.locator('#recents-title')).toHaveText('Recents'); + await expect(page.locator('#file-picker')).toContainText('No projects yet.'); +}); + +test('rows list by last edit with the current project highlighted; editing reorders', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openProject(page, testInfo, 'Project B'); + expect(await rowTitles(page)).toEqual(['Project B', 'Project A']); // B edited (created) last + + // switch back to A — no dialog, highlight moves, order unchanged (no edit yet) + await row(page, 'Project A').click(); + await expect(activeRow(page)).toHaveText('Project A'); + expect(await rowTitles(page)).toEqual(['Project B', 'Project A']); + + // hover reveals the full name (rows ellipsize) plus the stored preview in + // a popout floated RIGHT of the panel — clear of the row and its kebab + await row(page, 'Project A').hover(); + const popout = page.locator('#recents-popout'); + await expect(popout).toBeVisible(); + await expect(popout).toContainText('Project A'); + await expect(popout).toContainText('summary of Project A'); + expect(await page.evaluate(() => { + const pane = document.getElementById('recents-pane').getBoundingClientRect(); + const pop = document.getElementById('recents-popout').getBoundingClientRect(); + return pop.left >= pane.right; + })).toBe(true); + await page.locator('#hypertranscript').hover(); // leaving the row dismisses it + await expect(popout).toHaveCount(0); + + // an edit bumps A to the top (last-edited order) + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.textContent = 'EDITED-A '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await expect(page.locator('#file-picker .file-item').first()).toHaveText('Project A', { timeout: 5000 }); +}); + +test('switching flushes the outgoing project\'s pending edit — nothing lost, nothing asked (#456)', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openProject(page, testInfo, 'Project B'); + const state = await readLibraryState(page); + const idB = state.current; + + // edit B and switch away INSIDE the autosave debounce window + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.textContent = 'PENDING-B '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await row(page, 'Project A').click(); + await expect(activeRow(page)).toHaveText('Project A'); + await expect(page.locator('#hypertranscript')).not.toContainText('PENDING-B'); + + // no dialog appeared, and B's directory holds the pending edit as its DRAFT + expect(await page.evaluate(() => { + const el = document.getElementById('project-dialog'); + return el !== null && el.classList.contains('modal-open'); + })).toBe(false); + await pollPage(page, async (id) => { + try { + const root = await navigator.storage.getDirectory(); + const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id); + const text = await (await (await dir.getFileHandle('draft.json')).getFile()).text(); + return text.indexOf('PENDING-B') !== -1; + } catch (e) { return false; } + }, idB); + + // switching back replays the flushed edit + await row(page, 'Project B').click(); + await expect(page.locator('#hypertranscript')).toContainText('PENDING-B'); +}); + +test('Info lives in the kebab: switches to the project and shows ITS details (#456)', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openProject(page, testInfo, 'Project B'); // B is current now + + await openKebab(page, 'Project A'); + await page.locator('#recents-menu .recents-menu-info').click(); + + // Info made A current (dialog-free) and opened the modal with A's stored + // provenance and texts — not B's, and not a stale engine report + await expect(activeRow(page)).toHaveText('Project A'); + expect(await page.evaluate(() => document.getElementById('info-modal').checked)).toBe(true); + await expect(page.locator('#project-info-name')).toHaveText('Project A'); + await expect(page.locator('#project-info-media')).toContainText('tone.wav'); + await expect(page.locator('#project-info-media')).toContainText('Duration: 0:02'); // from the index's media meta + await expect(page.locator('#transcription-info')).toContainText('model of Project A'); + await expect(page.locator('#summary')).toContainText('summary of Project A'); + await page.evaluate(() => { document.getElementById('info-modal').checked = false; }); +}); + +test('rename via the kebab is the title Save and Export use', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openKebab(page, 'Project A'); + await page.locator('#recents-menu .recents-menu-rename').click(); + const input = page.locator('.recents-rename-input'); + await input.fill('Interview Final'); + await input.press('Enter'); + await expect(row(page, 'Interview Final')).toHaveCount(1); + + const downloadPromise = page.waitForEvent('download'); + await page.evaluate(() => document.getElementById('project-export-hyperaudio').click()); + expect((await downloadPromise).suggestedFilename()).toBe('Interview Final.hyperaudio'); +}); + +test('a renamed project keeps its name across a switch (snapshot title rewritten)', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openProject(page, testInfo, 'Project B'); + await openKebab(page, 'Project A'); // rename the NON-current project + await page.locator('#recents-menu .recents-menu-rename').click(); + const input = page.locator('.recents-rename-input'); + await input.fill('Archive Cut'); + await input.press('Enter'); + await expect(row(page, 'Archive Cut')).toHaveCount(1); + + // switch to it, let its autosave run, and the name must survive (the + // stored snapshot's title was rewritten, not just the index) + await row(page, 'Archive Cut').click(); + await expect(activeRow(page)).toHaveText('Archive Cut'); + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.waitForTimeout(2200); // outlive the debounce + await expect(row(page, 'Archive Cut')).toHaveCount(1); + expect(await rowTitles(page)).not.toContain('Project A'); +}); + +test('starred projects pin above with section headings (#440 pattern)', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openProject(page, testInfo, 'Project B'); + + await openKebab(page, 'Project A'); + await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Star'); + await page.locator('#recents-menu .recents-menu-star').click(); + + // starred A pins above B despite B being edited last; the static Recents + // h2 yields to equal-weight "Starred" / "Recents" section headings while + // anything is starred (#440 pattern, kept for #456) + await expect(page.locator('#file-picker .recents-group-heading h2').first()).toHaveText('Starred'); + expect(await rowTitles(page)).toEqual(['Project A', 'Project B']); + await expect(page.locator('#recents-title')).toBeHidden(); + await expect(page.locator('#file-picker .recents-group-heading h2').nth(1)).toHaveText('Recents'); + + // unstar restores the plain list under the static Recents heading + await openKebab(page, 'Project A'); + await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Unstar'); + await page.locator('#recents-menu .recents-menu-star').click(); + await expect(page.locator('#file-picker .recents-group-heading')).toHaveCount(0); + await expect(page.locator('#recents-title')).toBeVisible(); +}); + +test('duplicate makes an independent copy with its own directory', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openKebab(page, 'Project A'); + await page.locator('#recents-menu .recents-menu-duplicate').click(); + await expect(row(page, 'Project A copy')).toHaveCount(1); + + const state = await readLibraryState(page); + expect(state.projects.length).toBe(2); + expect(state.dirs.length).toBe(2); + const copy = state.projects.find((p) => p.name === 'Project A copy'); + expect(copy.id).not.toBe(state.current); // the copy is not the current project + expect(save.isEntryDirty(copy)).toBe(false); // it mirrors its clean (opened) source + + // the copy's saved state carries its own title and the media came along + const copyFiles = await page.evaluate(async (id) => { + const root = await navigator.storage.getDirectory(); + const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id); + const snapshot = JSON.parse(await (await (await dir.getFileHandle('saved.json')).getFile()).text()); + const media = await (await dir.getDirectoryHandle('media')).getFileHandle('tone.wav'); + return { title: JSON.parse(snapshot.json).texts.title, media: media.name }; + }, copy.id); + expect(copyFiles.title).toBe('Project A copy'); + expect(copyFiles.media).toBe('tone.wav'); +}); + +test('delete is a two-step arm inside the menu; a non-current project just goes', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openProject(page, testInfo, 'Project B'); + + await openKebab(page, 'Project A'); + const del = page.locator('#recents-menu .recents-menu-delete'); + await del.click(); + await expect(del).toHaveText(/Delete\?/); // armed, not executed + await expect(row(page, 'Project A')).toHaveCount(1); + await del.click(); + + await expect(row(page, 'Project A')).toHaveCount(0); + await expect(page.locator('#recents-notice')).toHaveCount(0); // no undo offer: it wasn't current + const state = await readLibraryState(page); + expect(state.projects.length).toBe(1); + expect(state.dirs.length).toBe(1); // the directory went with the entry +}); + +test('deleting the CURRENT project keeps it on screen and Restore re-homes it', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + const before = await readLibraryState(page); + + await openKebab(page, 'Project A'); + const del = page.locator('#recents-menu .recents-menu-delete'); + await del.click(); + await del.click(); + + // gone from the library, still on screen, undo offered + await expect(row(page, 'Project A')).toHaveCount(0); + await expect(page.locator('#hypertranscript')).toContainText('Benvenuti'); + await expect(page.locator('#recents-notice')).toContainText('no longer being saved'); + + await page.locator('#recents-notice .recents-notice-action').click(); + await expect(activeRow(page)).toHaveText('Project A'); + const after = await readLibraryState(page); + expect(after.projects.length).toBe(1); + expect(after.current).not.toBe(before.current); // re-homed under a fresh id + expect(after.dirs).toEqual([after.current]); + + // and the re-homed project autosaves again + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.textContent = 'RESTORED '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await pollPage(page, async (id) => { + try { + const root = await navigator.storage.getDirectory(); + const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id); + const text = await (await (await dir.getFileHandle('draft.json')).getFile()).text(); + return text.indexOf('RESTORED') !== -1; + } catch (e) { return false; } + }, after.current); +}); + +test('boot restores the most recently EDITED project, not the last opened', async ({ page }, testInfo) => { + await openProject(page, testInfo, 'Project A'); + await openProject(page, testInfo, 'Project B'); + + // go back to A and edit it — A becomes the most recently edited + await row(page, 'Project A').click(); + await expect(activeRow(page)).toHaveText('Project A'); + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.textContent = 'LAST-EDIT '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + // Wait for the INDEX to carry the edit (snapshot lands first, then the + // entry) — boot orders by the index, so that's the durable signal. + const idA = await page.evaluate(() => window.HyperaudioSave.library.currentId()); + await pollPage(page, async (id) => { + const root = await navigator.storage.getDirectory(); + const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id); + let text = null; + try { text = await (await (await dir.getFileHandle('draft.json')).getFile()).text(); } + catch (e) { return false; } + if (text.indexOf('LAST-EDIT') === -1) return false; + const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text()); + const edited = lib.projects.find((p) => p.id === id); + return lib.projects.every((p) => p.id === id || (p.modifiedAt || 0) < (edited.modifiedAt || 0)); + }, idA); + + await page.reload(); + await page.waitForSelector('#hypertranscript [data-m]'); + await expect(page.locator('#hypertranscript')).toContainText('LAST-EDIT'); + await expect(activeRow(page)).toHaveText('Project A'); +}); diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index 6e7d378d..6b749ae2 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -6,7 +6,7 @@ import { test, expect } from '@playwright/test'; import { createRequire } from 'node:module'; import fs from 'node:fs'; -import { ladderWav } from './helpers.mjs'; +import { ladderWav, pollPage } from './helpers.mjs'; const require = createRequire(import.meta.url); const save = require('../../js/hyperaudio-save.js'); @@ -75,6 +75,35 @@ const awaitModal = (page) => page.waitForFunction(() => { return el !== null && el.classList.contains('modal-open'); }); +// The library index (#456) replaced the localStorage boot hint: "the working +// copy landed" now means the current project has an entry in library.json. +const awaitLibraryEntry = (page) => pollPage(page, async () => { + try { + const root = await navigator.storage.getDirectory(); + const text = await (await (await root.getFileHandle('library.json')).getFile()).text(); + return JSON.parse(text).projects.length > 0 + && window.HyperaudioSave.library.currentId() !== null; + } catch (e) { + return false; + } +}); + +// The current project's index entry and per-project working state — the +// draft (unsaved edits) when one exists, else the saved state (#456). +const readCurrentProject = (page) => page.evaluate(async () => { + const id = window.HyperaudioSave.library.currentId(); + const root = await navigator.storage.getDirectory(); + const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text()); + const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id); + const readState = async (name) => { + try { return JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text()); } + catch (e) { return null; } + }; + const draft = await readState('draft.json'); + const saved = await readState('saved.json'); + return { id, entry: lib.projects.find((p) => p.id === id), snapshot: draft || saved, draft, saved }; +}); + test.beforeEach(async ({ page }) => { await page.goto('/index.html'); await page.waitForSelector('#hypertranscript [data-m]'); @@ -89,6 +118,7 @@ test('save button, import menu item, and hidden input are injected', async ({ pa }); expect(order).toBe('export-media-btn'); await expect(page.locator('#file-exportimport-submenu #project-open-hyperaudio')).toHaveText('Import Project (.hyperaudio)'); + await expect(page.locator('#file-exportimport-submenu #project-export-hyperaudio')).toHaveText('Export Project (.hyperaudio)'); await expect(page.locator('#project-open-input')).toHaveCount(1); }); @@ -119,12 +149,12 @@ test('opening a .hyperaudio lands transcript, redaction, captions, options and t expect(dialogs).toEqual([]); // a conformant file opens without any alert }); -test('saving downloads a conformant container that round-trips', async ({ page }, testInfo) => { +test('Export Project downloads a conformant container that round-trips (#456)', async ({ page }, testInfo) => { const dialogs = []; await openFixture(page, testInfo, dialogs); const downloadPromise = page.waitForEvent('download'); - await page.evaluate(() => document.getElementById('project-save-btn').click()); + await page.evaluate(() => document.getElementById('project-export-hyperaudio').click()); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('E2E Project.hyperaudio'); @@ -143,6 +173,10 @@ test('saving downloads a conformant container that round-trips', async ({ page } expect(loaded.mediaData.length).toBeGreaterThan(1000); // the redaction survived the full editor round-trip expect(loaded.project.transcript.words.some((w) => w.text === 'ehm' && w.struck === true)).toBe(true); + // the speaker survived it too — as a paragraph name, never as a fake word + // (the gather-side class strip used to demote "[Maria]" to a word, #456) + expect(loaded.project.transcript.paragraphs[0].speaker).toBe('Maria'); + expect(loaded.project.transcript.words.some((w) => w.text.includes('[Maria]'))).toBe(false); // the origin travelled along, untouched and struck-free expect(JSON.parse(loaded.originalText).words[0].text).toBe('benvenuti'); expect(loaded.captionsVtt).toContain('Benvenuti a Hyperaudio'); @@ -153,8 +187,8 @@ test('the working copy survives a reload (OPFS restore)', async ({ page }, testI const dialogs = []; await openFixture(page, testInfo, dialogs); - // the open seeds OPFS and sets the synchronous boot hint - await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === '1'); + // the open seeds a project dir and its library entry (#456) + await awaitLibraryEntry(page); await page.reload(); await page.waitForSelector('#hypertranscript [data-m]'); @@ -162,51 +196,80 @@ test('the working copy survives a reload (OPFS restore)', async ({ page }, testI // the restored project replaces the static demo transcript await expect(page.locator('#hypertranscript')).toContainText('Benvenuti'); await expect(page.locator('#hypertranscript span[data-m="840"]')).toHaveCSS('text-decoration-line', 'line-through'); + // the speaker label restores WITH its class (styling + Speakers toggle) + await expect(page.locator('#hypertranscript .speaker')).toHaveText('[Maria] '); await expect(page.locator('#remove-gaps-threshold')).toHaveValue('700'); const src = await page.evaluate(() => document.querySelector('#hyperplayer').src); expect(src).toMatch(/^blob:/); // the project title survived the restore in the session (no UI field until - // #449): a save after reload still suggests the title-derived filename + // #449): an export after reload still suggests the title-derived filename const downloadPromise = page.waitForEvent('download'); - await page.evaluate(() => document.getElementById('project-save-btn').click()); + await page.evaluate(() => document.getElementById('project-export-hyperaudio').click()); expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); }); -test('dirty open: danger triad styling, and "Save and open" saves then opens (#449)', async ({ page }, testInfo) => { +test('opening while dirty asks nothing: the pending edit flushes to its own project (#456)', async ({ page }, testInfo) => { const dialogs = []; await openFixture(page, testInfo, dialogs); + await awaitLibraryEntry(page); + const first = await readCurrentProject(page); await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); span.textContent = 'DIRTY '; span.dispatchEvent(new Event('input', { bubbles: true })); }); - // open the fixture again over the dirty project + // re-open the fixture over the dirty project — the discard dialog is gone: + // the outgoing project keeps its edits in its own directory and the open + // simply makes a second library entry await page.evaluate(() => { document.getElementById('project-open-input').value = ''; }); const fixturePath = testInfo.outputPath('fixture.hyperaudio'); await page.setInputFiles('#project-open-input', fixturePath); - await awaitModal(page); - expect(await projectModal(page)).toContain('DISCARD'); - expect(await page.evaluate(() => ({ - danger: document.getElementById('project-dialog-confirm').classList.contains('btn-error'), - saveLabel: document.getElementById('project-dialog-extra').textContent, - focused: document.activeElement && document.activeElement.id, - cancelHidden: document.getElementById('project-dialog-cancel').style.display === 'none', - }))).toEqual({ danger: true, saveLabel: 'Save and open', focused: 'project-dialog-extra', cancelHidden: true }); + await expect(page.locator('#hypertranscript')).not.toContainText('DIRTY'); + expect(await projectModal(page)).toBeNull(); // switching asks nothing + expect(dialogs).toEqual([]); - const downloadPromise = page.waitForEvent('download'); - await page.click('#project-dialog-extra'); - expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); // saved… - await expect(page.locator('#hypertranscript')).toContainText('Benvenuti'); // …then opened - await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); + await pollPage(page, async (firstId) => { + try { + const root = await navigator.storage.getDirectory(); + const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text()); + if (lib.projects.length !== 2) return false; + const work = await root.getDirectoryHandle('work'); + // the outgoing project's pending edit flushed to ITS OWN DRAFT… + await (await work.getDirectoryHandle(firstId)).getFileHandle('draft.json'); + // …and the opened project seeded its saved state + const current = window.HyperaudioSave.library.currentId(); + await (await work.getDirectoryHandle(current)).getFileHandle('saved.json'); + return true; + } catch (e) { + return false; + } + }, first.id); + const state = await page.evaluate(async (firstId) => { + const root = await navigator.storage.getDirectory(); + const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text()); + const work = await root.getDirectoryHandle('work'); + const dir = await work.getDirectoryHandle(firstId); + const draft = JSON.parse(await (await (await dir.getFileHandle('draft.json')).getFile()).text()); + return { + count: lib.projects.length, + current: window.HyperaudioSave.library.currentId(), + firstHtml: draft.html, + firstEntry: lib.projects.find((p) => p.id === firstId), + }; + }, first.id); + expect(state.count).toBe(2); // re-opening made a second entry + expect(state.current).not.toBe(first.id); // …which now owns the editor + expect(state.firstHtml).toContain('DIRTY'); // nothing was lost + expect(save.isEntryDirty(state.firstEntry)).toBe(true); // and it stays honestly dirty }); -test('an unopenable file is refused BEFORE the replace-confirmation, project untouched', async ({ page }, testInfo) => { +test('an unopenable file is refused with the designed modal, project untouched', async ({ page }, testInfo) => { const dialogs = []; await openFixture(page, testInfo, dialogs); - // dirty the project so the replace-warning WOULD apply to a valid open + // dirty the project so an accidental switch/replace would be observable await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); span.textContent = 'EDITED '; span.dispatchEvent(new Event('input', { bubbles: true })); }); @@ -244,7 +307,7 @@ test('an unopenable file is refused BEFORE the replace-confirmation, project unt test('edit tracking survives the caption-mode round trip (#448 delegation)', async ({ page }, testInfo) => { const dialogs = []; await openFixture(page, testInfo, dialogs); - await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === '1'); + await awaitLibraryEntry(page); // the round trip that REPLACES #hypertranscript — direct listeners died here await page.click('#caption-editor-btn'); @@ -252,56 +315,62 @@ test('edit tracking survives the caption-mode round trip (#448 delegation)', asy await page.click('#transcript-editor-btn'); await page.waitForTimeout(400); - const before = await page.evaluate(async () => { - const root = await navigator.storage.getDirectory(); - const f = await (await root.getFileHandle('app-state.json')).getFile(); - return JSON.parse(await f.text()).lastWorkWriteAt || 0; - }); + const before = (await readCurrentProject(page)).entry.lastDraftAt || 0; // an edit on the REPLACED transcript element must still reach the autosave await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); span.textContent = 'POST-ROUNDTRIP '; span.dispatchEvent(new Event('input', { bubbles: true })); }); await page.waitForTimeout(2500); - const after = await page.evaluate(async () => { - const root = await navigator.storage.getDirectory(); - const dir = await root.getDirectoryHandle('work'); - const state = JSON.parse(await (await (await root.getFileHandle('app-state.json')).getFile()).text()); - const snapshot = JSON.parse(await (await (await dir.getFileHandle('snapshot.json')).getFile()).text()); - return { at: state.lastWorkWriteAt || 0, html: snapshot.html }; - }); - expect(after.at).toBeGreaterThan(before); - expect(after.html).toContain('POST-ROUNDTRIP'); + const after = await readCurrentProject(page); + expect(after.entry.lastDraftAt).toBeGreaterThan(before); + expect(after.draft.html).toContain('POST-ROUNDTRIP'); }); -test('Save button: dirty dot appears on edit, click saves and clears it (#449)', async ({ page }, testInfo) => { +test('Save is a SILENT OPFS commit: dot clears, saved.json lands, the draft retires (#456)', async ({ page }, testInfo) => { const dialogs = []; await openFixture(page, testInfo, dialogs); + await awaitLibraryEntry(page); await expect(page.locator('#project-save-btn')).toHaveCount(1); await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); - span.textContent = 'DIRTY '; + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.textContent = 'COMMITTED '; span.dispatchEvent(new Event('input', { bubbles: true })); }); await expect(page.locator('#project-save-btn')).toHaveClass(/dirty/); - const downloadPromise = page.waitForEvent('download'); + // no download listener here on purpose: a Save must not download anything + let downloaded = false; + page.on('download', () => { downloaded = true; }); await page.click('#project-save-btn'); - expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); + + const state = await readCurrentProject(page); + expect(state.saved.html).toContain('COMMITTED'); // the commit holds the edit + expect(state.draft).toBeNull(); // the draft died with the save + expect(save.isEntryDirty(state.entry)).toBe(false); + expect(downloaded).toBe(false); + expect(dialogs).toEqual([]); }); -test('Ctrl/⌘-S saves with the project title (#449)', async ({ page }, testInfo) => { +test('Ctrl/⌘-S is the same silent save (#449/#456)', async ({ page }, testInfo) => { const dialogs = []; await openFixture(page, testInfo, dialogs); - const downloadPromise = page.waitForEvent('download'); + await awaitLibraryEntry(page); + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.textContent = 'KEYBOARD '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await expect(page.locator('#project-save-btn')).toHaveClass(/dirty/); await page.keyboard.press('Control+s'); - expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); + await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); + expect((await readCurrentProject(page)).saved.html).toContain('KEYBOARD'); }); test('the native bridge intercepts the save instead of a download (#449)', async ({ page }, testInfo) => { @@ -312,7 +381,7 @@ test('the native bridge intercepts the save instead of a download (#449)', async window.hyperaudioProjectBridge = { save(blob, name) { window.__bridgeSaved = { size: blob.size, name }; return true; }, }; - const span = document.querySelector('#hypertranscript span[data-m]'); + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); span.textContent = 'BRIDGED '; span.dispatchEvent(new Event('input', { bubbles: true })); }); @@ -324,70 +393,150 @@ test('the native bridge intercepts the save instead of a download (#449)', async await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); // bridge save marks clean }); -test('the quit guard arms on unsaved changes and disarms after a save (#449)', async ({ page }, testInfo) => { - // Tests the guard's arming logic via a cancelable synthetic event — - // defaultPrevented is precisely what the browser reads to decide whether - // to prompt. The prompt itself is platform chrome (and headless Chromium's - // dialog plumbing for real closes is unreliable); manual testing covers it. +test('closing loses nothing: unsaved edits ride the draft across a reload, still dirty (#456)', async ({ page }, testInfo) => { const dialogs = []; await openFixture(page, testInfo, dialogs); + await awaitLibraryEntry(page); const armed = () => page.evaluate(() => { const e = new Event('beforeunload', { cancelable: true }); window.dispatchEvent(e); return e.defaultPrevented; }); - expect(await armed()).toBe(false); // freshly opened: clean - await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); span.textContent = 'UNSAVED '; span.dispatchEvent(new Event('input', { bubbles: true })); }); - expect(await armed()).toBe(true); // dirty: leaving would prompt + // dirty, but the draft persists — so the quit guard must NOT nag (#456: + // it arms only for a deleted-but-on-screen document with no home) + expect(await armed()).toBe(false); + + // let the draft land, then reload: the edit survives WITH its dirty state + await pollPage(page, async () => { + const id = window.HyperaudioSave.library.currentId(); + try { + const root = await navigator.storage.getDirectory(); + const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id); + const text = await (await (await dir.getFileHandle('draft.json')).getFile()).text(); + return text.indexOf('UNSAVED') !== -1; + } catch (e) { return false; } + }); + await page.reload(); + await page.waitForSelector('#hypertranscript [data-m]'); + await expect(page.locator('#hypertranscript')).toContainText('UNSAVED'); + await expect(page.locator('#project-save-btn')).toHaveClass(/dirty/); - const downloadPromise = page.waitForEvent('download'); + // a Save commits it: clean across the NEXT reload too, from saved.json await page.click('#project-save-btn'); - await downloadPromise; - expect(await armed()).toBe(false); // saved: leaving is silent again + await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); + await page.reload(); + await page.waitForSelector('#hypertranscript [data-m]'); + await expect(page.locator('#hypertranscript')).toContainText('UNSAVED'); + await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); }); -test('a second tab is guarded: banner, no slot writes, promotion on owner close (#450)', async ({ page, context }, testInfo) => { +test('a second tab on the SAME project is guarded: banner, no writes, promotion on owner close (#450/#456)', async ({ page, context }, testInfo) => { const dialogs = []; - await openFixture(page, testInfo, dialogs); // tab 1 owns the slot - await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === '1'); - const ownerSnapshot = await page.evaluate(async () => { + await openFixture(page, testInfo, dialogs); // tab 1 owns the project + await awaitLibraryEntry(page); + const owner = await readCurrentProject(page); + // the guarded tab's edits must never write the owner's directory: no + // draft.json may appear there, and saved.json must stay byte-identical + const readOwnerState = () => page.evaluate(async (id) => { const root = await navigator.storage.getDirectory(); - const dir = await root.getDirectoryHandle('work'); - return (await (await dir.getFileHandle('snapshot.json')).getFile()).text(); + const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id); + const saved = await (await (await dir.getFileHandle('saved.json')).getFile()).text(); + let hasDraft = true; + try { await dir.getFileHandle('draft.json'); } catch (e) { hasDraft = false; } + return { saved, hasDraft }; + }, owner.id); + const ownerState = await readOwnerState(); + expect(ownerState.hasDraft).toBe(false); + + // tab 2 boots onto the same most-recent project: on screen and editable, + // but bannered — its edits must NOT reach the owner's working copy + const page2 = await context.newPage(); + await page2.goto('/index.html'); + await page2.waitForSelector('#hypertranscript [data-m]'); + await expect(page2.locator('#tab-guard-banner')).toBeVisible(); + await expect(page2.locator('#hypertranscript')).toContainText('Benvenuti'); + + await page2.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.textContent = 'TAB-TWO '; + span.dispatchEvent(new Event('input', { bubbles: true })); }); + await page2.waitForTimeout(2200); // outlive the autosave debounce + const untouched = await readOwnerState(); + expect(untouched.hasDraft).toBe(false); // tab 2's edit never reached the working copy + expect(untouched.saved).toBe(ownerState.saved); + + // owner closes → tab 2 is promoted: banner drops, its autosave now lands + await page.close(); + await expect(page2.locator('#tab-guard-banner')).toHaveCount(0); + await page2.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await pollPage(page2, async (id) => { + try { + const root = await navigator.storage.getDirectory(); + const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id); + const text = await (await (await dir.getFileHandle('draft.json')).getFile()).text(); + return text.indexOf('TAB-TWO') !== -1; + } catch (e) { return false; } + }, owner.id); + await page2.close(); +}); + +test('two tabs edit two DIFFERENT projects, each owning its own working copy (#456)', async ({ page, context }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); // tab 1: project one + await awaitLibraryEntry(page); + const owner = await readCurrentProject(page); - // tab 2: banner shown, and its transcription must NOT touch the owner's slot const page2 = await context.newPage(); await page2.goto('/index.html'); await page2.waitForSelector('#hypertranscript [data-m]'); - await expect(page2.locator('#tab-guard-banner')).toBeVisible(); - // tab 2 did not boot-restore the owner's project — it shows the demo - await expect(page2.locator('#hypertranscript')).not.toContainText('Benvenuti'); + await expect(page2.locator('#tab-guard-banner')).toBeVisible(); // same project at boot + // a new transcription in tab 2 becomes its OWN project: banner drops await page2.evaluate(() => { document.querySelector('#hyperplayer').src = 'https://example.com/media/tab2.mp4'; document.querySelector('#hypertranscript').innerHTML = '

TAB-TWO

'; document.dispatchEvent(new CustomEvent('hyperaudioInit')); - const span = document.querySelector('#hypertranscript span[data-m]'); + }); + await expect(page2.locator('#tab-guard-banner')).toHaveCount(0); + await page2.waitForFunction((ownerId) => { + const id = window.HyperaudioSave.library.currentId(); + return id !== null && id !== ownerId; + }, owner.id); + + // both tabs write their own directories; the shared index lists both + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)'); + span.textContent = 'TAB-ONE '; span.dispatchEvent(new Event('input', { bubbles: true })); }); - await page2.waitForTimeout(2200); // outlive the autosave debounce - const afterSnapshot = await page.evaluate(async () => { + await page.waitForTimeout(2200); + const state = await page2.evaluate(async () => { const root = await navigator.storage.getDirectory(); - const dir = await root.getDirectoryHandle('work'); - return (await (await dir.getFileHandle('snapshot.json')).getFile()).text(); + const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text()); + const work = await root.getDirectoryHandle('work'); + const html = {}; + for (const p of lib.projects) { + const dir = await work.getDirectoryHandle(p.id); + let text = null; + try { text = await (await (await dir.getFileHandle('draft.json')).getFile()).text(); } + catch (e) { text = await (await (await dir.getFileHandle('saved.json')).getFile()).text(); } + html[p.id] = JSON.parse(text).html; + } + return { count: lib.projects.length, current: window.HyperaudioSave.library.currentId(), html }; }); - expect(afterSnapshot).toBe(ownerSnapshot); // untouched by tab 2 - - // owner closes → tab 2 is promoted: banner drops - await page.close(); - await expect(page2.locator('#tab-guard-banner')).toHaveCount(0); + expect(state.count).toBe(2); + expect(state.html[owner.id]).toContain('TAB-ONE'); + expect(state.html[state.current]).toContain('TAB-TWO'); await page2.close(); }); diff --git a/__TEST__/unit/hyperaudio-save.test.mjs b/__TEST__/unit/hyperaudio-save.test.mjs index 74511544..710852d1 100644 --- a/__TEST__/unit/hyperaudio-save.test.mjs +++ b/__TEST__/unit/hyperaudio-save.test.mjs @@ -342,3 +342,49 @@ test('the writer sanitizes hostile media entry names with the shared rule (§ 10 assert.ok(zip.file('media/.._evil.wav') !== null); // separator neutralized, ".." substring kept assert.equal(zip.file('media/../evil.wav'), null); }); + +/* ---- Library index rules (#456) — pure layer of the project library ---- */ + +test('library entries sort by last edit, created date the fallback (#456)', () => { + const sorted = save.sortLibraryEntries([ + { id: 'a', modifiedAt: 100 }, + { id: 'b', modifiedAt: 300 }, + { id: 'c', createdAt: 200 }, // never written: created decides + { id: 'd', modifiedAt: 0, createdAt: 400 }, // modifiedAt 0 falls back too + ]); + assert.deepEqual(sorted.map((e) => e.id), ['d', 'b', 'c', 'a']); +}); + +test('sortLibraryEntries does not mutate its input', () => { + const entries = [{ id: 'a', modifiedAt: 1 }, { id: 'b', modifiedAt: 2 }]; + save.sortLibraryEntries(entries); + assert.deepEqual(entries.map((e) => e.id), ['a', 'b']); +}); + +test('per-project dirty: a draft newer than the last manual Save (#456)', () => { + assert.equal(save.isEntryDirty({ lastDraftAt: 2, lastSavedAt: 1 }), true); + assert.equal(save.isEntryDirty({ lastDraftAt: 1, lastSavedAt: 1 }), false); + assert.equal(save.isEntryDirty({ lastDraftAt: 0, lastSavedAt: 2 }), false); // freshly saved + assert.equal(save.isEntryDirty({ lastDraftAt: 5 }), true); // never saved (fresh transcription) + assert.equal(save.isEntryDirty({}), false); // nothing written yet +}); + +test('project ids are unique and safe as OPFS directory names (#456)', () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) ids.add(save.newProjectId()); + assert.equal(ids.size, 100); + for (const id of ids) assert.match(id, /^[A-Za-z0-9-]+$/); +}); + +test('gather-side class sanitizer keeps the speaker class, strips pollution (#456)', () => { + const html = '

[Maria] ' + + 'Benvenuti ' + + 'a

'; + const out = save.sanitizeTranscriptClasses(html); + assert.ok(out.includes('class="speaker"')); // semantic class survives… + assert.ok(!out.includes('active')); // …playback classes go + assert.ok(!out.includes('speaker-adjacent')); // substring must not fake a match + // a polluted speaker span ("speaker read") collapses to exactly class="speaker" + const mixed = save.sanitizeTranscriptClasses('[A] '); + assert.equal(mixed, '[A] '); +}); diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index 225d0bca..4044edde 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -578,14 +578,6 @@ dialog::backdrop { transform: none; } -/* Info button sits at the right edge of the side panel, so right-anchor its - tooltip bubble so it opens inward instead of being clipped by the panel. */ -#info-btn.tooltip::before { - left: auto; - right: 0; - transform: none; -} - /* Search: highlight only the matched substring. searchPhrase wraps the match in and also tags the word .search-match — neutralise the vendored whole-word background so just the mark shows, tinted with a much @@ -678,6 +670,31 @@ body.find-replace-open .hyperaudio-transcript { padding-top: 108px; } UI polish (#375) ========================================================================= */ +/* Recents: the heading lives INSIDE the white card (so the card top can align + with the transcript card when the video is collapsed), and the card matches + the video/transcript corner treatment. */ +#recents-card { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + margin-top: 12px; + background: #ffffff; + border-radius: 0.5rem; +} +#recents-title { + flex-shrink: 0; + font-weight: 700; + font-size: 1.05rem; + padding: 12px 16px 4px; + margin: 0; +} +#recents-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + border-radius: 0 0 0.5rem 0.5rem; +} /* The caption editor's floating Regenerate button is injected as fixed top-20 right-8 with no z-index, so positioned content (the caption @@ -703,7 +720,11 @@ body.find-replace-open .hyperaudio-transcript { padding-top: 108px; } } #player-controls { transition: margin-top 0.5s ease; - }} + } + #recents-card { + transition: margin-top 0.5s ease; + } +} /* Player controls row (formerly inline styles in index.html, moved here so state rules below can override without !important). */ @@ -833,7 +854,11 @@ label[data-a11y-wired]:focus-visible { @media screen and (min-width: 949px) { body.video-collapsed #player-controls { margin-top: 11px; - }} + } + body.video-collapsed #recents-card { + margin-top: 27px; + } +} /* Transcript/captions view switch: a recessed track with a raised thumb that slides under the active segment, so the current view reads as a position in @@ -921,11 +946,290 @@ label[data-a11y-wired]:focus-visible { } } +/* 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; + /* the daisyUI menu wraps with flex-shrink:0 items, so a long name sizes the + row to its content and pushes the action icons off-screen — clamp the row + to the list and let the name's ellipsis absorb the difference */ + max-width: 100%; +} +/* breathing room between consecutive rows (headings carry their own) */ +#file-picker .recents-row + .recents-row { + margin-top: 4px; +} +#file-picker .recents-row .file-item { + display: block; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* The actions stretch to the row's full height so the kebab's hover pill + matches the name pill beside it, with a small gap between the two (#456 + visual pass) — a shorter, flush square read as a mismatched afterthought. */ +#file-picker .recents-actions { + display: flex; + align-items: center; + align-self: stretch; + gap: 2px; + margin-left: 4px; + opacity: 0; +} +/* daisyUI styles every direct child of a menu li as a menu item — strip that + from the actions span so hovering the kebab shows ONE affordance (the + button's own), not a pill around a pill */ +#file-picker .recents-actions, +#file-picker .recents-actions:hover, +#file-picker .recents-actions:active { + background: none; + padding: 0; +} +#file-picker .recents-row:hover .recents-actions, +#file-picker .recents-row:focus-within .recents-actions { + opacity: 1; +} +/* Hovering anywhere in the row — the kebab included — lights the name pill + too, so the row reads as one unit while its actions are in use. Same fill + as daisyUI's own menu-item hover; held while the row's menu is open; the + active row keeps its stronger state. */ +#file-picker .recents-row:hover .file-item:not(.active), +#file-picker .recents-row:focus-within .file-item:not(.active), +#file-picker .recents-row:has(.recents-kebab[aria-expanded="true"]) .file-item:not(.active) { + background-color: oklch(var(--bc) / 0.1); +} +/* neutralise the global button chrome (border + grey fill) for the tiny + in-row actions; they read as quiet icons until hovered. Radius matches the + row pill (--rounded-btn, like daisyUI's menu items) since they now share a + height. */ +#file-picker .recents-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + /* an explicit square at the menu-row height (0.5rem padding + 1.25rem line + = 2.25rem): matches the app's btn-square icon-button language and centres + the glyph optically. NOT aspect-ratio:1 — that resolves width after flex + sizing, so the actions span sized to the bare icon and the square + overflowed the row off the card edge. */ + width: 2.25rem; + height: 2.25rem; + border: none; + background: transparent; + margin: 0; + padding: 0; + border-radius: var(--rounded-btn, 0.5rem); + 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)); +} +.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; +} + +/* Row kebab menu (#436): one shared, fixed-position menu so it never clips + against the Recents scroll container. */ +#file-picker .recents-row:has(.recents-kebab[aria-expanded="true"]) .recents-actions { + opacity: 1; /* keep the anchor visible while its menu is open */ +} +/* Info modal (#456): one consistent layout — the project name is the only + large heading; every section sits under the same small muted label, and + all data rows share one size. The label:value rows match the shape + editor-core's setTranscriptionInfo writes, so live engine reports and the + stored per-project rebuild render identically. */ +#info-modal + .modal #project-info-name { + padding-right: 32px; /* clear of the ✕ */ + overflow-wrap: anywhere; +} +#info-modal + .modal .info-section { margin-top: 14px; } +#info-modal + .modal .info-section-label { + margin: 0 0 4px; + font-weight: 600; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: oklch(var(--bc) / 0.55); +} +#info-modal + .modal .info-rows p, +#info-modal + .modal #summary, +#info-modal + .modal #topics { + margin: 2px 0; + font-size: 0.9rem; + overflow-wrap: anywhere; +} +/* Row hover popout (#456): full name + stored summary/topics, floated to the + RIGHT of the panel so it never covers the row or its kebab. Fixed to escape + the scroll clip; pointer-transparent — purely informational. */ +#recents-popout { + position: fixed; + z-index: 50; + max-width: 300px; + padding: 10px 14px; + border-radius: 0.5rem; + background-color: oklch(var(--b1)); + box-shadow: 0 4px 16px oklch(var(--bc) / 0.15), 0 0 0 1px oklch(var(--bc) / 0.06); + pointer-events: none; + font-size: 13px; + line-height: 1.45; +} +#recents-popout p { margin: 0; } +#recents-popout p + p { margin-top: 6px; } +#recents-popout .recents-popout-name { + font-weight: 600; + overflow-wrap: anywhere; /* full filenames without spaces must wrap, not clip */ +} +#recents-popout .recents-popout-topics { + opacity: 0.7; + font-size: 12px; +} +#recents-menu { + position: fixed; + z-index: 50; + display: flex; + flex-direction: column; + min-width: 150px; + padding: 4px; + border-radius: 0.5rem; + background-color: oklch(var(--b1)); + box-shadow: 0 4px 16px oklch(var(--bc) / 0.15), 0 0 0 1px oklch(var(--bc) / 0.06); +} +#recents-menu button { + display: flex; + align-items: center; + gap: 8px; + border: none; + background: transparent; + margin: 0; + padding: 8px 16px; + border-radius: 0.375rem; + font-size: 14px; /* match the FILE dropdown's item size */ + text-align: left; + color: oklch(var(--bc) / 0.85); + cursor: pointer; +} +#recents-menu button:hover, +#recents-menu button:focus-visible { + border: none; + background-color: oklch(var(--b2)); + color: oklch(var(--bc)); +} +#recents-menu button.confirming, +#recents-menu button.confirming:hover { + color: oklch(var(--er)); + font-weight: 600; +} + +/* Starred/Recents section headings (#440, kept for #456) — same weight as + the panel's static "Recents" h2, which hides while these are rendered + (nothing starred = static h2 only, exactly the default look). The picker's + own 16px inset aligns them with where the static h2 sits. */ +#file-picker .recents-group-heading { + padding: 12px 0 4px; + pointer-events: none; +} +#file-picker .recents-group-heading h2 { + /* daisyUI styles any direct child of a menu li as a menu item — zero that + out so the heading sits at the same 16px inset as the static h2 */ + margin: 0; + padding: 0; + background: none; + font-weight: 700; + font-size: 1.05rem; +} /* The export adjust panel's number inputs (speed, target minutes/seconds) drop the browser spinner buttons — the slider covers coarse speed changes diff --git a/index.html b/index.html index 189070df..57a012be 100644 --- a/index.html +++ b/index.html @@ -108,7 +108,7 @@ } - + @@ -356,7 +356,9 @@ - - +
+ +
+

Recents

+
+ +
+
@@ -442,15 +453,31 @@ @@ -1026,7 +1053,8 @@

Caption Regeneration

- + + diff --git a/js/hyperaudio-library.js b/js/hyperaudio-library.js new file mode 100644 index 00000000..5144bb7f --- /dev/null +++ b/js/hyperaudio-library.js @@ -0,0 +1,452 @@ +/* + * ============================================================================ + * PROJECT LIBRARY PANEL (#456) — the side panel over the OPFS library + * ============================================================================ + * + * The management UX of the former Recents (#434/#435/#440), resurrected from + * its pre-#451 history and rewired: rows list the library index that + * hyperaudio-save.js maintains (HyperaudioSave.library), identity is the + * generated project id, and every action is one call into that API. Starred + * entries pin above the rest; rows order by last edit; the current project + * carries the active highlight; the kebab menu does star/rename/duplicate/ + * delete with the armed two-step delete. Re-renders ride the + * 'hyperaudioLibraryChanged' document event (fired locally and relayed from + * other tabs over a BroadcastChannel), so the panel is always the index's + * truth — including a second tab's. + * + * The recents-* ids/classes are kept so the pre-#451 CSS applies verbatim + * and the mobile drawer (responsive.js) keeps working untouched. + */ + +(function () { + 'use strict'; + + function escapeMarkup(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + const RENAME_SVG = ''; + const DUPLICATE_SVG = ''; + const KEBAB_SVG = ''; + const STAR_SVG = ''; + const DELETE_SVG = ''; + const INFO_SVG = ''; + + const lib = () => window.HyperaudioSave && window.HyperaudioSave.library; + + // seconds → "M:SS" / "H:MM:SS" for the info modal's Duration row + function formatDuration(seconds) { + const total = Math.round(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = (n) => String(n).padStart(2, '0'); + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; + } + + /* ---- Notices above the list (relocated from the legacy module): the + delete-undo offer, and any future library problem — replaces alert() ---- */ + + let noticeTimer = null; + function showPanelNotice(message, opts) { + opts = 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(noticeTimer); + if (opts.sticky !== true) { + noticeTimer = setTimeout(() => { el.remove(); }, 8000); + } + } + + // A pending Restore offers to re-home the ON-SCREEN document; once a + // project owns the screen again (switch, open, new transcription) that + // offer would save the wrong content — withdraw it. Only notices carrying + // an action are removed. + function hideRestoreNotice() { + const el = document.getElementById('recents-notice'); + if (el !== null && el.dataset.hasAction === 'true') el.remove(); + } + + /* ---- Row hover popout: full name + stored summary/topics, floated to the + RIGHT of the panel so it never covers the row or its kebab. Fixed + position to escape the panel's scroll clip (same reasoning as the kebab + menu below); pointer-events:none in CSS — purely informational. Skipped + in the small-screen drawer, where there is no useful hover and no room + beside the panel. ---- */ + + const drawerQuery = window.matchMedia('(max-width: 948px)'); + let popoutEl = null; + let popoutTimer = null; + + function hidePopout() { + clearTimeout(popoutTimer); + popoutTimer = null; + if (popoutEl !== null) { + popoutEl.remove(); + popoutEl = null; + } + } + + function showPopout(rowEl, entry) { + hidePopout(); + const pane = document.getElementById('recents-pane'); + if (pane === null || !entry) return; + popoutEl = document.createElement('div'); + popoutEl.id = 'recents-popout'; + popoutEl.setAttribute('aria-hidden', 'true'); // hover-only duplicate of kebab→Info + const name = document.createElement('p'); + name.className = 'recents-popout-name'; + name.textContent = entry.name || 'project'; + popoutEl.appendChild(name); + if (entry.summary && entry.summary.trim() !== '') { + const summary = document.createElement('p'); + summary.textContent = entry.summary; + popoutEl.appendChild(summary); + } + if ((entry.topics || []).length > 0) { + const topics = document.createElement('p'); + topics.className = 'recents-popout-topics'; + topics.textContent = 'Topics: ' + entry.topics.join(', '); + popoutEl.appendChild(topics); + } + document.body.appendChild(popoutEl); + const paneRect = pane.getBoundingClientRect(); + const rowRect = rowEl.getBoundingClientRect(); + popoutEl.style.left = Math.round(paneRect.right + 8) + 'px'; + const size = popoutEl.getBoundingClientRect(); + popoutEl.style.top = Math.round(Math.max(8, + Math.min(rowRect.top, window.innerHeight - size.height - 8))) + 'px'; + } + + /* ---- Row kebab menu: one shared, fixed-position menu (#436). The list + lives in a scroll container, so a dropdown positioned inside it would be + clipped at the card edge for rows near the bottom — a fixed menu anchored + to the kebab's rect behaves for every row, flipping upward near the + viewport bottom. Closed by outside click, Escape, any scroll (the anchor + moves), or a list re-render. ---- */ + + let menuProjectId = null; // project id the open menu acts on, null when closed + + function closeMenu() { + const menu = document.getElementById('recents-menu'); + if (menu !== null) menu.remove(); + const kebab = document.querySelector('.recents-kebab[aria-expanded="true"]'); + if (kebab !== null) kebab.setAttribute('aria-expanded', 'false'); + menuProjectId = null; + } + + function openMenu(kebabBtn, entry) { + closeMenu(); + hidePopout(); // one floating element at a time + menuProjectId = entry.id; + kebabBtn.setAttribute('aria-expanded', 'true'); + + const isStarred = entry.starred === true; + const menu = document.createElement('div'); + menu.id = 'recents-menu'; + menu.setAttribute('role', 'menu'); + menu.innerHTML = + `` + + `` + + `` + + `` + + ``; + document.body.appendChild(menu); + + const anchor = kebabBtn.getBoundingClientRect(); + const size = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, anchor.right - size.width) + 'px'; + menu.style.top = (anchor.bottom + 4 + size.height > window.innerHeight + ? anchor.top - size.height - 4 + : anchor.bottom + 4) + 'px'; + + // Info is project-bound: make the project current (a dialog-free switch, + // a no-op if it already is), then open the info modal — apply() has + // populated it from the project's stored provenance/summary/topics. + menu.querySelector('.recents-menu-info').addEventListener('click', async () => { + closeMenu(); + await lib().open(entry.id); + // the modal leads with the project's name — after the switch the + // session title is authoritative (rename-safe), the entry the fallback + const nameEl = document.getElementById('project-info-name'); + if (nameEl !== null) { + nameEl.textContent = (window.HyperaudioSave.getProjectTitle && window.HyperaudioSave.getProjectTitle()) + || entry.name || 'project'; + } + const mediaEl = document.getElementById('project-info-media'); + if (mediaEl !== null) { + const media = entry.media || {}; + const rows = []; + if (media.kind === 'original' && media.filename) rows.push(['File', media.filename]); + if (media.kind === 'link') rows.push(['Source', 'remote URL']); + if (media.durationSeconds > 0) rows.push(['Duration', formatDuration(media.durationSeconds)]); + mediaEl.textContent = ''; + if (rows.length === 0) { + const p = document.createElement('p'); + p.textContent = 'No media — text only.'; + mediaEl.appendChild(p); + } + rows.forEach(([label, value]) => { + const p = document.createElement('p'); + const strong = document.createElement('strong'); + strong.textContent = label + ':'; + p.appendChild(strong); + p.appendChild(document.createTextNode(' ' + value)); + mediaEl.appendChild(p); + }); + } + const toggle = document.getElementById('info-modal'); + if (toggle !== null) toggle.checked = true; + }); + menu.querySelector('.recents-menu-star').addEventListener('click', () => { + closeMenu(); + lib().setStarred(entry.id, !isStarred); // the index write re-renders us + }); + menu.querySelector('.recents-menu-rename').addEventListener('click', () => { + closeMenu(); + startRename(entry); + }); + menu.querySelector('.recents-menu-duplicate').addEventListener('click', () => { + closeMenu(); + lib().duplicate(entry.id); + }); + // two-step delete lives inside the menu: first click arms ("Delete?"), + // the second executes; closing the menu by any route disarms it + const del = menu.querySelector('.recents-menu-delete'); + del.addEventListener('click', () => { + if (del.dataset.confirming !== 'true') { + del.dataset.confirming = 'true'; + del.classList.add('confirming'); + del.innerHTML = `${DELETE_SVG}Delete?`; + return; + } + closeMenu(); + performDelete(entry); + }); + + menu.querySelector('.recents-menu-rename').focus(); + } + + /* ---- Row actions ---- */ + + function findRowItem(id) { + return [...document.querySelectorAll('#file-picker .file-item')] + .find((el) => el.getAttribute('data-id') === id) || null; + } + + // Swap the row label for a text input; Enter/blur commits, Escape cancels. + // Rename is the project title Save uses — the library API updates the + // index, the stored snapshot and (for the current project) the session. + function startRename(entry) { + const item = findRowItem(entry.id); + if (item === null) return; + + const input = document.createElement('input'); + input.type = 'text'; + input.value = entry.name || ''; + 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 && input.value.trim() !== '' && input.value.trim() !== entry.name) { + lib().rename(entry.id, input.value); // index write re-renders the list + } else { + render(); // restore the normal row on cancel/no-op + } + }; + + 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()); + } + + async function performDelete(entry) { + const wasCurrent = await lib().remove(entry.id); + // Deleting the CURRENT project leaves the document on screen (the only + // undo there is), but nothing owns it anymore — say so, offer the undo. + if (wasCurrent) { + showPanelNotice('Removed from the library. The transcript is still on screen but no longer being saved.', { + tone: 'info', + sticky: true, + action: { + label: 'Restore', + handler: () => { lib().restoreDeleted(entry.starred === true); }, + }, + }); + } + } + + /* ---- Rendering ---- */ + + let renderToken = 0; + + async function render() { + const api = lib(); + const filePicker = document.querySelector('#file-picker'); + if (!api || filePicker === null) return; + const token = ++renderToken; + const rows = await api.list(); + if (token !== renderToken) return; // a newer render superseded this one + + closeMenu(); // the rows it was anchored to are about to be replaced + hidePopout(); // ditto + filePicker.innerHTML = ''; + + const currentId = api.currentId(); + if (currentId !== null) hideRestoreNotice(); // a project owns the screen again + + const entryById = {}; + const renderRow = (entry) => { + entryById[entry.id] = entry; + const idAttr = escapeMarkup(entry.id); + const nameHtml = escapeMarkup(entry.name || 'project'); + filePicker.insertAdjacentHTML('beforeend', + `
  • ${nameHtml}` + + `` + + `` + + `
  • `); + }; + + // Starred entries pin above the rest (#440, kept for #456). With nothing + // starred the panel keeps its static "Recents" h2 — the established + // label, and the list really is ordered by last edit; once something is + // starred that h2 hides and the list carries its own equal-weight + // "Starred" / "Recents" headings instead (they scroll with the rows). + // No "Projects" label anywhere: it's obvious these are projects. + // Ordering within each group is unchanged (last edit). + const starredRows = rows.filter((r) => r.starred === true); + const recentRows = rows.filter((r) => r.starred !== true); + const panelTitle = document.getElementById('recents-title'); + if (panelTitle !== null) { + panelTitle.style.display = starredRows.length > 0 ? 'none' : ''; + } + if (starredRows.length > 0) { + filePicker.insertAdjacentHTML('beforeend', '
  • Starred

  • '); + starredRows.forEach(renderRow); + if (recentRows.length > 0) { + filePicker.insertAdjacentHTML('beforeend', '
  • Recents

  • '); + } + } + recentRows.forEach(renderRow); + + 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 projects yet.
  • '); + } + + filePicker.querySelectorAll('.file-item').forEach((el) => { + el.classList.toggle('active', el.getAttribute('data-id') === currentId); + el.addEventListener('click', (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; + event.preventDefault(); + api.open(el.getAttribute('data-id')); // flushes the outgoing project itself + }); + }); + // hover popout: attach to the ROW so it stays up while reaching for the + // kebab (it floats clear of both); a short delay stops flicker while the + // pointer travels down the list + filePicker.querySelectorAll('.recents-row').forEach((li) => { + const item = li.querySelector('.file-item'); + if (item === null) return; + const id = item.getAttribute('data-id'); + li.addEventListener('mouseenter', () => { + if (drawerQuery.matches || menuProjectId !== null) return; + clearTimeout(popoutTimer); + // a deliberate tooltip-grade dwell: browsing the list shouldn't + // trigger it, resting on a row should + popoutTimer = setTimeout(() => showPopout(li, entryById[id]), 1000); + }); + li.addEventListener('mouseleave', hidePopout); + }); + filePicker.querySelectorAll('.recents-kebab').forEach((btn) => { + btn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const id = btn.getAttribute('data-id'); + if (menuProjectId === id) { + closeMenu(); // second click on the same kebab toggles it shut + return; + } + openMenu(btn, entryById[id]); + }); + }); + } + + function boot() { + if (!window.HyperaudioSave || !window.HyperaudioSave.opfsAvailable) { + // No OPFS, no library: leave the panel empty (the demo/session still works). + const panelTitle = document.getElementById('recents-title'); + if (panelTitle !== null) panelTitle.style.display = 'none'; + return; + } + + document.addEventListener('hyperaudioLibraryChanged', render); + + // Kebab menu teardown: outside click, Escape, or any scroll (the fixed + // menu is anchored to the kebab's on-screen position, which scrolling + // moves). + document.addEventListener('click', (event) => { + if (menuProjectId === null) return; + const t = event.target; + if (t && t.closest && (t.closest('#recents-menu') !== null || t.closest('.recents-kebab') !== null)) return; + closeMenu(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeMenu(); + }); + document.addEventListener('scroll', () => { + if (menuProjectId !== null) closeMenu(); + hidePopout(); // its row anchor just moved + }, true); + + render(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 12ac2101..9a0222c9 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -25,9 +25,13 @@ * Five internal layers; only the BRIDGE touches the editor's DOM: * 1. FORMAT build/validate hyperaudio.json (pure — node-testable) * 2. CONTAINER zip/unzip via JSZip, whitelist-read (pure — node-testable) - * 3. OPFS work/ = the exploded container, autosave, dirty state + * 3. OPFS the project library (#456): work// per project + * (saved.json committed by Save, draft.json autosave scratch, + * origin, media), library.json index, per-project Web Locks. + * Save is a SILENT OPFS commit; Export is the only download. * 4. BRIDGE gather() editor state / apply() a loaded project - * 5. UI menu items in #file-dropdown, hidden file input, boot restore + * 5. UI menu items in #file-dropdown, hidden file input, Save button, + * boot restore of the most recently edited project * * The FORMAT and CONTAINER layers are exported for node --test and are the * pieces a native app would reuse. @@ -62,16 +66,37 @@ const LARGE_MEDIA_WARN_BYTES_LOWMEM = 200 * 1024 * 1024; const WORK_DIR = 'work'; + // The project library (#456): every project lives in work// and + // library.json at the OPFS root is the index the side panel lists — {id, + // name, starred, createdAt, modifiedAt, lastDraftAt, lastSavedAt, media + // meta, summary, topics}. The index replaced the localStorage boot hint: + // boot reads it and restores the most recently edited project. + // + // Each project dir holds TWO states, mirroring Glider's document model: + // saved.json — the state the user last committed with Save (⌘S). Save is + // a silent OPFS write, never a download; a future format + // revision records each manual save as a version. + // draft.json — the autosave scratch (Glider's RecoveryStore analog): + // written debounced on edit so switching projects, crashes + // and closes lose nothing, while the project honestly stays + // DIRTY until a real Save. Deleted by Save. + // transcript.original.json and media/ sit beside them, shared by both. + // Taking a .hyperaudio OUT of the browser is a separate explicit Export — + // the only path that downloads. + const SAVED_FILE = 'saved.json'; + const DRAFT_FILE = 'draft.json'; + const LIBRARY_FILE = 'library.json'; + // Index writes are read-modify-write on one JSON file, so they serialize + // under one origin-global Web Lock; each project's working copy has its own + // per-project lock (#450's slot made per-project): the owning tab gets + // autosave, another tab on the SAME project keeps full editing but no slot + // (bannered honestly), and the lock's queue promotes it when the owner + // closes or switches away. Two tabs on different projects both own theirs. + const LIBRARY_LOCK = 'hyperaudio:library'; + const PROJECT_LOCK_PREFIX = 'hyperaudio:project:'; + // Pre-#456 single-slot layout (work/snapshot.json + root app-state.json): + // never released — migrated into a project dir once, for dev working copies. const APP_STATE_FILE = 'app-state.json'; - // Synchronous boot hint: OPFS can only be probed async, so the autosave - // maintains this flag and boot reads it before deciding to restore. - const WORK_HINT_KEY = 'hyperaudioWorkPresent'; - // One origin-global working-copy slot → one Web Lock (#450). The owning tab - // gets autosave/boot-restore; other tabs keep FULL editing but no slot - // (bannered honestly, beforeunload still guards their unsaved work), and - // the lock's queue promotes a waiting tab automatically when the owner - // closes. Per-project locks arrive with the Phase B work dirs (#452). - const WORK_LOCK = 'hyperaudio:work'; /* ========================================================================== * 1. FORMAT — build/validate hyperaudio.json (pure) @@ -89,6 +114,18 @@ return { ok: true, major, minor }; } + // Strip class pollution from transcript markup (playback highlighting, + // contenteditable artifacts) while KEEPING the semantic "speaker" class — + // htmlToJSON identifies speaker labels by it (span[data-m]:not(.speaker)), + // so the blanket class strip the editor's getTranscriptData() applies + // demoted every speaker to a plain word on each save/autosave round trip: + // paragraphs lost their speaker names and restored labels lost their + // styling and the Speakers toggle. + function sanitizeTranscriptClasses(html) { + return String(html).replace(/ class="([^"]*)"/g, (match, classes) => + (/(?:^|\s)speaker(?:\s|$)/.test(classes) ? ' class="speaker"' : '')); + } + // media.path MUST be media/: exactly one non-empty segment, no // separators of either convention, and the segment must not be the exact // traversal tokens "." or ".." (spec § 10.2, pinned in 1.2). A ".." @@ -217,6 +254,35 @@ return JSON.stringify(project, null, 2); } + /* -------------------------------------------------------------------------- + * Library index rules (#456) — pure, node-testable. + * ------------------------------------------------------------------------ */ + + // Panel order: last edited first, created date the fallback for entries + // that have never been written to. + function sortLibraryEntries(entries) { + return entries.slice().sort((a, b) => + (b.modifiedAt || b.createdAt || 0) - (a.modifiedAt || a.createdAt || 0)); + } + + // The deterministic per-project "dirty" rule (#456, Glider-matched): a + // draft has been written since the last manual Save. A never-saved project + // (fresh transcription) is dirty; an opened .hyperaudio starts clean (the + // file IS the saved state). + function isEntryDirty(entry) { + return (entry.lastDraftAt || 0) > (entry.lastSavedAt || 0); + } + + // Identity is OPFS-native (#456): a generated id names the work// dir + // and the per-project lock — none of the file↔workdir matching hazards; + // opening the same .hyperaudio twice simply makes two entries. + function newProjectId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return 'p-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); + } + /* ========================================================================== * 2. CONTAINER — zip/unzip (pure; JSZip implementation injected) * ======================================================================== */ @@ -367,7 +433,9 @@ FORMAT_NAME, FORMAT_VERSION, CONTAINER_MIMETYPE, ENTRY, MEDIA_DIR, TEXT_ENTRY_MAX_BYTES, checkFormatVersion, validateMediaPath, sanitizeMediaFilename, validateProjectJson, + sanitizeTranscriptClasses, buildProjectJson, serializeProjectJson, + sortLibraryEntries, isEntryDirty, newProjectId, zipProject, unzipProject, }; if (typeof module !== 'undefined' && module.exports) { @@ -385,11 +453,16 @@ && typeof FileSystemFileHandle !== 'undefined' && FileSystemFileHandle.prototype.createWritable); - async function getWorkDir(create) { + async function getWorkRoot(create) { const root = await navigator.storage.getDirectory(); return root.getDirectoryHandle(WORK_DIR, { create: !!create }); } + async function getProjectDir(id, create) { + const work = await getWorkRoot(create); + return work.getDirectoryHandle(id, { create: !!create }); + } + async function writeFileTo(dir, name, data) { const handle = await dir.getFileHandle(name, { create: true }); const writable = await handle.createWritable(); @@ -407,9 +480,9 @@ } } - async function readMediaFileFromWork(filename) { + async function readMediaFileFromProject(id, filename) { try { - const dir = await getWorkDir(false); + const dir = await getProjectDir(id, false); const mediaDir = await dir.getDirectoryHandle('media'); const handle = await mediaDir.getFileHandle(filename); return await handle.getFile(); @@ -418,43 +491,77 @@ } } - async function clearWork() { + async function deleteProjectDir(id) { try { - const root = await navigator.storage.getDirectory(); - await root.removeEntry(WORK_DIR, { recursive: true }); - } catch (e) { /* nothing to clear */ } - try { localStorage.removeItem(WORK_HINT_KEY); } catch (e) { /* private mode */ } + const work = await getWorkRoot(false); + await work.removeEntry(id, { recursive: true }); + } catch (e) { /* nothing to delete */ } } - async function readAppState() { + /* -------------------------------------------------------------------------- + * The library index — library.json at the OPFS root. Reads are lock-free + * (a torn read is impossible: createWritable swaps atomically on close); + * writes are read-modify-write and serialize under the origin-global + * LIBRARY_LOCK so two tabs editing different projects can't lose each + * other's index updates. Every change notifies this tab's panel directly + * and other tabs over a BroadcastChannel. + * ------------------------------------------------------------------------ */ + + async function readLibrary() { try { const root = await navigator.storage.getDirectory(); - const text = await readTextFrom(root, APP_STATE_FILE); - return text !== null ? JSON.parse(text) : {}; + const text = await readTextFrom(root, LIBRARY_FILE); + const lib = text !== null ? JSON.parse(text) : null; + if (lib === null || typeof lib !== 'object' || !Array.isArray(lib.projects)) { + return { projects: [] }; + } + return lib; } catch (e) { - return {}; + return { projects: [] }; } } - async function patchAppState(patch) { - try { + async function updateLibrary(mutate) { + const run = async () => { + const lib = await readLibrary(); + mutate(lib); const root = await navigator.storage.getDirectory(); - const state = Object.assign(await readAppState(), patch); - await writeFileTo(root, APP_STATE_FILE, JSON.stringify(state)); - return state; - } catch (e) { - return null; + await writeFileTo(root, LIBRARY_FILE, JSON.stringify(lib)); + return lib; + }; + let lib; + if ('locks' in navigator) { + lib = await navigator.locks.request(LIBRARY_LOCK, run); + } else { + lib = await run(); } + notifyLibraryChanged(false); + return lib; } - // The deterministic "dirty" rule (discussion doc § 13): work has been written - // since the last .hyperaudio download. Download marking is optimistic — the - // browser gives no completion signal for . + // The panel (hyperaudio-library.js) re-renders on this event; the channel + // keeps a second tab's panel honest when this one writes the index. + const libraryChannel = typeof BroadcastChannel !== 'undefined' + ? new BroadcastChannel('hyperaudio:library') : null; + function notifyLibraryChanged(fromRemote) { + document.dispatchEvent(new CustomEvent('hyperaudioLibraryChanged')); + if (fromRemote !== true && libraryChannel !== null) { + libraryChannel.postMessage('changed'); + } + } + if (libraryChannel !== null) { + libraryChannel.onmessage = () => notifyLibraryChanged(true); + } + + // Per-project dirty (#456): the current project's index entry decides; a + // tab without the project's lock falls back to its own session flag (its + // edits never reach the working copy, so the index can't speak for it). async function isDirty() { - if (!hasWorkLock) return session.active && sessionEdited; if (!session.active) return false; - const state = await readAppState(); - return (state.lastWorkWriteAt || 0) > (state.lastDownloadAt || 0); + if (!opfsAvailable || session.projectId === null || !hasProjectLock) return sessionEdited; + const lib = await readLibrary(); + const entry = lib.projects.find((p) => p.id === session.projectId); + return entry !== undefined ? isEntryDirty(entry) : sessionEdited; } /* ========================================================================== @@ -462,9 +569,11 @@ * ======================================================================== */ // Everything the module knows about the open project. Hydrated on new - // transcript (hyperaudioInit), on open, and on boot restore. + // transcript (hyperaudioInit), on open, on boot restore, and on a library + // switch (#456). const session = { active: false, + projectId: null, // work// this session writes to (#456); null = nowhere (demo, or deleted-but-on-screen) created: null, provenance: null, // {engine, model, transcribedAt} provenanceAt: 0, // when the engine reported it (staleness guard) @@ -491,24 +600,37 @@ let editGeneration = 0; // bumps on every edit signal let identityGeneration = 0; // bumps when a DIFFERENT document commits let saveInFlight = false; - let autosaveInFlight = false; - let autosaveFollowUp = false; - // Whether THIS tab owns the working-copy slot (#450). Browsers without Web - // Locks (pre-15.4 Safari) assume single-tab ownership — the pre-#450 status - // quo, no worse. - let hasWorkLock = false; + // Snapshot writes serialize on a promise chain (#448: parallel writes could + // interleave files from different states); calls landing while one is + // running or queued coalesce into ONE queued follow-up, which re-gathers — + // so the last write always holds the latest state. + let snapshotChain = Promise.resolve(); + let snapshotQueued = false; + let autosavePending = false; // an edit is debouncing toward a snapshot write + // Whether THIS tab owns the CURRENT project's working copy (#450 made + // per-project by #456). Browsers without Web Locks (pre-15.4 Safari) assume + // single-tab ownership — the pre-#450 status quo, no worse. + let hasProjectLock = false; + let projectLockRelease = null; // resolving this releases the held lock + let projectLockQueue = null; // AbortController for the queued promotion request let autosaveTimer = null; function nowIso() { return new Date().toISOString(); } + // The transcript's markup for the writer. Reads the live element directly + // (NOT getTranscriptData(), whose blanket class strip destroys the speaker + // class); in caption mode the transcript element only exists inside + // editor-core's transcriptCache clone — read it from there. function getEditorHtml() { - if (typeof getTranscriptData === 'function') { - return getTranscriptData(); - } const el = document.querySelector('#hypertranscript'); - return el !== null ? el.innerHTML.replace(/ class=".*?"/g, '') : ''; + if (el !== null) return sanitizeTranscriptClasses(el.innerHTML); + if (typeof transcriptCache !== 'undefined' && transcriptCache !== null) { + const cached = transcriptCache.querySelector('#hypertranscript'); + if (cached !== null) return sanitizeTranscriptClasses(cached.innerHTML); + } + return typeof getTranscriptData === 'function' ? getTranscriptData() : ''; } function getCaptionsVtt() { @@ -698,6 +820,41 @@ return article; } + // The info modal's Transcription section is project-bound (#456): rebuilt + // here from the loaded project's STORED provenance whenever a project takes + // the editor (apply below, import reset in onNewTranscript). A live engine + // run still overwrites it with its richer rows (device, time taken) via + // editor-core's setTranscriptionInfo — those extras aren't persisted, so a + // reload shows this stored subset. DOM-built: provenance is file data, + // never innerHTML (spec § 10.5). + function renderTranscriptionInfo(provenance, language) { + const container = document.getElementById('transcription-info'); + if (container === null) return; + const rows = []; + if (provenance && provenance.engine) rows.push(['Service', String(provenance.engine)]); + if (provenance && provenance.model) rows.push(['Model', String(provenance.model)]); + if (language) rows.push(['Language', String(language)]); + if (provenance && provenance.transcribedAt) { + const at = new Date(provenance.transcribedAt); + if (!Number.isNaN(at.getTime())) rows.push(['Transcribed', at.toLocaleString()]); + } + container.textContent = ''; + if (rows.length === 0) { + const p = document.createElement('p'); + p.textContent = 'No transcription details recorded for this project.'; + container.appendChild(p); + return; + } + rows.forEach(([label, value]) => { + const p = document.createElement('p'); + const strong = document.createElement('strong'); + strong.textContent = label + ':'; + p.appendChild(strong); + p.appendChild(document.createTextNode(' ' + value)); + container.appendChild(p); + }); + } + // Replay a loaded project into the editor. Mirrors what the legacy // renderTranscript() does for Recents, but builds the DOM safely from JSON. function apply(loaded) { @@ -773,6 +930,8 @@ session.title = (texts !== null && texts.title) ? texts.title : ''; const titleField = document.querySelector('#project-title'); if (titleField !== null) titleField.value = session.title; + renderTranscriptionInfo(loaded.recovered ? null : loaded.project.provenance, + texts !== null ? (texts.language || '') : ''); const cleaned = transcriptEl.innerHTML.replace(/ class=".*?"/g, ''); const htmlLink = document.querySelector('#download-html'); @@ -791,39 +950,151 @@ * Project lifecycle: new project capture, autosave, save/open * ======================================================================== */ - async function writeWorkSnapshot() { - if (!opfsAvailable || !session.active || !hasWorkLock) return; - // Serialize snapshots (#448): parallel writes could interleave files from - // different states. An edit landing mid-write schedules ONE follow-up. - if (autosaveInFlight) { autosaveFollowUp = true; return; } - autosaveInFlight = true; - const identityAtStart = identityGeneration; + // Gather the live document and write it as ONE state file (draft.json or + // saved.json). One atomic-enough artifact (#448): json + html + captions in + // a single file, so a crash can never leave a mixed-generation multi-file + // state. Absent captions are an explicit null — unambiguous. The origin and + // media stay as separate immutable files. gather() and the projectId + // capture are synchronous, so the write is of ONE document to ITS OWN + // directory even if a switch lands mid-write. + async function writeStateFile(projectId, filename) { + const state = gather(); + const dir = await getProjectDir(projectId, true); + const vtt = getCaptionsVtt(); + await writeFileTo(dir, filename, JSON.stringify({ + json: serializeProjectJson(buildProjectJson(state)), + html: state.html, + captionsVtt: vtt !== '' ? vtt : null, + })); + return state; + } + + async function writeDraftNow() { + if (!opfsAvailable || !session.active || !hasProjectLock || session.projectId === null) return; + autosavePending = false; + const projectId = session.projectId; try { - const state = gather(); - const dir = await getWorkDir(true); - // One atomic-enough artifact (#448): json + html + captions in a single - // file, so a crash can never leave a mixed-generation multi-file - // snapshot. Absent captions are an explicit null — unambiguous. The - // origin and media stay as separate immutable files. - const vtt = getCaptionsVtt(); - await writeFileTo(dir, 'snapshot.json', JSON.stringify({ - json: serializeProjectJson(buildProjectJson(state)), - html: state.html, - captionsVtt: vtt !== '' ? vtt : null, - })); - // A completion for a superseded document must not be adopted (#448). - if (identityGeneration === identityAtStart) { - await patchAppState({ lastWorkWriteAt: Date.now() }); - try { localStorage.setItem(WORK_HINT_KEY, '1'); } catch (e) { /* private mode */ } - } + const state = await writeStateFile(projectId, DRAFT_FILE); + await touchLibraryEntry(projectId, state, { draft: true }); } catch (e) { - console.warn('hyperaudio-save: autosave failed', e); - } finally { - autosaveInFlight = false; - if (autosaveFollowUp) { - autosaveFollowUp = false; - if (identityGeneration === identityAtStart) writeWorkSnapshot(); + console.warn('hyperaudio-save: draft autosave failed', e); + } + } + + function writeDraft() { + if (snapshotQueued) return snapshotChain; + snapshotQueued = true; + snapshotChain = snapshotChain.then(() => { + snapshotQueued = false; + return writeDraftNow(); + }); + return snapshotChain; + } + + // Every state write refreshes the project's index entry — name (the title + // Save uses), order timestamp, the dirty timestamps, media meta and the + // hover-preview texts, so the panel renders from the index alone. + async function touchLibraryEntry(id, state, stamps) { + const now = Date.now(); + await updateLibrary((lib) => { + let entry = lib.projects.find((p) => p.id === id); + if (entry === undefined) { + entry = { + id, + starred: false, + createdAt: Date.parse(state.created) || now, + lastDraftAt: 0, + lastSavedAt: 0, + }; + lib.projects.push(entry); + } + entry.name = state.texts.title; + entry.modifiedAt = now; + if (stamps && stamps.draft === true) entry.lastDraftAt = now; + if (stamps && stamps.saved === true) { + entry.lastSavedAt = now; + entry.lastDraftAt = 0; // the draft died with the save + } + entry.media = { + kind: state.media.kind, + filename: state.media.filename || '', + durationSeconds: state.media.durationSeconds || 0, + }; + entry.summary = state.texts.summary || ''; + entry.topics = state.texts.topics || []; + }); + } + + // Land the outgoing project's pending draft in its own directory before the + // editor DOM changes hands (#456: switching loses nothing — the draft keeps + // the edits, the dirty state stays honest). Await-ing the chain also lets + // an in-flight write finish — its state was gathered before the call, so it + // is still the outgoing document's. + async function flushPendingDraft() { + clearTimeout(autosaveTimer); + autosaveTimer = null; + if (autosavePending) { + await writeDraft(); + } else { + await snapshotChain; + } + } + + // Manual Save (⌘S / the navbar button), Glider-matched: commit the live + // document to saved.json SILENTLY — never a download — and retire the + // draft. A future format revision records each manual save as a version. + // In the native wrapper the bridge receives the built container instead + // (#449: one save path for web and native); without OPFS the explicit + // export download is the only durable copy, so Save falls back to it. + async function saveProject() { + if (saveInFlight) return false; + saveInFlight = true; + try { + const bridge = window.hyperaudioProjectBridge; + if (bridge && typeof bridge.save === 'function') { + return await exportProject({ asSave: true }); // the bridge intercepts the built container + } + if (!opfsAvailable) { + return await exportProject({ asSave: true }); // no OPFS: the download IS the save + } + if (!session.active || session.projectId === null) { + await projectAlert('There is no project to save yet — transcribe or import something first.'); + return false; + } + const identityAtStart = identityGeneration; + const editAtGather = editGeneration; + clearTimeout(autosaveTimer); + autosaveTimer = null; + autosavePending = false; + const projectId = session.projectId; + // ride the state-write chain so a draft write can't interleave and + // resurrect the draft after the save deletes it + let ok = false; + snapshotChain = snapshotChain.then(async () => { + try { + const state = await writeStateFile(projectId, SAVED_FILE); + const dir = await getProjectDir(projectId, false); + await dir.removeEntry(DRAFT_FILE).catch(() => {}); + await touchLibraryEntry(projectId, state, { saved: true }); + ok = true; + } catch (e) { + console.warn('hyperaudio-save: save failed', e); + } + }); + await snapshotChain; + if (!ok) { + await projectAlert('Saving the project failed. Your work is still in the editor and the autosaved draft.'); + return false; + } + // Mark clean only if this save still belongs to the current document + // AND no edit landed while it was being written (#448). + if (identityGeneration === identityAtStart && editGeneration === editAtGather) { + sessionEdited = false; + updateSaveIndicator(); } + return true; + } finally { + saveInFlight = false; } } @@ -833,14 +1104,15 @@ editGeneration += 1; updateSaveIndicator(); if (!opfsAvailable) return; + autosavePending = true; clearTimeout(autosaveTimer); - autosaveTimer = setTimeout(writeWorkSnapshot, 1500); + autosaveTimer = setTimeout(writeDraft, 1500); } async function writeMediaOnce() { - if (!opfsAvailable || session.mediaFile === null || !hasWorkLock) return; + if (!opfsAvailable || session.mediaFile === null || !hasProjectLock || session.projectId === null) return; try { - const dir = await getWorkDir(true); + const dir = await getProjectDir(session.projectId, true); const mediaDir = await dir.getDirectoryHandle('media', { create: true }); // one media per project: drop any previous file first for await (const name of mediaDir.keys()) { @@ -852,6 +1124,19 @@ } } + // The current session's origin as held in memory, written to the project + // dir — used when a project is born and when a deleted-but-on-screen + // document is restored as a new entry (#456). + async function writeOriginToProjectDir() { + if (!opfsAvailable || !hasProjectLock || session.projectId === null || session.originalJson === null) return; + try { + const dir = await getProjectDir(session.projectId, true); + await writeFileTo(dir, ENTRY.original, session.originalJson); + } catch (e) { + console.warn('hyperaudio-save: origin write failed', e); + } + } + // The origin (spec § 5): written once when a project is born from a // transcription/import, immutable afterwards, never with struck flags. async function writeOriginOnce(transcript) { @@ -865,23 +1150,26 @@ }; session.hasOriginal = true; session.originalJson = JSON.stringify(clean, null, 2); - if (!opfsAvailable || !hasWorkLock) return; - try { - const dir = await getWorkDir(true); - await writeFileTo(dir, ENTRY.original, session.originalJson); - } catch (e) { - console.warn('hyperaudio-save: origin write failed', e); - } + await writeOriginToProjectDir(); } // A NEW project begins whenever a transcription or import lands a fresh - // transcript (they all fire hyperaudioInit; legacy Recents loads call - // hyperaudio() directly and do NOT, so they never overwrite the origin). + // transcript (they all fire hyperaudioInit). It gets its own id, directory + // and lock (#456) — the previous project stays in the library untouched. + // No flush of the outgoing project here: by the time hyperaudioInit fires + // the editor DOM already holds the NEW transcript, so a late gather would + // write the wrong document into the old directory. Any autosave already + // in flight gathered before the swap and lands correctly. async function onNewTranscript() { if (suppressCapture) return; const transcriptEl = document.querySelector('#hypertranscript'); if (transcriptEl === null || transcriptEl.querySelector('span[data-m]') === null) return; + clearTimeout(autosaveTimer); + autosaveTimer = null; + autosavePending = false; + releaseProjectLock(); session.active = true; + session.projectId = opfsAvailable ? newProjectId() : null; session.created = nowIso(); session.hasOriginal = false; session.mediaFileFromUrl = null; @@ -897,13 +1185,16 @@ if (Date.now() - session.provenanceAt > 120000) { session.provenance = null; session.language = ''; + // an import with no engine run: the modal must not keep showing a + // previous project's transcription details + renderTranscriptionInfo(null, ''); } - if (opfsAvailable && hasWorkLock) { - await clearWork(); + if (opfsAvailable && session.projectId !== null) { + await acquireProjectLock(session.projectId); // fresh id: always granted await writeOriginOnce(htmlToJSON(getEditorHtml())); await resolveMediaFile(); await writeMediaOnce(); - await writeWorkSnapshot(); + await writeDraft(); // a fresh transcription is an unsaved draft } } @@ -1053,17 +1344,24 @@ if (btn !== null) btn.classList.toggle('dirty', sessionEdited === true); } - async function saveToFile() { - if (saveInFlight) return false; // one container build at a time (#448) - saveInFlight = true; + // Export Project (.hyperaudio): build the container and download it — the + // ONLY path that downloads. Saving is the silent OPFS commit (saveProject); + // export is how a portable copy leaves the browser. asSave marks the two + // fallback contexts where the container IS the save (native bridge, no + // OPFS) so success clears the dirty state there — a plain export never + // touches it. + let exportInFlight = false; + async function exportProject(opts) { + if (exportInFlight) return false; // one container build at a time (#448) + exportInFlight = true; try { - return await saveToFileInner(); + return await exportProjectInner(!!(opts && opts.asSave)); } finally { - saveInFlight = false; + exportInFlight = false; } } - async function saveToFileInner() { + async function exportProjectInner(asSave) { const identityAtStart = identityGeneration; let mediaFile = await resolveMediaFile(); const player = document.querySelector('#hyperplayer'); @@ -1110,10 +1408,10 @@ // primary source (also covers browsers without OPFS); work/ carries it // across reloads. let originalJson = session.originalJson; - if (originalJson === null && opfsAvailable && hasWorkLock) { + if (originalJson === null && opfsAvailable && session.projectId !== null) { try { - originalJson = await readTextFrom(await getWorkDir(false), ENTRY.original); - } catch (e) { /* no work dir yet */ } + originalJson = await readTextFrom(await getProjectDir(session.projectId, false), ENTRY.original); + } catch (e) { /* no project dir yet */ } } state.hasOriginal = originalJson !== null; @@ -1154,10 +1452,12 @@ // Mark clean only if this save still belongs to the current document AND // no edit landed while the container was being built (#448) — otherwise // the download is real but the session stays dirty. - if (identityGeneration === identityAtStart && editGeneration === editAtGather) { + // Only the save-fallback contexts mark clean (and only if this container + // still belongs to the current document and no edit landed while it was + // built, #448) — a plain export is a copy, not a save. + if (asSave && identityGeneration === identityAtStart && editGeneration === editAtGather) { sessionEdited = false; updateSaveIndicator(); - if (hasWorkLock) await patchAppState({ lastDownloadAt: Date.now() }); } return true; } @@ -1184,18 +1484,10 @@ return; } - if (await isDirty()) { - const choice = await projectDialog('The current project has changes not yet saved as a .hyperaudio file. Opening a new project will DISCARD them.', { - confirmLabel: 'Discard and open', danger: true, extraLabel: 'Save and open', cancelButton: false, - }); - if (choice === false) return; - if (choice === 'extra') { - let saved = false; - try { saved = await saveToFile(); } catch (e) { saved = false; } - if (saved !== true) return; // the save was abandoned — replacing now would lose it - } - } - + // No discard dialog (#456): the outgoing project's pending draft flushes + // to its own directory and stays in the library — opening loses nothing. + // The dialog existed only because there was one work slot. + await flushPendingDraft(); let reconcileNow = null; // § 7.3: original-kind container missing its media entry if (loaded.mediaData !== null && loaded.mediaEntryName !== null) { @@ -1214,9 +1506,13 @@ try { apply(loaded); - // Hydrate the session from the loaded project and seed work/ so the - // autosave continues from here. + // Hydrate the session and seed a NEW project directory so the autosave + // continues from here. Every open makes a fresh library entry (#456) — + // identity is OPFS-native, so re-opening the same file twice simply + // creates a second entry. + releaseProjectLock(); session.active = true; + session.projectId = opfsAvailable ? newProjectId() : null; session.created = (!loaded.recovered && loaded.project.created) || nowIso(); session.provenance = (!loaded.recovered && loaded.project.provenance) || null; session.language = (!loaded.recovered && loaded.project.texts && loaded.project.texts.language) || ''; @@ -1231,16 +1527,29 @@ identityGeneration += 1; // a different document now owns the session updateSaveIndicator(); - if (opfsAvailable && hasWorkLock) { - await clearWork(); - const dir = await getWorkDir(true); + if (opfsAvailable && session.projectId !== null) { + await acquireProjectLock(session.projectId); // fresh id: always granted + const dir = await getProjectDir(session.projectId, true); if (loaded.originalText !== null) await writeFileTo(dir, ENTRY.original, loaded.originalText); await writeMediaOnce(); } } finally { suppressCapture = false; } - await writeWorkSnapshot(); + // The opened file IS the saved state: seed saved.json, no draft — the + // fresh entry starts clean. + if (opfsAvailable && session.projectId !== null) { + const id = session.projectId; + snapshotChain = snapshotChain.then(async () => { + try { + const state = await writeStateFile(id, SAVED_FILE); + await touchLibraryEntry(id, state, { saved: true }); + } catch (e) { + console.warn('hyperaudio-save: seeding the opened project failed', e); + } + }); + await snapshotChain; + } if (loaded.warnings.length > 0) { console.warn('hyperaudio-save: opened with warnings:', loaded.warnings); @@ -1301,62 +1610,309 @@ scheduleAutosave(); } - // Boot restore: the synchronous localStorage hint decides whether to probe - // OPFS at all; the static demo transcript in index.html is simply replaced. - async function restoreFromWork() { + /* ========================================================================== + * Project switching, boot restore and the library operations (#456) + * ======================================================================== */ + + // Read a project's working state from its directory — the DRAFT when one + // exists (the edits made since the last Save), else the saved state. + // Returns {project, captionsVtt, mediaFile, originalText, fromDraft} or + // null when both are missing/unreadable/invalid (the caller leaves the + // editor untouched). + async function readProjectFiles(id) { try { - const dir = await getWorkDir(false); - // The snapshot is ONE file since #448 (torn multi-file states are - // impossible); work dirs written before that carry the per-file layout — - // read them as a fallback until they age out. + const dir = await getProjectDir(id, false); + let fromDraft = true; + let stateText = await readTextFrom(dir, DRAFT_FILE); + if (stateText === null) { + fromDraft = false; + stateText = await readTextFrom(dir, SAVED_FILE); + } + if (stateText === null) return null; let jsonText = null; let captionsVtt = null; - const snapshotText = await readTextFrom(dir, 'snapshot.json'); - let snapshotHtml = null; - if (snapshotText !== null) { - try { - const snapshot = JSON.parse(snapshotText); - jsonText = typeof snapshot.json === 'string' ? snapshot.json : null; - snapshotHtml = typeof snapshot.html === 'string' ? snapshot.html : null; - captionsVtt = typeof snapshot.captionsVtt === 'string' ? snapshot.captionsVtt : null; - } catch (e) { - console.warn('hyperaudio-save: unreadable snapshot.json', e); - } - } - if (jsonText === null) { - jsonText = await readTextFrom(dir, ENTRY.json); // pre-#448 layout - captionsVtt = await readTextFrom(dir, ENTRY.captions); - } - if (jsonText === null) { - try { localStorage.removeItem(WORK_HINT_KEY); } catch (e) { /* ignore */ } - return; + try { + const snapshot = JSON.parse(stateText); + jsonText = typeof snapshot.json === 'string' ? snapshot.json : null; + captionsVtt = typeof snapshot.captionsVtt === 'string' ? snapshot.captionsVtt : null; + } catch (e) { + console.warn('hyperaudio-save: unreadable project state file', e); + return null; } + if (jsonText === null) return null; const project = JSON.parse(jsonText); const validation = validateProjectJson(project); if (!validation.ok) { - console.warn('hyperaudio-save: work copy failed validation, leaving demo', validation.errors); - try { localStorage.removeItem(WORK_HINT_KEY); } catch (e) { /* ignore */ } - return; + console.warn('hyperaudio-save: working copy failed validation', validation.errors); + return null; } const mediaFile = project.media.kind === 'original' - ? await readMediaFileFromWork(project.media.filename) : null; + ? await readMediaFileFromProject(id, project.media.filename) : null; const originalText = await readTextFrom(dir, ENTRY.original); + return { project, captionsVtt, mediaFile, originalText, fromDraft }; + } catch (e) { + return null; // directory gone or OPFS refused + } + } - apply({ recovered: false, project, captionsVtt, mediaFile }); - session.active = true; - identityGeneration += 1; // the restored document owns the session - session.created = project.created || nowIso(); - session.provenance = project.provenance || null; - session.language = (project.texts && project.texts.language) || ''; - session.mediaFile = mediaFile; - session.mediaFileFromUrl = null; - session.pendingReconcile = project.media.kind === 'link' ? project.media : null; - session.hasOriginal = originalText !== null; - session.originalJson = originalText; - session.envelope = project; // §8.1: a save after restore must preserve unknown fields too + // Replay project files into the editor and hydrate the session — shared by + // boot restore and panel switches. prepare → apply ordering (#448): callers + // read the files BEFORE tearing anything down. + function applyProjectFiles(id, files) { + const project = files.project; + apply({ recovered: false, project, captionsVtt: files.captionsVtt, mediaFile: files.mediaFile }); + session.active = true; + session.projectId = id; + identityGeneration += 1; // a different document owns the session now + session.created = project.created || nowIso(); + session.provenance = project.provenance || null; + session.language = (project.texts && project.texts.language) || ''; + session.mediaFile = files.mediaFile; + session.mediaFileFromUrl = null; + session.pendingReconcile = project.media.kind === 'link' ? project.media : null; + session.hasOriginal = files.originalText !== null; + session.originalJson = files.originalText; + session.envelope = project; // §8.1: a save after restore must preserve unknown fields too + } + + // Switching asks nothing and loses nothing (#456): flush the outgoing + // project's pending draft to its own directory, hand the editor to the + // incoming one (draft first — its unsaved edits come back, still dirty), + // move the per-project lock. Returns false (editor untouched) when the + // target can't be read. + async function switchToProject(id) { + if (!opfsAvailable) return false; + if (id === session.projectId) return true; + const files = await readProjectFiles(id); + if (files === null) return false; + await flushPendingDraft(); + releaseProjectLock(); + suppressCapture = true; + try { + applyProjectFiles(id, files); + } finally { + suppressCapture = false; + } + await acquireProjectLock(id); + const lib = await readLibrary(); + const entry = lib.projects.find((p) => p.id === id); + sessionEdited = entry !== undefined ? isEntryDirty(entry) : files.fromDraft; + updateSaveIndicator(); + notifyLibraryChanged(false); // active-row highlight moves + return true; + } + + /* ---- Library operations the panel calls (#456) ---- */ + + // Rewrite texts.title inside a stored state file (draft or saved) so a + // later switch reads the new name back rather than resurrecting the old. + async function rewriteStateTitle(dir, filename, name) { + const text = await readTextFrom(dir, filename); + if (text === null) return; + const snapshot = JSON.parse(text); + const project = JSON.parse(snapshot.json); + project.texts = Object.assign({}, project.texts, { title: name }); + snapshot.json = serializeProjectJson(project); + await writeFileTo(dir, filename, JSON.stringify(snapshot)); + } + + // Rename IS the project title Save uses, so it lands everywhere the title + // lives: the index entry, both stored state files, and — for the current + // project — the live session. + async function renameProject(id, newName) { + const name = String(newName === null || newName === undefined ? '' : newName).trim(); + if (name === '') return; + try { + const dir = await getProjectDir(id, false); + await rewriteStateTitle(dir, DRAFT_FILE, name); + await rewriteStateTitle(dir, SAVED_FILE, name); } catch (e) { - console.warn('hyperaudio-save: restore failed, leaving demo', e); + console.warn('hyperaudio-save: rename state rewrite failed', e); } + if (id === session.projectId) { + session.title = name; + const titleField = document.querySelector('#project-title'); + if (titleField !== null) titleField.value = name; + } + await updateLibrary((lib) => { + const entry = lib.projects.find((p) => p.id === id); + if (entry !== undefined) entry.name = name; + }); + } + + async function setProjectStarred(id, starred) { + await updateLibrary((lib) => { + const entry = lib.projects.find((p) => p.id === id); + if (entry !== undefined) entry.starred = starred === true; + }); + } + + // Duplicate: a new id sharing nothing — both state files, origin and media + // are copied byte-for-byte. The copy is never the current project and + // mirrors the source's dirty state (same draft/saved stamps). + async function duplicateProject(id) { + const srcLib = await readLibrary(); + const srcEntry = srcLib.projects.find((p) => p.id === id); + if (srcEntry === undefined) return null; + const newId = newProjectId(); + const copyName = (srcEntry.name || 'project') + ' copy'; + try { + const src = await getProjectDir(id, false); + const dst = await getProjectDir(newId, true); + for (const name of [DRAFT_FILE, SAVED_FILE, ENTRY.original]) { + const text = await readTextFrom(src, name); + if (text !== null) await writeFileTo(dst, name, text); + } + try { + const srcMedia = await src.getDirectoryHandle('media'); + const dstMedia = await dst.getDirectoryHandle('media', { create: true }); + for await (const [name, handle] of srcMedia.entries()) { + if (handle.kind === 'file') await writeFileTo(dstMedia, name, await handle.getFile()); + } + } catch (e) { /* no media dir */ } + // the copy carries its own title so a later switch doesn't resurrect the old one + await rewriteStateTitle(dst, DRAFT_FILE, copyName); + await rewriteStateTitle(dst, SAVED_FILE, copyName); + } catch (e) { + console.warn('hyperaudio-save: duplicate failed', e); + await deleteProjectDir(newId); + return null; + } + const now = Date.now(); + await updateLibrary((lib) => { + lib.projects.push(Object.assign({}, srcEntry, { + id: newId, + name: copyName, + starred: false, + createdAt: now, + modifiedAt: now, + })); + }); + return newId; + } + + // Delete removes the directory and the index entry. Deleting the CURRENT + // project leaves the document on screen (the only undo there is) but + // nothing owns it anymore — autosave stops until the panel's Restore + // re-homes it as a new entry. + async function deleteProject(id) { + const wasCurrent = id === session.projectId; + if (wasCurrent) { + clearTimeout(autosaveTimer); + autosaveTimer = null; + autosavePending = false; + await snapshotChain; // let an in-flight write finish before the dir goes + releaseProjectLock(); + session.projectId = null; + } + await deleteProjectDir(id); + await updateLibrary((lib) => { + lib.projects = lib.projects.filter((p) => p.id !== id); + }); + return wasCurrent; + } + + // Undo for deleting the current project: re-home the on-screen document — + // still fully held by the session — under a fresh id. + async function restoreCurrentAsNewProject(starred) { + if (!opfsAvailable || !session.active || session.projectId !== null) return null; + session.projectId = newProjectId(); + const id = session.projectId; + await acquireProjectLock(id); + await writeOriginToProjectDir(); + await writeMediaOnce(); + await writeDraft(); // re-homed work is an unsaved draft until Saved + if (starred === true) await setProjectStarred(id, true); + return id; + } + + /* ---- Boot (#456): migrate any pre-#456 single-slot working copy, then + restore the most recently edited project — the index IS the boot hint. + The single-slot layout (work/snapshot.json + root app-state.json) never + shipped in a release, so this migration only preserves dev working + copies; pre-#448 multi-file layouts are older still and are left alone. */ + + async function migrateSingleSlotWork() { + try { + const work = await getWorkRoot(false); + const snapshotText = await readTextFrom(work, 'snapshot.json'); + if (snapshotText === null) return; + const snapshot = JSON.parse(snapshotText); + const project = JSON.parse(snapshot.json); + const id = newProjectId(); + const dir = await getProjectDir(id, true); + // the old slot was autosave state that had (maybe) never been taken out + // of the browser — land it as a DRAFT, dirty until a real Save + await writeFileTo(dir, DRAFT_FILE, snapshotText); + const originalText = await readTextFrom(work, ENTRY.original); + if (originalText !== null) await writeFileTo(dir, ENTRY.original, originalText); + try { + const srcMedia = await work.getDirectoryHandle('media'); + const dstMedia = await dir.getDirectoryHandle('media', { create: true }); + for await (const [name, handle] of srcMedia.entries()) { + if (handle.kind === 'file') await writeFileTo(dstMedia, name, await handle.getFile()); + } + } catch (e) { /* no media */ } + let appState = {}; + try { + const root = await navigator.storage.getDirectory(); + const text = await readTextFrom(root, APP_STATE_FILE); + if (text !== null) appState = JSON.parse(text); + } catch (e) { /* defaults below */ } + const now = Date.now(); + await updateLibrary((lib) => { + lib.projects.push({ + id, + name: (project.texts && project.texts.title) || 'project', + starred: false, + createdAt: Date.parse(project.created) || now, + modifiedAt: appState.lastWorkWriteAt || now, + lastDraftAt: appState.lastWorkWriteAt || now, + lastSavedAt: 0, + media: { + kind: project.media.kind, + filename: project.media.filename || '', + durationSeconds: project.media.durationSeconds || 0, + }, + summary: (project.texts && project.texts.summary) || '', + topics: (project.texts && project.texts.topics) || [], + }); + }); + // the old slot is spent — remove it so this runs exactly once + await work.removeEntry('snapshot.json').catch(() => {}); + await work.removeEntry(ENTRY.original).catch(() => {}); + await work.removeEntry('media', { recursive: true }).catch(() => {}); + try { + const root = await navigator.storage.getDirectory(); + await root.removeEntry(APP_STATE_FILE).catch(() => {}); + } catch (e) { /* fine */ } + try { localStorage.removeItem('hyperaudioWorkPresent'); } catch (e) { /* retired hint */ } + } catch (e) { /* no single-slot layout (the usual case) */ } + } + + async function bootLibrary() { + if (!opfsAvailable) { + notifyLibraryChanged(false); + return; + } + // The library is the only home of unexported work now — ask the browser + // not to evict it under storage pressure. Best-effort; a denial changes + // nothing about how we behave. + try { + if (navigator.storage && navigator.storage.persist) navigator.storage.persist(); + } catch (e) { /* best-effort */ } + try { + await migrateSingleSlotWork(); + const lib = await readLibrary(); + // Most recently edited first; a corrupt head entry falls through to the + // next rather than abandoning the boot (the demo stays for none). + for (const entry of sortLibraryEntries(lib.projects)) { + if (await switchToProject(entry.id)) break; + } + } catch (e) { + console.warn('hyperaudio-save: boot restore failed, leaving demo', e); + } + notifyLibraryChanged(false); } /* ========================================================================== @@ -1384,16 +1940,23 @@ dropdown.insertAdjacentHTML('beforeend', '' + ''); - // The navbar Save button covers saving (#449), so the menu carries no - // Save item; opening a project lives with the other imports. + // The navbar Save button covers saving (#449, a silent OPFS commit since + // #456), so the menu carries no Save item; opening a project lives with + // the other imports, and Export Project is the explicit way to take a + // portable .hyperaudio out of the browser — the only save-ish download. const importList = document.querySelector('#file-exportimport-submenu ul'); if (importList !== null) { importList.insertAdjacentHTML('afterbegin', - '
  • Import Project (.hyperaudio)
  • '); + '
  • Import Project (.hyperaudio)
  • ' + + '
  • Export Project (.hyperaudio)
  • '); } else { dropdown.insertAdjacentHTML('beforeend', - '
  • Import Project (.hyperaudio)
  • '); + '
  • Import Project (.hyperaudio)
  • ' + + '
  • Export Project (.hyperaudio)
  • '); } + document.querySelector('#project-export-hyperaudio').addEventListener('click', () => { + exportProject().catch((e) => projectAlert('Exporting the project failed: ' + e.message)); + }); // Navbar Save button (#449), matching the native app's treatment exactly: // primary (Save leads the lifecycle cluster Save · Export · NEW — outline @@ -1412,7 +1975,7 @@ saveBtn.innerHTML = '' + ''; saveBtn.addEventListener('click', () => { - saveToFile().catch((e) => projectAlert('Saving the project failed: ' + e.message)); + saveProject().catch((e) => projectAlert('Saving the project failed: ' + e.message)); }); if (exportBtn !== null) navEnd.insertBefore(saveBtn, exportBtn); else navEnd.appendChild(saveBtn); @@ -1424,14 +1987,16 @@ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && (event.key === 's' || event.key === 'S')) { event.preventDefault(); - saveToFile().catch((e) => projectAlert('Saving the project failed: ' + e.message)); + saveProject().catch((e) => projectAlert('Saving the project failed: ' + e.message)); } }, true); - // The quit guard (#449): warn only when the session holds unsaved work. - // The prompt is the browser's own — beforeunload cannot show custom UI. + // The quit guard (#449), narrowed by #456: the per-project draft survives + // closes and reloads, so leaving with unsaved changes loses NOTHING and + // warning would be a lie. The one true loss case left is a document whose + // library entry was deleted and lives only on screen — guard that. window.addEventListener('beforeunload', (event) => { - if (session.active && sessionEdited) { + if (session.active && sessionEdited && (session.projectId === null || !opfsAvailable)) { event.preventDefault(); event.returnValue = ''; } @@ -1552,14 +2117,6 @@ }); } - function bootAsOwner() { - let hint = null; - try { hint = localStorage.getItem(WORK_HINT_KEY); } catch (e) { /* private mode */ } - if (opfsAvailable && hint === '1') { - restoreFromWork(); - } - } - function showTabGuardBanner() { const anchor = document.getElementById('side-notices'); if (anchor === null) return; @@ -1567,7 +2124,7 @@ const el = document.createElement('div'); el.id = 'tab-guard-banner'; el.setAttribute('role', 'status'); - el.textContent = 'Another tab is already using this editor — autosave and crash recovery are active there. You can still edit and save here.'; + el.textContent = 'This project is open in another tab — autosave and crash recovery are active there. You can still edit and save here, or switch to a different project.'; anchor.appendChild(el); } @@ -1576,41 +2133,68 @@ if (el !== null) el.remove(); } - // Acquire the working-copy slot (#450). First tab wins and boots normally; - // a later tab gets the banner, keeps editing without the slot, and QUEUES — - // when the owner closes (or crashes; locks release with the tab), the - // waiting tab is promoted: banner drops, captures enable from here on. No - // boot-restore on promotion — replacing a mid-session document would be - // worse than the recovery it offers. - function initWorkOwnership() { + // Release the current project's working-copy lock (switching away, new + // project, delete). Also abandons a queued promotion request — this tab is + // no longer interested in that project. + function releaseProjectLock() { + if (projectLockQueue !== null) { + projectLockQueue.abort(); + projectLockQueue = null; + } + if (projectLockRelease !== null) { + projectLockRelease(); + projectLockRelease = null; + } + hasProjectLock = false; + hideTabGuardBanner(); + } + + // Acquire a project's working-copy lock (#450, per-project since #456). + // First tab wins; a tab finding the project locked shows the banner, keeps + // FULL editing without the slot, and QUEUES — when the owner closes, + // crashes or switches away, the waiting tab is promoted: banner drops, + // captures enable from here on. No re-read on promotion — replacing a + // mid-session document would be worse than the recovery it offers. + function acquireProjectLock(id) { if (!('locks' in navigator)) { - hasWorkLock = true; // no Web Locks (pre-15.4 Safari): the pre-#450 status quo - bootAsOwner(); - return; + hasProjectLock = true; // no Web Locks (pre-15.4 Safari): single-tab assumption + return Promise.resolve(true); } - navigator.locks.request(WORK_LOCK, { ifAvailable: true }, (lock) => { - if (lock === null) return null; - hasWorkLock = true; - bootAsOwner(); - return new Promise(() => {}); // hold the slot for the tab's lifetime - }).then(() => { - if (hasWorkLock) return; + const lockName = PROJECT_LOCK_PREFIX + id; + return new Promise((resolve) => { + navigator.locks.request(lockName, { ifAvailable: true }, (lock) => { + if (lock === null) { + resolve(false); + return null; + } + hasProjectLock = true; + resolve(true); + return new Promise((release) => { projectLockRelease = release; }); + }).catch((e) => { + console.warn('hyperaudio-save: project lock unavailable, assuming single tab', e); + if (projectLockQueue !== null) { projectLockQueue.abort(); projectLockQueue = null; } + hasProjectLock = true; + resolve(true); + }); + }).then((granted) => { + if (granted) return true; showTabGuardBanner(); - return navigator.locks.request(WORK_LOCK, () => { - hasWorkLock = true; + projectLockQueue = new AbortController(); + navigator.locks.request(lockName, { signal: projectLockQueue.signal }, () => { + projectLockQueue = null; + if (session.projectId !== id) return null; // switched away as the grant raced the abort + hasProjectLock = true; hideTabGuardBanner(); - return new Promise(() => {}); // promoted: hold from here on - }); - }).catch((e) => { - console.warn('hyperaudio-save: work lock unavailable, assuming single tab', e); - if (!hasWorkLock) { hasWorkLock = true; bootAsOwner(); } + return new Promise((release) => { projectLockRelease = release; }); + }).catch(() => { /* aborted: switched away before promotion */ }); + return false; }); } function boot() { injectUi(); wireCapture(); - initWorkOwnership(); + bootLibrary(); maybeShowLegacyNotice(); } @@ -1656,13 +2240,29 @@ } window.HyperaudioSave = { - saveToFile, + saveProject, // silent OPFS commit (⌘S / the navbar button) + exportProject, // build + download a portable .hyperaudio // export naming and any future UI read the title through here getProjectTitle: () => session.title || (session.mediaFile !== null ? session.mediaFile.name : '') || '', openFromFile, - autosaveNow: writeWorkSnapshot, + autosaveNow: writeDraft, isDirty, opfsAvailable, + // The project library (#456) — everything the side panel + // (hyperaudio-library.js) needs; re-renders ride the + // 'hyperaudioLibraryChanged' document event. + library: { + list: async () => sortLibraryEntries((await readLibrary()).projects), + currentId: () => session.projectId, + ownsCurrent: () => hasProjectLock, + isEntryDirty, + open: switchToProject, + rename: renameProject, + setStarred: setProjectStarred, + duplicate: duplicateProject, + remove: deleteProject, + restoreDeleted: restoreCurrentAsNewProject, + }, }; if (document.readyState === 'loading') {