diff --git a/__TEST__/e2e/a11y.spec.mjs b/__TEST__/e2e/a11y.spec.mjs index 4964fefe..69cb0f40 100644 --- a/__TEST__/e2e/a11y.spec.mjs +++ b/__TEST__/e2e/a11y.spec.mjs @@ -42,13 +42,7 @@ test.beforeEach(async ({ page }) => { await page.waitForSelector('#hypertranscript [data-m]'); }); -test('default view (with Recents content) has no #402-class violations', async ({ page }) => { - // populate the Recents list both ways storage.js renders it - await page.evaluate(() => { - const fp = document.getElementById('file-picker'); - fp.insertAdjacentHTML('beforeend', `
  • my-project
  • `); - fp.insertAdjacentHTML('beforeend', `
  • No files saved.
  • `); - }); +test('default view has no #402-class violations', async ({ page }) => { expect(await runAxe(page)).toEqual([]); }); diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs new file mode 100644 index 00000000..6e7d378d --- /dev/null +++ b/__TEST__/e2e/project-save.spec.mjs @@ -0,0 +1,393 @@ +// .hyperaudio project save/open (js/hyperaudio-save.js; spec: docs/format/). +// Drives the shipped editor end to end: opens a conformant container built +// with the module's own pure layers, checks that transcript (redactions +// included), captions, options and texts land in the editor; downloads a save +// and verifies the container; reloads and checks the OPFS working-copy restore. +import { test, expect } from '@playwright/test'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import { ladderWav } from './helpers.mjs'; + +const require = createRequire(import.meta.url); +const save = require('../../js/hyperaudio-save.js'); +const JSZip = require('jszip'); + +const FIXTURE_VTT = 'WEBVTT\n\n00:00:00.320 --> 00:00:01.500\nBenvenuti a Hyperaudio\n'; + +async function buildFixture() { + 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: true, thresholdMs: 700, bufferMs: 150 }, + updateCaptionsFromTranscript: false, + view: { showSpeakers: true, showTimecodes: false }, + }, + texts: { title: 'E2E Project', language: 'it', summary: 'summary text', topics: ['e2e'] }, + provenance: { engine: 'deepgram', model: 'nova-3', transcribedAt: '2026-07-10T08:55:00Z' }, + hasOriginal: true, + transcript: { + words: [ + { start: 0.32, end: 0.84, text: 'Benvenuti' }, + { start: 0.84, end: 1.02, text: 'ehm', struck: true }, + { 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

    ', + originalJson: JSON.stringify({ words: [{ start: 0.32, end: 0.84, text: 'benvenuti' }], paragraphs: [] }), + captionsVtt: FIXTURE_VTT, + media: { name: 'tone.wav', data: ladderWav(2) }, + }, JSZip, 'nodebuffer'); +} + +// Open the fixture in the live page via the module's hidden input; collect any +// native dialogs (a conformant open must produce none). +async function openFixture(page, testInfo, dialogs) { + const fixturePath = testInfo.outputPath('fixture.hyperaudio'); + fs.writeFileSync(fixturePath, await buildFixture()); + page.on('dialog', (dialog) => { + dialogs.push(dialog.message()); + dialog.accept(); + }); + await page.setInputFiles('#project-open-input', fixturePath); + await expect(page.locator('#hypertranscript')).toContainText('Benvenuti'); +} + +// The module's designed dialog (replaces native alert/confirm): its visible +// message text, or null when closed. +const projectModal = (page) => page.evaluate(() => { + const el = document.getElementById('project-dialog'); + return el !== null && el.classList.contains('modal-open') + ? el.querySelector('#project-dialog-message').textContent + : null; +}); +const awaitModal = (page) => page.waitForFunction(() => { + const el = document.getElementById('project-dialog'); + return el !== null && el.classList.contains('modal-open'); +}); + +test.beforeEach(async ({ page }) => { + await page.goto('/index.html'); + await page.waitForSelector('#hypertranscript [data-m]'); +}); + +test('save button, import menu item, and hidden input are injected', async ({ page }) => { + // Save lives in the navbar (primary, before the export button), not the menu + await expect(page.locator('#project-save-btn')).toHaveCount(1); + const order = await page.evaluate(() => { + const btn = document.getElementById('project-save-btn'); + return btn.nextElementSibling && btn.nextElementSibling.id; + }); + expect(order).toBe('export-media-btn'); + await expect(page.locator('#file-exportimport-submenu #project-open-hyperaudio')).toHaveText('Import Project (.hyperaudio)'); + await expect(page.locator('#project-open-input')).toHaveCount(1); +}); + +test('opening a .hyperaudio lands transcript, redaction, captions, options and texts', async ({ page }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); + + // transcript: words as spans, the redacted word struck out + const struck = page.locator('#hypertranscript span[data-m="840"]'); + await expect(struck).toHaveText('ehm '); + await expect(struck).toHaveCSS('text-decoration-line', 'line-through'); + await expect(page.locator('#hypertranscript .speaker')).toHaveText('[Maria] '); + + // media: playing from the embedded file (object URL, not the demo source) + const src = await page.evaluate(() => document.querySelector('#hyperplayer').src); + expect(src).toMatch(/^blob:/); + + // captions: the saved VTT is on the track (curated — updateFromTranscript false) + const trackSrc = await page.evaluate(() => document.querySelector('#hyperplayer-vtt').src); + expect(decodeURIComponent(trackSrc.split(',')[1])).toContain('Benvenuti a Hyperaudio'); + + // options and texts (the title has no UI field until #449 — it lives in the + // session and is asserted through the save round-trip in the next test) + await expect(page.locator('#remove-gaps-enabled')).toBeChecked(); + await expect(page.locator('#remove-gaps-threshold')).toHaveValue('700'); + await expect(page.locator('#summary')).toHaveText('summary text'); + + expect(dialogs).toEqual([]); // a conformant file opens without any alert +}); + +test('saving downloads a conformant container that round-trips', async ({ page }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); + + const downloadPromise = page.waitForEvent('download'); + await page.evaluate(() => document.getElementById('project-save-btn').click()); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('E2E Project.hyperaudio'); + + const savedPath = testInfo.outputPath('saved.hyperaudio'); + await download.saveAs(savedPath); + const buf = fs.readFileSync(savedPath); + + // mimetype-first convention: the MIME type is readable at byte offset 38 + expect(buf.toString('ascii', 30, 38)).toBe('mimetype'); + expect(buf.toString('utf8', 38, 38 + save.CONTAINER_MIMETYPE.length)).toBe(save.CONTAINER_MIMETYPE); + + const loaded = await save.unzipProject(new Uint8Array(buf), JSZip); + expect(loaded.recovered).toBe(false); + expect(loaded.project.texts.title).toBe('E2E Project'); + expect(loaded.project.media.filename).toBe('tone.wav'); + 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 origin travelled along, untouched and struck-free + expect(JSON.parse(loaded.originalText).words[0].text).toBe('benvenuti'); + expect(loaded.captionsVtt).toContain('Benvenuti a Hyperaudio'); + expect(loaded.project.provenance.originalTranscript).toBe('transcript.original.json'); +}); + +test('the working copy survives a reload (OPFS restore)', async ({ page }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); + + // the open seeds OPFS and sets the synchronous boot hint + await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === '1'); + + await page.reload(); + await page.waitForSelector('#hypertranscript [data-m]'); + + // 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'); + 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 + const downloadPromise = page.waitForEvent('download'); + await page.evaluate(() => document.getElementById('project-save-btn').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) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'DIRTY '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + // open the fixture again over the dirty project + 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 }); + + 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/); +}); + +test('an unopenable file is refused BEFORE the replace-confirmation, 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 + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'EDITED '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.waitForTimeout(2000); // let the autosave land (dirty = work > download) + + // a non-conforming container: compressed media entry + const badPath = testInfo.outputPath('bad.hyperaudio'); + const JSZipLocal = new JSZip(); + const buf = await buildFixture(); + const src = await JSZip.loadAsync(buf); + for (const name of Object.keys(src.files)) { + if (src.files[name].dir) continue; + const data = await src.files[name].async('uint8array'); + JSZipLocal.file(name, data, name.startsWith('media/') + ? { compression: 'DEFLATE' } + : { compression: name === 'mimetype' ? 'STORE' : 'DEFLATE' }); + } + fs.writeFileSync(badPath, await JSZipLocal.generateAsync({ type: 'nodebuffer' })); + + await page.evaluate(() => { document.getElementById('project-open-input').value = ''; }); + await page.setInputFiles('#project-open-input', badPath); + await awaitModal(page); + + // the refusal — designed modal, no native dialog, never the replace-question + expect(dialogs).toEqual([]); + const text = await projectModal(page); + expect(text).toContain('media compressed'); + expect(text).not.toContain('REPLACE'); + await page.click('#project-dialog-confirm'); + expect(await projectModal(page)).toBeNull(); + // and the dirty project is untouched + await expect(page.locator('#hypertranscript')).toContainText('EDITED'); +}); + +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'); + + // the round trip that REPLACES #hypertranscript — direct listeners died here + await page.click('#caption-editor-btn'); + await page.waitForTimeout(400); + 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; + }); + + // an edit on the REPLACED transcript element must still reach the autosave + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + 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'); +}); + +test('Save button: dirty dot appears on edit, click saves and clears it (#449)', async ({ page }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); + 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 '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await expect(page.locator('#project-save-btn')).toHaveClass(/dirty/); + + const downloadPromise = page.waitForEvent('download'); + await page.click('#project-save-btn'); + expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); + await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); +}); + +test('Ctrl/⌘-S saves with the project title (#449)', async ({ page }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); + const downloadPromise = page.waitForEvent('download'); + await page.keyboard.press('Control+s'); + expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); +}); + +test('the native bridge intercepts the save instead of a download (#449)', async ({ page }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); + await page.evaluate(() => { + window.__bridgeSaved = null; + window.hyperaudioProjectBridge = { + save(blob, name) { window.__bridgeSaved = { size: blob.size, name }; return true; }, + }; + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'BRIDGED '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.click('#project-save-btn'); + await page.waitForFunction(() => window.__bridgeSaved !== null); + const saved = await page.evaluate(() => window.__bridgeSaved); + expect(saved.name).toBe('E2E Project.hyperaudio'); + expect(saved.size).toBeGreaterThan(1000); + 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. + const dialogs = []; + await openFixture(page, testInfo, dialogs); + 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]'); + span.textContent = 'UNSAVED '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(await armed()).toBe(true); // dirty: leaving would prompt + + const downloadPromise = page.waitForEvent('download'); + await page.click('#project-save-btn'); + await downloadPromise; + expect(await armed()).toBe(false); // saved: leaving is silent again +}); + +test('a second tab is guarded: banner, no slot writes, promotion on owner close (#450)', 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 () => { + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle('work'); + return (await (await dir.getFileHandle('snapshot.json')).getFile()).text(); + }); + + // 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 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]'); + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page2.waitForTimeout(2200); // outlive the autosave debounce + const afterSnapshot = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle('work'); + return (await (await dir.getFileHandle('snapshot.json')).getFile()).text(); + }); + 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); + await page2.close(); +}); diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs deleted file mode 100644 index 6873ec1b..00000000 --- a/__TEST__/e2e/storage.spec.mjs +++ /dev/null @@ -1,518 +0,0 @@ -// Storage picker regression net (#410) + the Recents storage model (#434): -// saved projects are referenced by KEY STRING, not by storage.key(i) position -// — positional indices shift whenever any other module writes a key (the -// transcribe prefs do so on every toggle), which loaded the wrong entry or -// threw. Corrupted entries must not kill the click handler, and filenames must -// render as text, not markup. Since #434, entries are keyed by stable ID with -// the display name in meta (legacy name-keyed entries migrate on first list -// render), rows can be renamed inline and deleted (two-step), and the list -// orders by last-updated. -import { test, expect } from '@playwright/test'; - -const seed = (page) => page.evaluate(() => { - localStorage.clear(); - const entry = (text) => JSON.stringify({ - hypertranscript: `

    ${text}

    `, - video: 'https://example.com/a.mp3', - summary: 's', topics: [], - }); - localStorage.setItem('alpha.hyperaudio', entry('ALPHA')); - localStorage.setItem('beta.hyperaudio', entry('BETA')); - loadLocalStorageOptions(); -}); - -test.beforeEach(async ({ page }) => { - await page.goto('/index.html'); - await page.waitForSelector('#hypertranscript [data-m]'); -}); - -test('clicking a file loads that file even after other keys shift the order (#410)', async ({ page }) => { - await seed(page); - // shift the key landscape AFTER the list rendered — this is what the - // prefs/export modules do at arbitrary times - await page.evaluate(() => { - localStorage.setItem('aaa-unrelated', 'x'); - localStorage.removeItem('aaa-unrelated'); - localStorage.setItem('hyperaudioTranscribePrefs', '{"serviceMode":"local"}'); - }); - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); - }); - await page.waitForTimeout(300); - const loaded = await page.evaluate(() => document.querySelector('#hypertranscript').textContent); - expect(loaded).toContain('BETA'); - expect(loaded).not.toContain('ALPHA'); -}); - -test('a corrupted entry does not throw and the picker keeps working (#410)', async ({ page }) => { - await seed(page); - const errors = []; - page.on('pageerror', (e) => errors.push(e.message)); - await page.evaluate(() => { - localStorage.setItem('broken.hyperaudio', '{not json'); - loadLocalStorageOptions(); - }); - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'broken').click(); - }); - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'alpha').click(); - }); - await page.waitForTimeout(300); - expect(errors).toEqual([]); - expect(await page.evaluate(() => document.querySelector('#hypertranscript').textContent)).toContain('ALPHA'); -}); - -test('legacy name-keyed entries migrate to stable ID keys on first list render (#434)', async ({ page }) => { - await seed(page); - const r = await page.evaluate(() => ({ - legacyKeys: Object.keys(localStorage).filter((k) => k.endsWith('.hyperaudio')), - docKeys: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')), - names: [...document.querySelectorAll('.file-item')].map((a) => a.textContent).sort(), - })); - expect(r.legacyKeys).toEqual([]); - expect(r.docKeys.length).toBe(2); - expect(r.names).toEqual(['alpha', 'beta']); -}); - -test('rows order by last edit, falling back to creation date; migrated entries carry a date (#434)', async ({ page }) => { - await seed(page); // alpha + beta migrate with created = migration time - await page.evaluate(() => { - const entry = (name, meta) => JSON.stringify({ - hypertranscript: '

    x

    ', - video: 'https://example.com/a.mp3', summary: 's', topics: [], - meta: Object.assign({ name }, meta), - }); - const now = Date.now(); - localStorage.setItem('hyperaudio:doc:t1', entry('edited-recently', { updated: now + 100000 })); - localStorage.setItem('hyperaudio:doc:t2', entry('created-recently', { created: now + 50000 })); // never edited - loadLocalStorageOptions(); - }); - const names = await page.evaluate(() => - [...document.querySelectorAll('.file-item')].map((a) => a.textContent)); - // migrated alpha/beta share a creation stamp → tie broken alphabetically - expect(names).toEqual(['edited-recently', 'created-recently', 'alpha', 'beta']); -}); - -test('rename via the row action edits the name in place; the key is untouched (#434)', async ({ page }) => { - await seed(page); - const keysBefore = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).sort()); - const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); - await row.hover(); - await row.locator('.recents-kebab').click(); - await page.locator('#recents-menu .recents-menu-rename').click(); - const input = page.locator('.recents-rename-input'); - await input.fill('interview notes'); - await input.press('Enter'); - await expect(page.locator('.file-item', { hasText: 'interview notes' })).toHaveCount(1); - const after = await page.evaluate(() => ({ - keys: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).sort(), - names: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')) - .map((k) => JSON.parse(localStorage.getItem(k)).meta.name).sort(), - })); - expect(after.keys).toEqual(keysBefore); - expect(after.names).toEqual(['beta', 'interview notes']); -}); - -test('escape cancels a rename without changing the name (#434)', async ({ page }) => { - await seed(page); - const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); - await row.hover(); - await row.locator('.recents-kebab').click(); - await page.locator('#recents-menu .recents-menu-rename').click(); - const input = page.locator('.recents-rename-input'); - await input.fill('should-not-stick'); - await input.press('Escape'); - const names = await page.evaluate(() => - [...document.querySelectorAll('.file-item')].map((a) => a.textContent).sort()); - expect(names).toEqual(['alpha', 'beta']); -}); - -test('delete is two-step and removes the entry (#434)', async ({ page }) => { - await seed(page); - const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); - await row.hover(); - await row.locator('.recents-kebab').click(); - const del = page.locator('#recents-menu .recents-menu-delete'); - await del.click(); - // armed, not deleted - await expect(del).toHaveText('Delete?'); - expect(await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(2); - await del.click(); - await expect(page.locator('.file-item', { hasText: 'alpha' })).toHaveCount(0); - await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(1); - expect(await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(1); -}); - -test('deleting the loaded entry offers Restore; restoring re-saves the on-screen doc (#434)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); - }); - await page.waitForTimeout(300); - const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); - await row.hover(); - await row.locator('.recents-kebab').click(); - const del = page.locator('#recents-menu .recents-menu-delete'); - await del.click(); - await del.click(); // confirm - await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(0); - const notice = page.locator('#recents-notice'); - await expect(notice).toContainText('no longer being saved'); - // the transcript is still on screen - expect(await page.evaluate(() => document.querySelector('#hypertranscript').textContent)).toContain('BETA'); - await notice.locator('.recents-notice-action').click(); - await expect(notice).toHaveCount(0); - await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(1); - await expect(page.locator('.file-item.active')).toHaveText('beta'); - const restored = await page.evaluate(() => { - const key = Object.keys(localStorage).find((k) => - k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta'); - return JSON.parse(localStorage.getItem(key)); - }); - expect(restored.hypertranscript).toContain('BETA'); -}); - -test('a pending Restore is withdrawn when the screen holds a different document (#434)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); - }); - await page.waitForTimeout(300); - const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); - await row.hover(); - await row.locator('.recents-kebab').click(); - const del = page.locator('#recents-menu .recents-menu-delete'); - await del.click(); - await del.click(); - await expect(page.locator('#recents-notice')).toContainText('no longer being saved'); - // loading another entry invalidates restore-from-screen - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'alpha').click(); - }); - await expect(page.locator('#recents-notice')).toHaveCount(0); -}); - -test('a very long name truncates with ellipsis instead of pushing the actions off-screen (#436)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - localStorage.setItem('hyperaudio:doc:long', JSON.stringify({ - hypertranscript: '

    x

    ', - video: 'https://example.com/a.mp3', summary: '', topics: [], - meta: { name: 'A_really_long_interview_' + 'x'.repeat(80) + '.mp4', updated: 9999 }, - })); - loadLocalStorageOptions(); - }); - const r = await page.evaluate(() => { - const picker = document.querySelector('#file-picker').getBoundingClientRect(); - const name = [...document.querySelectorAll('.file-item')] - .find((a) => a.textContent.startsWith('A_really_long_interview_')); - const actions = name.closest('.recents-row').querySelector('.recents-actions').getBoundingClientRect(); - return { - pickerVisible: picker.width > 0, - actionsInside: actions.right <= picker.right + 1, - truncated: name.scrollWidth > name.clientWidth, - }; - }); - expect(r.pickerVisible).toBe(true); - expect(r.actionsInside).toBe(true); - expect(r.truncated).toBe(true); -}); - -test('the row Duplicate action copies an entry with a suffixed name, original untouched (#436)', async ({ page }) => { - await seed(page); - const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); - await row.hover(); - await row.locator('.recents-kebab').click(); - await page.locator('#recents-menu .recents-menu-duplicate').click(); - const names = await page.evaluate(() => - [...document.querySelectorAll('.file-item')].map((a) => a.textContent)); - expect(names[0]).toBe('beta (2)'); // fresh stamps put the copy on top - expect(names).toContain('beta'); - expect(names).toContain('alpha'); -}); - -test('editing a never-saved document (the demo) auto-creates its Recents entry (#436)', async ({ page }) => { - await page.evaluate(() => { localStorage.clear(); loadLocalStorageOptions(); }); - await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); - span.textContent = 'DEMO-EDIT '; - span.dispatchEvent(new Event('input', { bubbles: true })); - }); - await page.waitForTimeout(2600); - const saved = await page.evaluate(() => { - const keys = Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')); - return { count: keys.length, entry: keys.length ? JSON.parse(localStorage.getItem(keys[0])) : null }; - }); - expect(saved.count).toBe(1); - expect(saved.entry.hypertranscript).toContain('DEMO-EDIT'); -}); - -test('a pending Restore suppresses auto-create; dismissing it re-enables (#436)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); - }); - await page.waitForTimeout(300); - const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); - await row.hover(); - await row.locator('.recents-kebab').click(); - const del = page.locator('#recents-menu .recents-menu-delete'); - await del.click(); - await del.click(); // confirm — Restore offer now pending - const edit = () => page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); - span.textContent = 'EDIT-AFTER-DELETE '; - span.dispatchEvent(new Event('input', { bubbles: true })); - }); - await edit(); - await page.waitForTimeout(2600); - // deleted on purpose: the edit must NOT silently recreate the entry - expect(await page.evaluate(() => - Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(1); - await page.locator('#recents-notice .recents-notice-dismiss').click(); - await edit(); - await page.waitForTimeout(2600); - // offer declined: the doc is now just an unsaved document — edits re-enter Recents - expect(await page.evaluate(() => - Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(2); -}); - -test('starring pins an entry into a Starred group; unstarring removes the labels (#440)', async ({ page }) => { - await seed(page); - const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); - await row.hover(); - await row.locator('.recents-kebab').click(); - await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Star'); - await page.locator('#recents-menu .recents-menu-star').click(); - - // grouped: Starred heading, beta, Recents heading, alpha — and the panel's - // static "Recents" h2 hides while the in-list h2 headings are shown - await expect(page.locator('.recents-group-heading h2')).toHaveText(['Starred', 'Recents']); - await expect(page.locator('#recents-title')).toBeHidden(); - const order = await page.evaluate(() => - [...document.querySelectorAll('#file-picker li')].map((li) => li.textContent.trim())); - expect(order[0]).toBe('Starred'); - expect(order[1]).toContain('beta'); - expect(order[2]).toBe('Recents'); - expect(order[3]).toContain('alpha'); - - // autosave-style re-save must carry the star through the meta rebuild - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); - }); - await page.waitForTimeout(300); - await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); - span.textContent = 'STAR-EDIT '; - span.dispatchEvent(new Event('input', { bubbles: true })); - }); - await page.waitForTimeout(2600); - const starredAfterSave = await page.evaluate(() => { - const key = Object.keys(localStorage).find((k) => - k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta'); - return JSON.parse(localStorage.getItem(key)).meta.starred; - }); - expect(starredAfterSave).toBe(true); - - // unstar → flat list again, no labels - const starredRow = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); - await starredRow.hover(); - await starredRow.locator('.recents-kebab').click(); - await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Unstar'); - await page.locator('#recents-menu .recents-menu-star').click(); - await expect(page.locator('.recents-group-heading')).toHaveCount(0); - await expect(page.locator('#recents-title')).toBeVisible(); // default look restored -}); - -test('clicking a row loads it and marks it active; the highlight survives a re-render (#434)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); - }); - await page.waitForTimeout(300); - await expect(page.locator('.file-item.active')).toHaveText('beta'); - await page.evaluate(() => loadLocalStorageOptions()); - await expect(page.locator('.file-item.active')).toHaveText('beta'); -}); - -test('a new transcription auto-saves to Recents named after its media; repeats get a suffix (#435)', async ({ page }) => { - await seed(page); - const transcribe = () => page.evaluate(() => { - document.querySelector('#hyperplayer').src = 'https://example.com/media/clip.mp4'; - document.querySelector('#hypertranscript').innerHTML = - '

    FRESH

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

    X

    '; - document.dispatchEvent(new CustomEvent('hyperaudioInit')); - }); - await transcribe(); - const notice = page.locator('#recents-notice'); - await expect(notice).toHaveClass(/notice-info/); - await expect(notice).toContainText('on this device only'); - await notice.locator('.recents-notice-dismiss').click(); - await expect(notice).toHaveCount(0); - await transcribe(); - await expect(notice).toHaveCount(0); // never again -}); - -test('auto-add never captures the previous document\'s captions/summary/topics (#435)', async ({ page }) => { - await seed(page); - const saved = await page.evaluate(() => { - // the state the engines leave at hyperaudioInit time: the caption track, - // summary, and topics still belong to the PREVIOUS document (the intro - // demo on a fresh session) — regeneration happens after the event - document.getElementById('hyperplayer-vtt').src = - 'data:text/vtt;charset=utf-8,' + encodeURIComponent('WEBVTT\n\n00:00.000 --> 00:01.000\nSTALE DEMO CUE'); - document.getElementById('summary').innerHTML = 'stale demo summary'; - document.getElementById('topics').innerHTML = 'stale, demo, topics'; - document.querySelector('#hyperplayer').src = 'https://example.com/media/clip.mp4'; - document.querySelector('#hypertranscript').innerHTML = - '

    FRESH

    '; - document.dispatchEvent(new CustomEvent('hyperaudioInit')); - const key = Object.keys(localStorage).find((k) => - k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'clip.mp4'); - return JSON.parse(localStorage.getItem(key)); - }); - expect(saved.captions).toBeUndefined(); // load regenerates from the transcript instead - expect(saved.summary).toBe(''); - expect(saved.topics).toEqual([]); - expect(saved.hypertranscript).toContain('FRESH'); -}); - -test('edits autosave (debounced) to the active entry and bump its updated stamp (#435)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); - }); - await page.waitForTimeout(300); - const before = await page.evaluate(() => { - const key = Object.keys(localStorage).find((k) => - k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta'); - return { key, updated: JSON.parse(localStorage.getItem(key)).meta.updated || 0 }; - }); - await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); - span.textContent = 'EDITED '; - span.dispatchEvent(new Event('input', { bubbles: true })); - }); - await page.waitForTimeout(2600); // past the 2s debounce - const after = await page.evaluate((key) => JSON.parse(localStorage.getItem(key)), before.key); - expect(after.hypertranscript).toContain('EDITED'); - expect(after.meta.name).toBe('beta'); // autosave keeps the name - expect(after.meta.updated).toBeGreaterThan(before.updated); -}); - -test('a Recents load restores the media reference for the interactive export (#426)', async ({ page }) => { - await seed(page); - // a local-media doc: video is the indexeddb: marker, the blob is cached - // under meta.mediaKey, and meta.mediaRef holds the original filename - await page.evaluate(() => new Promise((resolve) => { - const open = indexedDB.open('hyperaudioMedia', 1); - open.onupgradeneeded = () => open.result.createObjectStore('media'); - open.onsuccess = () => { - const tx = open.result.transaction('media', 'readwrite'); - tx.objectStore('media').put('data:audio/mp3;base64,AAAA', 'm-key'); - tx.oncomplete = () => resolve(); - }; - })); - await page.evaluate(() => { - localStorage.setItem('hyperaudio:doc:localdoc', JSON.stringify({ - hypertranscript: '

    LOCAL

    ', - video: 'indexeddb:', summary: '', topics: [], - meta: { name: 'my doc', mediaKey: 'm-key', mediaRef: 'clip.mp4', updated: 5000 }, - })); - loadLocalStorageOptions(); - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'my doc').click(); - }); - await page.waitForTimeout(400); - const r = await page.evaluate(() => { - const modal = document.getElementById('interactive-export-modal'); - modal.checked = true; - modal.dispatchEvent(new Event('change')); - return { - stamped: document.getElementById('hyperplayer').dataset.mediaRef, - dialogValue: document.getElementById('interactive-media-filename').value, - playerSrc: document.getElementById('hyperplayer').src, - }; - }); - expect(r.stamped).toBe('clip.mp4'); - expect(r.dialogValue).toBe('clip.mp4'); - expect(r.playerSrc.startsWith('data:')).toBe(true); // the cached media loaded - - // autosave must carry the reference through the meta rebuild - await page.evaluate(() => { - const span = document.querySelector('#hypertranscript span[data-m]'); - span.textContent = 'LOCAL-EDIT '; - span.dispatchEvent(new Event('input', { bubbles: true })); - }); - await page.waitForTimeout(2600); - expect(await page.evaluate(() => - JSON.parse(localStorage.getItem('hyperaudio:doc:localdoc')).meta.mediaRef)).toBe('clip.mp4'); -}); - -test('a pre-mediaRef entry whose name is still the media filename backfills the reference (#426)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - localStorage.setItem('hyperaudio:doc:old', JSON.stringify({ - hypertranscript: '

    OLD

    ', - video: 'indexeddb:', summary: '', topics: [], - meta: { name: 'clapper-march-13.mp4', mediaKey: 'nope', updated: 5000 }, // no mediaRef - })); - loadLocalStorageOptions(); - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'clapper-march-13.mp4').click(); - }); - await page.waitForTimeout(300); - const r = await page.evaluate(() => { - const modal = document.getElementById('interactive-export-modal'); - modal.checked = true; - modal.dispatchEvent(new Event('change')); - return document.getElementById('interactive-media-filename').value; - }); - expect(r).toBe('clapper-march-13.mp4'); -}); - -test('an entry predating mediaRef clears the stamp instead of offering stale media (#426)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - document.getElementById('hyperplayer').dataset.mediaRef = 'stale-previous.mp4'; - [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'alpha').click(); - }); - await page.waitForTimeout(300); - expect(await page.evaluate(() => document.getElementById('hyperplayer').dataset.mediaRef)).toBeUndefined(); -}); - -test('a filename containing markup renders as text (#410)', async ({ page }) => { - await seed(page); - await page.evaluate(() => { - localStorage.setItem('.hyperaudio', localStorage.getItem('alpha.hyperaudio')); - loadLocalStorageOptions(); - }); - const r = await page.evaluate(() => ({ - xss: window.__xss === 1, - itemTexts: [...document.querySelectorAll('.file-item')].map((a) => a.textContent), - imgInPicker: document.querySelector('#file-picker img') !== null, - })); - expect(r.xss).toBe(false); - expect(r.imgInPicker).toBe(false); - expect(r.itemTexts).toContain(''); -}); diff --git a/__TEST__/unit/hyperaudio-save.test.mjs b/__TEST__/unit/hyperaudio-save.test.mjs new file mode 100644 index 00000000..74511544 --- /dev/null +++ b/__TEST__/unit/hyperaudio-save.test.mjs @@ -0,0 +1,344 @@ +// Unit tests for the .hyperaudio save format (spec: issue #403). +// Exercises the pure FORMAT and CONTAINER layers of js/hyperaudio-save.js — +// version rules, validation, container round-trip, whitelist-read and the +// mimetype-first convention — plus the struck round-trip in the converter. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const save = require('../../js/hyperaudio-save.js'); +const { jsonToHTML } = require('../../js/html-json-converter.js'); +const JSZip = require('jszip'); + +function sampleTranscript() { + return { + words: [ + { start: 0.32, end: 0.84, text: 'Benvenuti' }, + { start: 0.84, end: 1.02, text: 'ehm', struck: true }, + { start: 1.1, end: 1.3, text: 'a' }, + ], + paragraphs: [{ speaker: 'Maria', start: 0.32, end: 6.5 }], + }; +} + +function sampleState() { + return { + generatorVersion: '0.8.2', + created: '2026-07-10T09:00:00Z', + modified: '2026-07-10T11:30:00Z', + media: { + kind: 'original', path: 'media/test.mp4', url: null, filename: 'test.mp4', + mimeType: 'video/mp4', durationSeconds: 62.5, sizeBytes: 4, + }, + options: { + gapRemoval: { enabled: true, thresholdMs: 500, bufferMs: 100 }, + updateCaptionsFromTranscript: false, + view: { showSpeakers: true, showTimecodes: false }, + }, + texts: { title: 'Intervista', language: 'it', summary: 'riassunto', topics: ['hyperaudio'] }, + provenance: { engine: 'deepgram', model: 'nova-3', transcribedAt: '2026-07-10T08:55:00Z' }, + hasOriginal: true, + transcript: sampleTranscript(), + }; +} + +/* ---------- FORMAT ---------- */ + +test('checkFormatVersion: accepts same-major, rejects higher major and malformed', () => { + assert.equal(save.checkFormatVersion('1.0').ok, true); + assert.equal(save.checkFormatVersion('1.7').ok, true); // future minor: ignore-unknown + assert.deepEqual(save.checkFormatVersion('2.0').code, 'version-major'); + assert.equal(save.checkFormatVersion('banana').code, 'version-malformed'); + assert.equal(save.checkFormatVersion(1.0).code, 'version-malformed'); + assert.equal(save.checkFormatVersion('1.0.3').code, 'version-malformed'); +}); + +test('validateMediaPath: one segment under media/, no traversal, no absolutes', () => { + assert.equal(save.validateMediaPath('media/video.mp4'), true); + assert.equal(save.validateMediaPath('media/città è.mp4'), true); + assert.equal(save.validateMediaPath('media/../evil'), false); + assert.equal(save.validateMediaPath('media/sub/dir.mp4'), false); + assert.equal(save.validateMediaPath('/etc/passwd'), false); + assert.equal(save.validateMediaPath('media\\evil'), false); + assert.equal(save.validateMediaPath('other/file.mp4'), false); + assert.equal(save.validateMediaPath(null), false); +}); + +test('buildProjectJson: complete shape; provenance carries originalTranscript', () => { + const project = save.buildProjectJson(sampleState()); + assert.equal(project.format, 'hyperaudio'); + assert.equal(project.formatVersion, save.FORMAT_VERSION); + assert.equal(project.media.filename, 'test.mp4'); + assert.equal(project.options.captions.updateFromTranscript, false); + assert.equal(project.texts.title, 'Intervista'); + assert.equal(project.provenance.originalTranscript, 'transcript.original.json'); + assert.equal(project.transcript.words[1].struck, true); +}); + +test('buildProjectJson: provenance omitted when unknown', () => { + const state = sampleState(); + state.provenance = null; + const project = save.buildProjectJson(state); + assert.equal(project.provenance, undefined); +}); + +test('validateProjectJson: accepts a conformant project', () => { + const result = save.validateProjectJson(save.buildProjectJson(sampleState())); + assert.deepEqual(result, { ok: true, errors: [] }); +}); + +test('validateProjectJson: flags unknown kind, bad path, bad words', () => { + const good = () => save.buildProjectJson(sampleState()); + + let p = good(); + p.media.kind = 'hologram'; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'media-kind')); + + p = good(); + p.media.path = 'media/../evil.mp4'; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'media-path')); + + p = good(); + p.transcript.words[0].end = -1; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'transcript')); + + p = good(); + delete p.format; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'format')); +}); + +test('validateProjectJson: link kind needs an http(s) url, and nothing else', () => { + const p = save.buildProjectJson(sampleState()); + p.media = { kind: 'link', path: null, url: 'https://example.org/media.mp3', filename: '', mimeType: '', durationSeconds: 62.5, sizeBytes: 0 }; + assert.deepEqual(save.validateProjectJson(p), { ok: true, errors: [] }); + + p.media.url = 'file:///etc/passwd'; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'media')); + + p.media.url = null; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'media')); +}); + +/* ---------- converter: struck round-trip (writer side) ---------- */ + +test('jsonToHTML: struck word carries the line-through style, others do not', () => { + const html = jsonToHTML(sampleTranscript()); + assert.match(html, /ehm <\/span>/); + assert.match(html, /Benvenuti <\/span>/); +}); + +/* ---------- CONTAINER ---------- */ + +function sampleFiles() { + const project = save.buildProjectJson(sampleState()); + return { + json: save.serializeProjectJson(project), + html: '

    Benvenuti

    ', + originalJson: JSON.stringify({ words: [{ start: 0.32, end: 0.84, text: 'benvenuti' }], paragraphs: [] }), + captionsVtt: 'WEBVTT\n\n00:00:00.320 --> 00:00:03.100\nBenvenuti a Hyperaudio\n', + media: { name: 'test.mp4', data: new Uint8Array([1, 2, 3, 4]) }, + }; +} + +test('container: mimetype is the first entry, stored, at fixed offset 38', async () => { + const out = await save.zipProject(sampleFiles(), JSZip, 'uint8array'); + const buf = Buffer.from(out); + assert.equal(buf.readUInt32LE(0), 0x04034b50); // local file header signature + assert.equal(buf.toString('ascii', 30, 38), 'mimetype'); + assert.equal( + buf.toString('utf8', 38, 38 + save.CONTAINER_MIMETYPE.length), + save.CONTAINER_MIMETYPE, + ); +}); + +test('container: round-trip preserves project, media bytes, captions, origin', async () => { + const out = await save.zipProject(sampleFiles(), JSZip, 'uint8array'); + const loaded = await save.unzipProject(out, JSZip); + + assert.equal(loaded.recovered, false); + assert.equal(loaded.project.texts.title, 'Intervista'); + assert.equal(loaded.project.transcript.words[1].struck, true); + assert.equal(loaded.mediaEntryName, 'test.mp4'); + assert.deepEqual(Array.from(loaded.mediaData), [1, 2, 3, 4]); + assert.match(loaded.captionsVtt, /^WEBVTT/); + assert.match(loaded.originalText, /benvenuti/); + assert.match(loaded.htmlText, /Benvenuti/); + assert.deepEqual(loaded.warnings, []); +}); + +test('container: a link project round-trips with no media entry (v1.1)', async () => { + const files = sampleFiles(); + const project = JSON.parse(files.json); + project.media = { kind: 'link', path: null, url: 'https://example.org/media.mp3', filename: '', mimeType: '', durationSeconds: 62.5, sizeBytes: 0 }; + files.json = JSON.stringify(project); + files.media = null; + const out = await save.zipProject(files, JSZip, 'uint8array'); + const loaded = await save.unzipProject(out, JSZip); + + assert.equal(loaded.recovered, false); + assert.equal(loaded.project.media.kind, 'link'); + assert.equal(loaded.project.media.url, 'https://example.org/media.mp3'); + assert.equal(loaded.mediaData, null); + assert.deepEqual(loaded.warnings, []); +}); + +test('container: a higher major version is refused with a clear code', async () => { + const files = sampleFiles(); + const project = JSON.parse(files.json); + project.formatVersion = '2.0'; + files.json = JSON.stringify(project); + const out = await save.zipProject(files, JSZip, 'uint8array'); + await assert.rejects(() => save.unzipProject(out, JSZip), (e) => e.code === 'version-major'); +}); + +test('container: an unknown media.kind is refused with a clear code', async () => { + const files = sampleFiles(); + const project = JSON.parse(files.json); + project.media.kind = 'hologram'; + files.json = JSON.stringify(project); + const out = await save.zipProject(files, JSZip, 'uint8array'); + await assert.rejects(() => save.unzipProject(out, JSZip), (e) => e.code === 'media-kind'); +}); + +test('container: missing hyperaudio.json recovers from transcript.html', async () => { + const zip = new JSZip(); + zip.file('transcript.html', '

    Hi

    '); + const out = await zip.generateAsync({ type: 'uint8array' }); + const loaded = await save.unzipProject(out, JSZip); + assert.equal(loaded.recovered, true); + assert.match(loaded.htmlText, /Hi/); + assert.ok(loaded.warnings.length > 0); +}); + +test('container: no json and no html is unreadable', async () => { + const zip = new JSZip(); + zip.file('random.txt', 'nothing to see'); + const out = await zip.generateAsync({ type: 'uint8array' }); + await assert.rejects(() => save.unzipProject(out, JSZip), (e) => e.code === 'unreadable'); +}); + +test('container: missing mimetype entry is tolerated with a warning', async () => { + const files = sampleFiles(); + const zip = new JSZip(); + zip.file('hyperaudio.json', files.json); + zip.file('media/test.mp4', files.media.data); + const out = await zip.generateAsync({ type: 'uint8array' }); + const loaded = await save.unzipProject(out, JSZip); + assert.equal(loaded.recovered, false); + assert.ok(loaded.warnings.some((w) => /mimetype/.test(w))); +}); + +test('container: unknown entries in the zip are ignored (whitelist-read)', async () => { + const files = sampleFiles(); + const zip = new JSZip(); + zip.file('mimetype', save.CONTAINER_MIMETYPE, { compression: 'STORE' }); + zip.file('hyperaudio.json', files.json); + zip.file('media/test.mp4', files.media.data); + zip.file('../../../evil.sh', 'echo pwned'); + zip.file('extra/unknown.bin', new Uint8Array([9, 9])); + const out = await zip.generateAsync({ type: 'uint8array' }); + const loaded = await save.unzipProject(out, JSZip); + assert.equal(loaded.recovered, false); + assert.equal(loaded.project.format, 'hyperaudio'); +}); + +/* ---- format v1.2 parity (#447; spec § 7.1, 7.2.2, 8.1, 10.2, 10.3) ---- */ + +test('media.path: ".." substring is legal, exact traversal segments are not (§ 10.2)', () => { + assert.equal(save.validateMediaPath('media/mix..final.mp3'), true); // the regression class + assert.equal(save.validateMediaPath('media/tone.wav'), true); + assert.equal(save.validateMediaPath('media/..'), false); + assert.equal(save.validateMediaPath('media/.'), false); + assert.equal(save.validateMediaPath('media/'), false); + assert.equal(save.validateMediaPath('media/a/b.mp3'), false); + assert.equal(save.validateMediaPath('media/a\\b.mp3'), false); + assert.equal(save.validateMediaPath('elsewhere/a.mp3'), false); +}); + +test('sanitizeMediaFilename mirrors the reader rule (§ 10.2)', () => { + assert.equal(save.sanitizeMediaFilename('mix..final.mp3'), 'mix..final.mp3'); // preserved + assert.equal(save.sanitizeMediaFilename('a/b\\c.mp3'), 'a_b_c.mp3'); + assert.equal(save.sanitizeMediaFilename('..'), 'media'); + assert.equal(save.sanitizeMediaFilename(' '), 'media'); + assert.equal(save.sanitizeMediaFilename(null), 'media'); +}); + +test('rewrites preserve unknown envelope fields, top-level and nested (§ 8.1)', () => { + const envelope = { + format: 'hyperaudio', formatVersion: '1.4', + futureBlock: { anything: true }, + created: '2020-01-01T00:00:00Z', + options: { gapRemoval: { enabled: false }, futureOption: 'keep-me', captions: { updateFromTranscript: true, futureCaptionKey: 7 } }, + texts: { title: 'old', futureText: 'keep-me-too' }, + }; + const project = save.buildProjectJson({ + envelope, + generatorVersion: 'x', created: envelope.created, modified: 'now', + media: { kind: 'none', path: null, url: null, filename: '', mimeType: '', durationSeconds: 0, sizeBytes: 0 }, + options: { gapRemoval: { enabled: true, thresholdMs: 500, bufferMs: 100 }, updateCaptionsFromTranscript: false, view: { showSpeakers: true, showTimecodes: false } }, + texts: { title: 'new', language: '', summary: '', topics: [] }, + transcript: { words: [] }, + }); + assert.equal(project.futureBlock.anything, true); // unknown top-level survives + assert.equal(project.options.futureOption, 'keep-me'); // unknown inside known object survives + assert.equal(project.options.captions.futureCaptionKey, 7); // ...even nested two deep + assert.equal(project.texts.futureText, 'keep-me-too'); + assert.equal(project.texts.title, 'new'); // owned fields overwritten + assert.equal(project.options.captions.updateFromTranscript, false); + assert.equal(project.formatVersion, save.FORMAT_VERSION); // writers write their own version + assert.equal(save.FORMAT_VERSION, '1.2'); + assert.equal(envelope.texts.title, 'old'); // the input envelope is not mutated +}); + +test('media.kind "none": validates and round-trips a media-less container (§ 7.2.2)', async () => { + const state = { + generatorVersion: 'x', created: 'c', modified: 'm', + media: { kind: 'none', path: null, url: null, filename: '', mimeType: '', durationSeconds: 0, sizeBytes: 0 }, + options: { gapRemoval: { enabled: false, thresholdMs: 500, bufferMs: 100 }, updateCaptionsFromTranscript: true, view: {} }, + texts: { title: 't', language: '', summary: '', topics: [] }, + transcript: { words: [{ start: 0, end: 1, text: 'a' }] }, + }; + const project = save.buildProjectJson(state); + assert.equal(save.validateProjectJson(project).ok, true); + const zipped = await save.zipProject({ json: save.serializeProjectJson(project), html: '
    ' }, JSZip, 'nodebuffer'); + const loaded = await save.unzipProject(new Uint8Array(zipped), JSZip); + assert.equal(loaded.recovered, false); + assert.equal(loaded.project.media.kind, 'none'); + assert.equal(loaded.mediaData, null); + assert.deepEqual(loaded.warnings, []); // "none" is not "missing" — no warning +}); + +test('invalid UTF-8 in a text entry is refused, not silently replaced (§ 10.3)', async () => { + const zip = new JSZip(); + zip.file('mimetype', save.CONTAINER_MIMETYPE, { compression: 'STORE' }); + zip.file('hyperaudio.json', new Uint8Array([0x7b, 0xff, 0xfe, 0x7d])); // {} + const buf = await zip.generateAsync({ type: 'nodebuffer' }); + await assert.rejects(save.unzipProject(new Uint8Array(buf), JSZip), (e) => e.code === 'entry-invalid-utf8'); +}); + +test('a compressed media entry is refused (§ 7.1) — pins JSZip metadata access too', async () => { + const state = { + generatorVersion: 'x', created: 'c', modified: 'm', + media: { kind: 'original', path: 'media/tone.wav', url: null, filename: 'tone.wav', mimeType: 'audio/wav', durationSeconds: 1, sizeBytes: 4 }, + options: { gapRemoval: { enabled: false, thresholdMs: 500, bufferMs: 100 }, updateCaptionsFromTranscript: true, view: {} }, + texts: { title: 't', language: '', summary: '', topics: [] }, + transcript: { words: [{ start: 0, end: 1, text: 'a' }] }, + }; + const zip = new JSZip(); + zip.file('mimetype', save.CONTAINER_MIMETYPE, { compression: 'STORE' }); + zip.file('hyperaudio.json', save.serializeProjectJson(save.buildProjectJson(state))); + zip.file('media/tone.wav', new Uint8Array(4096), { compression: 'DEFLATE' }); // forbidden + const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); + await assert.rejects(save.unzipProject(new Uint8Array(buf), JSZip), (e) => e.code === 'media-compressed'); +}); + +test('the writer sanitizes hostile media entry names with the shared rule (§ 10.2)', async () => { + const zipped = await save.zipProject({ + json: '{}', html: '
    ', + media: { name: '../evil.wav', data: new Uint8Array(8) }, + }, JSZip, 'nodebuffer'); + const zip = await JSZip.loadAsync(zipped); + assert.ok(zip.file('media/.._evil.wav') !== null); // separator neutralized, ".." substring kept + assert.equal(zip.file('media/../evil.wav'), null); +}); diff --git a/__TEST__/unit/storage.test.mjs b/__TEST__/unit/storage.test.mjs deleted file mode 100644 index 984297cc..00000000 --- a/__TEST__/unit/storage.test.mjs +++ /dev/null @@ -1,203 +0,0 @@ -// Unit tests for the Recents storage model (#434): entries keyed by stable ID -// with the display name in meta, legacy name-keyed entries migrated in place, -// name collisions suffixed instead of overwriting, and the list ordered by -// last-updated. All helpers run against a fake Storage so no DOM is needed. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const { - isDocKey, - isLegacyKey, - entryName, - entryMediaKey, - uniqueEntryName, - listDocEntries, - migrateLegacyEntries, - renameTranscriptEntry, - deleteTranscriptEntry, - duplicateTranscriptEntry, - setTranscriptStarred, - mediaKeyInUse, - mediaNameFromRef, -} = require('../../js/hyperaudio-lite-editor-storage.js'); - -function fakeStorage(init = {}) { - const map = new Map(Object.entries(init)); - return { - get length() { return map.size; }, - key: (i) => [...map.keys()][i] ?? null, - getItem: (k) => (map.has(k) ? map.get(k) : null), - setItem: (k, v) => { map.set(k, String(v)); }, - removeItem: (k) => { map.delete(k); }, - }; -} - -const entry = (fields = {}) => JSON.stringify(Object.assign({ - hypertranscript: '

    x

    ', - video: 'https://example.com/a.mp3', - summary: 's', - topics: [], -}, fields)); - -test('key classification: doc keys vs legacy keys vs unrelated keys', () => { - assert.ok(isDocKey('hyperaudio:doc:abc123')); - assert.ok(!isDocKey('alpha.hyperaudio')); - assert.ok(isLegacyKey('alpha.hyperaudio')); - assert.ok(!isLegacyKey('hyperaudio:doc:abc123')); - assert.ok(!isLegacyKey('hyperaudioTranscribePrefs')); - assert.ok(!isLegacyKey('.hyperaudio')); // name part must be non-empty (indexOf > 0) -}); - -test('migration: legacy entry moves to an ID key, name and mediaKey preserved', () => { - const s = fakeStorage({ 'interview.hyperaudio': entry() }); - migrateLegacyEntries(s); - assert.equal(s.getItem('interview.hyperaudio'), null); - const rows = listDocEntries(s); - assert.equal(rows.length, 1); - assert.ok(isDocKey(rows[0].key)); - const migrated = JSON.parse(s.getItem(rows[0].key)); - assert.equal(migrated.meta.name, 'interview'); - assert.equal(migrated.meta.mediaKey, 'interview'); // media stays under its legacy key - assert.ok(migrated.meta.created > 0); // stamped so the entry sorts by date from now on - assert.equal(migrated.hypertranscript.includes('data-m'), true); -}); - -test('migration: an unparseable legacy entry stays on its legacy key and is still listed', () => { - const s = fakeStorage({ 'broken.hyperaudio': '{not json', 'ok.hyperaudio': entry() }); - migrateLegacyEntries(s); - assert.equal(s.getItem('broken.hyperaudio'), '{not json'); - const names = listDocEntries(s).map((r) => r.name).sort(); - assert.deepEqual(names, ['broken', 'ok']); -}); - -test('migration is idempotent', () => { - const s = fakeStorage({ 'a.hyperaudio': entry() }); - migrateLegacyEntries(s); - const keysAfterFirst = listDocEntries(s).map((r) => r.key); - migrateLegacyEntries(s); - assert.deepEqual(listDocEntries(s).map((r) => r.key), keysAfterFirst); -}); - -test('uniqueEntryName: suffixes instead of colliding, own key excluded', () => { - const s = fakeStorage({ - 'hyperaudio:doc:one': entry({ meta: { name: 'interview' } }), - 'hyperaudio:doc:two': entry({ meta: { name: 'interview (2)' } }), - }); - assert.equal(uniqueEntryName('fresh', s, null), 'fresh'); - assert.equal(uniqueEntryName('interview', s, null), 'interview (3)'); - // an entry keeping its own name is not a collision with itself - assert.equal(uniqueEntryName('interview', s, 'hyperaudio:doc:one'), 'interview'); -}); - -test('listDocEntries: last-edited first, creation date stands in when never edited', () => { - const s = fakeStorage({ - 'hyperaudio:doc:a': entry({ meta: { name: 'older', updated: 1000 } }), - 'hyperaudio:doc:b': entry({ meta: { name: 'newest', updated: 3000 } }), - 'hyperaudio:doc:c': entry({ meta: { name: 'created-only', created: 2000 } }), // never edited → creation date - 'hyperaudio:doc:d': entry({ meta: { name: 'zeta' } }), // no dates at all → - 'hyperaudio:doc:e': entry({ meta: { name: 'alpha' } }), // bottom, alphabetical - }); - assert.deepEqual(listDocEntries(s).map((r) => r.name), - ['newest', 'created-only', 'older', 'alpha', 'zeta']); -}); - -test('rename: one-field update, de-duplicated, key untouched; empty name rejected', () => { - const s = fakeStorage({ - 'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1', updated: 42 } }), - 'hyperaudio:doc:two': entry({ meta: { name: 'taken' } }), - }); - assert.equal(renameTranscriptEntry('hyperaudio:doc:one', 'fresh', s), true); - let e = JSON.parse(s.getItem('hyperaudio:doc:one')); - assert.equal(e.meta.name, 'fresh'); - assert.equal(e.meta.mediaKey, 'm1'); // media key survives the rename - assert.equal(e.meta.updated, 42); // renaming must not reorder the list - - assert.equal(renameTranscriptEntry('hyperaudio:doc:one', 'taken', s), true); - e = JSON.parse(s.getItem('hyperaudio:doc:one')); - assert.equal(e.meta.name, 'taken (2)'); - - assert.equal(renameTranscriptEntry('hyperaudio:doc:one', ' ', s), false); - assert.equal(renameTranscriptEntry('hyperaudio:doc:missing', 'x', s), false); -}); - -test('delete: removes the entry, including an unparseable legacy one', () => { - const s = fakeStorage({ - 'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1' } }), - 'broken.hyperaudio': '{not json', - }); - deleteTranscriptEntry('hyperaudio:doc:one', s); - assert.equal(s.getItem('hyperaudio:doc:one'), null); - deleteTranscriptEntry('broken.hyperaudio', s); - assert.equal(s.getItem('broken.hyperaudio'), null); - assert.equal(listDocEntries(s).length, 0); -}); - -test('mediaNameFromRef: URL basename (decoded), plain filename passthrough, Untitled fallback (#435)', () => { - assert.equal(mediaNameFromRef('https://example.com/media/clip.mp4'), 'clip.mp4'); - assert.equal(mediaNameFromRef('https://example.com/media/My%20Interview.mp3?token=1#t=10'), 'My Interview.mp3'); - assert.equal(mediaNameFromRef('https://example.com/'), 'Untitled'); // no basename in the path - assert.equal(mediaNameFromRef('interview.mp4'), 'interview.mp4'); // a local file's real name - assert.equal(mediaNameFromRef(''), 'Untitled'); - assert.equal(mediaNameFromRef(null), 'Untitled'); -}); - -test('duplicate: fresh ID and timestamps, suffixed name, shared mediaKey (#436)', () => { - const s = fakeStorage({ - 'hyperaudio:doc:one': entry({ meta: { name: 'interview', mediaKey: 'm1', created: 100, updated: 200 } }), - }); - const newKey = duplicateTranscriptEntry('hyperaudio:doc:one', s); - assert.ok(newKey !== null && newKey !== 'hyperaudio:doc:one'); - const copy = JSON.parse(s.getItem(newKey)); - assert.equal(copy.meta.name, 'interview (2)'); - assert.equal(copy.meta.mediaKey, 'm1'); // shares the source's media - assert.ok(copy.meta.created > 100); // fresh stamps → sorts to the top - assert.equal(copy.meta.updated, copy.meta.created); - // the original is untouched - const original = JSON.parse(s.getItem('hyperaudio:doc:one')); - assert.equal(original.meta.name, 'interview'); - assert.equal(original.meta.updated, 200); - // an unusable source duplicates to nothing - s.setItem('broken.hyperaudio', '{not json'); - assert.equal(duplicateTranscriptEntry('broken.hyperaudio', s), null); -}); - -test('delete refcounts shared media: the blob outlives one of two duplicates (#436)', () => { - const s = fakeStorage({ - 'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1' } }), - 'hyperaudio:doc:two': entry({ meta: { name: 'a (2)', mediaKey: 'm1' } }), - }); - assert.equal(mediaKeyInUse('m1', 'hyperaudio:doc:one', s), true); // the twin still refs it - deleteTranscriptEntry('hyperaudio:doc:one', s); - assert.equal(s.getItem('hyperaudio:doc:one'), null); - assert.equal(mediaKeyInUse('m1', 'hyperaudio:doc:two', s), false); // last reference - assert.equal(mediaKeyInUse('m1', null, s), true); // still referenced overall -}); - -test('star/unstar: one-field toggle that never touches updated; copies start unstarred (#440)', () => { - const s = fakeStorage({ - 'hyperaudio:doc:one': entry({ meta: { name: 'a', updated: 42 } }), - }); - assert.equal(setTranscriptStarred('hyperaudio:doc:one', true, s), true); - let e = JSON.parse(s.getItem('hyperaudio:doc:one')); - assert.equal(e.meta.starred, true); - assert.equal(e.meta.updated, 42); // starring must not reorder - assert.equal(listDocEntries(s)[0].starred, true); // surfaced to the renderer - - const copyKey = duplicateTranscriptEntry('hyperaudio:doc:one', s); - assert.equal(JSON.parse(s.getItem(copyKey)).meta.starred, false); - - assert.equal(setTranscriptStarred('hyperaudio:doc:one', false, s), true); - e = JSON.parse(s.getItem('hyperaudio:doc:one')); - assert.equal(e.meta.starred, false); - assert.equal(setTranscriptStarred('hyperaudio:doc:missing', true, s), false); -}); - -test('entryName / entryMediaKey fall back sensibly for malformed entries', () => { - assert.equal(entryName('alpha.hyperaudio', null), 'alpha'); - assert.equal(entryName('hyperaudio:doc:x', null), 'Untitled'); - assert.equal(entryMediaKey('alpha.hyperaudio', null), 'alpha'); - assert.equal(entryMediaKey('hyperaudio:doc:x', null), null); - assert.equal(entryMediaKey('hyperaudio:doc:x', { meta: { mediaKey: 'm' } }), 'm'); -}); diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index 52e6f6e7..225d0bca 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -678,31 +678,6 @@ 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 @@ -728,11 +703,7 @@ 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). */ @@ -862,11 +833,7 @@ 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 @@ -954,210 +921,11 @@ 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%; -} -#file-picker .recents-row .file-item { - display: block; - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -#file-picker .recents-actions { - display: flex; - align-items: center; - gap: 2px; - 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 4px 0 0; -} -#file-picker .recents-row:hover .recents-actions, -#file-picker .recents-row:focus-within .recents-actions { - opacity: 1; -} -/* neutralise the global button chrome (border + grey fill) for the tiny - in-row actions; they read as quiet icons until hovered */ -#file-picker .recents-actions button { - display: inline-flex; - align-items: center; - border: none; - background: transparent; - margin: 0; - padding: 4px; - border-radius: 0.375rem; - color: oklch(var(--bc) / 0.55); - cursor: pointer; - font-size: 11px; - line-height: 1; -} -#file-picker .recents-actions button:hover { - border: none; - background-color: oklch(var(--b2)); - color: oklch(var(--bc)); -} -.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 */ -} -#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) — same weight as the panel's - static "Recents" h2, which hides while these are rendered (storage.js - decides; 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 @@ -1171,3 +939,43 @@ label[data-a11y-wired]:focus-visible { -moz-appearance: textfield; appearance: textfield; } + +/* The project dialog's destructive confirm: tailwind-min.css was built before + btn-error appeared in markup, so only fragments of the utility survived the + purge (hover fill yes, resting fill no). Style the resting state explicitly + from the theme vars, matching what the full build would produce. */ +#project-dialog .btn-error { + background-color: oklch(var(--er)); + border-color: oklch(var(--er)); + color: oklch(var(--erc)); +} + +/* Navbar Save button's dirty dot (#449): a primary-content dot ringed in + primary while unsaved changes exist — the native app's exact treatment. */ +#project-save-dirty-dot { + display: none; + position: absolute; + top: 4px; + right: 4px; + width: 9px; + height: 9px; + border-radius: 9999px; + background-color: oklch(var(--pc)); + box-shadow: 0 0 0 1.5px oklch(var(--p)); + z-index: 2; + pointer-events: none; +} +#project-save-btn.dirty #project-save-dirty-dot { + display: block; +} + +/* Second-tab guard banner (#450): this tab edits without the working-copy + slot; the owning tab has autosave/crash recovery. */ +#tab-guard-banner { + margin: 0 16px 8px; + padding: 8px 12px; + border-radius: 0.5rem; + font-size: 12px; + background-color: oklch(var(--b2)); + color: oklch(var(--bc) / 0.8); +} diff --git a/docs/format-fixtures/README.md b/docs/format-fixtures/README.md new file mode 100644 index 00000000..bd1b0c8b --- /dev/null +++ b/docs/format-fixtures/README.md @@ -0,0 +1,25 @@ +# `.hyperaudio` conformance fixtures + +Shared test cases for the format specified in [`../hyperaudio-format.md`](../hyperaudio-format.md). +Every implementation (this editor's `js/hyperaudio-save.js`, native apps) runs +these; a container written by one implementation must open losslessly in the +others. + +Currently the cases are embodied executable-first in this repo's unit lane +(`__TEST__/unit/hyperaudio-save.test.mjs` — path rules incl. the legal `..` +substring, unknown-field preservation, `kind: "none"`, invalid UTF-8, byte +caps, compressed-media rejection, hostile filename sanitization) and e2e lane +(`__TEST__/e2e/project-save.spec.mjs` — full editor round trip, OPFS restore). + +Next step (tracked in hyperaudio-lite-editor#447): generate the cases as +checked-in `.hyperaudio` files with an expectations manifest, so non-JS +implementations consume them without running this repo's test harness. The +planned matrix: + +- legal `mix..final.mp3`; `/`, `\`, `.`, `..`, empty and Unicode filenames +- unknown top-level and nested fields (must survive open→save) +- newer minor (loads), newer major (clean rejection), malformed versions +- invalid UTF-8; oversized declared and actual text entries +- compressed media entry (rejection); `link` media; `none` media; missing media +- captions absent / present / intentionally divergent +- `transcript.original.json` byte preservation diff --git a/docs/hyperaudio-format.md b/docs/hyperaudio-format.md new file mode 100644 index 00000000..94e847d2 --- /dev/null +++ b/docs/hyperaudio-format.md @@ -0,0 +1,871 @@ +# The `.hyperaudio` file format + +**Version 1.2** · Status: **frozen for cross-implementation interchange** + +This document is the normative specification. It originated as the v1.1 spec +in [hyperaudio-lite-editor#403](https://github.com/hyperaudio/hyperaudio-lite-editor/issues/403); +this file supersedes that comment. Shared conformance fixtures live in +[`docs/format-fixtures/`](format-fixtures/) — every implementation runs them. + +| Version | Changes | +|---|---| +| 1.0 | Initial format | +| 1.1 | `media.kind: "link"` — declared remote media with reconciliation (§ 7.2.1, § 7.3) | +| 1.2 | `media.kind: "none"` (§ 7.2.2); writer-side unknown-field preservation made normative (§ 8.1); STORE required for media entries on read (§ 7.1); `media.path` segment rule and byte-measured caps pinned (§ 10.2, § 10.3); container exclusion of app/session identity made explicit (§ 9) | + +--- + +> **To the reader (human or LLM):** this document specifies a file format. Rules +> marked **MUST** / **MUST NOT** are binding for writers and readers; **SHOULD** +> is a strong recommendation; **MAY** is optional behaviour. + +--- + +## 1. What a `.hyperaudio` file is + +A `.hyperaudio` file is the **working save** of a Hyperaudio Lite Editor +project: media + word-synchronized transcript + captions + project settings, +in a single portable file. + +Technically it is a **renamed ZIP archive** (like `.epub`, `.docx`, `.sketch`), +designed to be read and written by: + +- Hyperaudio Lite Editor (in the browser) +- future native apps (desktop / mobile) +- any tool that can open a zip and parse JSON + +### 1.1 SAVE, not EXPORT — the founding principle + +A `.hyperaudio` file captures the **state of the work**; it does not produce a +finished result. Three rules follow: + +1. The container **MUST NOT** contain derived or rendered artifacts: no + edited/cut media, no captions burned into frames, no re-timed transcript, + no word-level karaoke VTT. Those are what the editor's *export* functions + are for. +2. The media inside the container is the **original, byte for byte**. Saving + never re-encodes anything. +3. Everything that is **non-reconstructible working state** goes in: the text + with in-progress redactions, the current captions (which may have been + hand-edited), the original machine transcription, the project settings. + +In short: a `.hyperaudio` is a **working zip** that preserves the edits in +full — editing is *non-destructive*: decisions (redacted words, silences to +skip) travel **as data** (`struck`, options) on top of the intact original +media, and are applied at playback and at export time. Only *rendered* export +products stay out. + +> **Privacy consequence — share exports, not project files.** Because the +> container preserves the work in full, it includes the *complete original +> media* and every redacted word as recoverable data. A `.hyperaudio` given to +> someone else discloses exactly what its editor struck out. When redactions +> or cuts matter, share a *rendered export* (or a flattened new project whose +> media is the rendered result); treat the project file itself like a source +> document. + +### 1.2 Format identity + +| Property | Value | +|---|---| +| Extension | `.hyperaudio` | +| MIME type | `application/vnd.hyperaudio+zip` | +| Container | ZIP (standard PKZIP) | +| Text encoding | UTF-8, always | +| Current version | `1.1` | + +--- + +## 2. Container structure + +``` +project.hyperaudio (ZIP) +├── mimetype ← first entry, NOT compressed (see 2.1) +├── hyperaudio.json ← THE file: version + media + options + texts + transcript (§ 3) +├── transcript.html ← the transcript in the editor's native format, for compatibility (§ 4) +├── transcript.original.json ← optional: the original machine transcription, immutable (§ 5) +├── captions.vtt ← the current captions, WebVTT (§ 6) +└── media/ + └── ← the original media, STORE entry (§ 7) +``` + +Rules: + +- `hyperaudio.json` **MUST** be present. It is the single source of truth for + the working state. +- `transcript.html` and `captions.vtt` **SHOULD** be present (a conforming + writer always writes them; a reader must tolerate their absence). +- `transcript.original.json` is **optional**: present only when the project + was born from an automatic transcription (or from an import whose origin + was kept). +- `media/` contains **at most one file**. It may be absent when `media.kind` + is `"link"` (§ 7.2.1). +- Readers **MUST** ignore unknown files in the container (this allows + non-breaking future additions). + +### 2.1 The `mimetype` entry (EPUB/ODF convention) + +The first entry of the zip is a file named `mimetype`, containing exactly the +string: + +``` +application/vnd.hyperaudio+zip +``` + +with no trailing newline, **stored without compression** (STORE method) and +with no extra fields. This puts the MIME type bytes at a **fixed offset of +38** in the physical file: a native app can recognise a `.hyperaudio` by +reading the first ~80 bytes, without even opening the zip. + +- Writers **MUST** write this entry first, uncompressed. +- Readers **SHOULD** verify it, but **MUST** tolerate its absence (files + produced by generic tools that re-zip the content remain valid). + +--- + +## 3. `hyperaudio.json` — field-by-field reference + +One `JSON.parse` and you have the whole project except the media. A complete, +valid example is in the collapsed section at the end of this comment. + +### 3.1 Root + +| Field | Type | Required | Description | +|---|---|---|---| +| `format` | string | ✔ | Always `"hyperaudio"`. Sanity check for readers. | +| `formatVersion` | string | ✔ | `"major.minor"`, e.g. `"1.0"`. Rules in § 8. | +| `generator` | object | ✔ | Who wrote the file: `{ "name": string, "version": string }`. Diagnostic only. | +| `created` | string | ✔ | ISO 8601 UTC — when the project was first created. | +| `modified` | string | ✔ | ISO 8601 UTC — last save. | +| `media` | object | ✔ | Media descriptor (§ 3.2). | +| `options` | object | ✔ | Project settings (§ 3.3). | +| `texts` | object | ✔ | Key textual metadata (§ 3.4). | +| `provenance` | object | – | Origin of the transcription (§ 3.5). | +| `transcript` | object | ✔ | The complete working transcript (§ 3.6). | + +### 3.2 `media` + +```json +"media": { + "kind": "original", + "path": "media/intervista-maria.mp4", + "url": null, + "filename": "intervista-maria.mp4", + "mimeType": "video/mp4", + "durationSeconds": 62.5, + "sizeBytes": 48211337 +} +``` + +| Field | Type | Description | +|---|---|---| +| `kind` | string enum | The media "formula": `"original"` (1.0), `"link"` (1.1, § 7.2.1) or `"none"` (1.2, § 7.2.2). Reserved value: `"audio-m4a"` (§ 7.2). Behaviour on an unknown `kind`: § 7.3. | +| `path` | string \| null | Path inside the zip (with `kind: "original"` / `"audio-m4a"`). `null` with `"link"`. Security constraints in § 10. | +| `url` | string \| null | URL of the media on the web (only with `kind: "link"`). Otherwise `null`. | +| `filename` | string | The original filename chosen by the user, preserved. | +| `mimeType` | string | MIME type of the media (`video/mp4`, `audio/mpeg`, …). | +| `durationSeconds` | number | Duration in seconds (float). Informational; also used for reconciliation (§ 7.3). | +| `sizeBytes` | number | Size of the media file in bytes. Informational; also used for reconciliation (§ 7.3). | + +### 3.3 `options` — project settings + +```json +"options": { + "gapRemoval": { "enabled": true, "thresholdMs": 500, "bufferMs": 100 }, + "captions": { "updateFromTranscript": false }, + "view": { "showSpeakers": true, "showTimecodes": false } +} +``` + +| Field | Type | Description | +|---|---|---| +| `gapRemoval.enabled` | bool | Silence skipping active in preview. | +| `gapRemoval.thresholdMs` | int | Minimum pause (ms) for a silence to be skipped. | +| `gapRemoval.bufferMs` | int | Margin (ms) kept at the edges of a skipped silence. | +| `captions.updateFromTranscript` | bool | **The flag that governs divergence** (§ 6.1). `true` = captions are derived; the editor regenerates them on every transcript edit. `false` = captions are hand-curated: the editor **MUST NOT** overwrite them. | +| `view.*` | bool | Project display preferences. | + +Readers **MUST** ignore unknown keys inside `options`; writers may add keys +with a minor version bump. + +⚠️ **`options` MUST NOT ever contain**: API keys, tokens, application +preferences (preferred transcription engine, etc.). Those are *app* +preferences, not project settings — a `.hyperaudio` file gets shared; a key +inside it is a guaranteed leak. + +### 3.4 `texts` — key metadata + +```json +"texts": { + "title": "Intervista con Maria — formato di salvataggio", + "language": "it", + "summary": "Maria e Piero presentano il nuovo formato .hyperaudio.", + "topics": ["hyperaudio", "formato file", "salvataggio"] +} +``` + +Clean data (strings and arrays, never HTML). `title` is also the basis of the +filename suggested at download. `language` is BCP-47 (`"it"`, `"en-GB"`). +`summary` and `topics` may come from the transcription engine or from the +user; they may be an empty string / empty array. + +### 3.5 `provenance` (optional) + +```json +"provenance": { + "engine": "deepgram", + "model": "nova-3", + "transcribedAt": "2026-07-10T08:55:00Z", + "originalTranscript": "transcript.original.json" +} +``` + +| Field | Type | Description | +|---|---|---| +| `engine` | string | Engine that produced the transcription (`deepgram`, `whisper`, `parakeet-local`, …). | +| `model` | string | Model used, if known. | +| `transcribedAt` | string | ISO 8601 UTC of the original transcription. | +| `originalTranscript` | string | Path of the file holding the original machine transcription (§ 5), if kept. | + +Records who/what produced the original transcription. Zero cost today, +valuable tomorrow (e.g. deciding whether to re-transcribe with a better +model). Absent when the transcript was pasted/imported without keeping the +origin. + +### 3.6 `transcript` — the working transcript + +```json +"transcript": { + "words": [ + { "start": 0.32, "end": 0.84, "text": "Benvenuti" }, + { "start": 0.84, "end": 1.02, "text": "ehm", "struck": true } + ], + "paragraphs": [ + { "speaker": "Maria", "start": 0.32, "end": 6.5 } + ] +} +``` + +**`words[]`** — every word, in temporal order: + +| Field | Type | Description | +|---|---|---| +| `start` | number | Start in **seconds** (float). | +| `end` | number | End in **seconds** (float). | +| `text` | string | The word, without its trailing space. | +| `space` | bool | `true` = a word boundary (space) follows the word. `false` = fragment glued to the next one (e.g. hyphenated words split by the engine). Default when absent: `true`. | +| `struck` | bool | `true` = **redacted** word (struck out): excluded from playback and from every export, but text and timings remain. Default when absent: `false`. **Do not serialize defaults** (smaller files, backward compatibility with pre-existing JSON). | + +**`paragraphs[]`** — the paragraph/turn structure: + +| Field | Type | Description | +|---|---|---| +| `speaker` | string \| null | Speaker name (without square brackets), or `null` if unattributed. | +| `start` / `end` | number | Temporal extent of the paragraph in seconds. Words belong to the paragraph that temporally contains them. | + +Rules: + +- Times are **seconds** (float) throughout the JSON. (The editor's DOM uses + integer milliseconds — `data-m`/`data-d`; the conversion is + `ms = round(s × 1000)`; § 12.) +- Redactions (`struck`) are **working state** and travel in the save. They + are reversible in the editor; they become final only in an export. +- In v1, redaction is **word-level only** (`struck`), complemented by + gap-removal in `options`. Time-range redaction may arrive as a future + additive field `redactions: [{start, end}]` (minor bump, e.g. v1.1) if a + real need emerges. + +--- + +## 4. `transcript.html` — the compatibility copy + +The transcript in the **editor's native format**: HTML with one `` per +word. + +```html +
    +

    + [Maria] + Benvenuti + ehm +

    +
    +``` + +Conventions of the HTML format (the editor's current ones): + +- `data-m` = start in **milliseconds** (integer); `data-d` = duration in + milliseconds. +- The **trailing space inside the span** encodes the word boundary + (equivalent to `space: true` in the JSON). +- Speakers are dedicated spans: `class="speaker"`, `data-d="0"`, text + `[Name] `. +- Redactions are `style="text-decoration: line-through"` on the span + (equivalent to `struck: true`). + +Role: + +1. **Compatibility**: existing HTML-based flows (hyperaudio-lite, legacy + storage) consume it without converters. +2. **Inspectability**: opening the zip, a browser displays the transcript + directly. +3. **Safety net**: if the JSON round-trip ever had a bug, the editor's data + is still there. + +**Anti-divergence rule:** the source of truth is +`hyperaudio.json.transcript`. The writer **MUST** generate `transcript.html` +from the same state in the same save (the two files are consistent by +construction). + +**Fallback rules:** the reader **MUST** load from the JSON; using +`transcript.html` as a source is allowed **only as recovery**, when the JSON +is missing or unreadable. In that case the reader: + +- **MUST** sanitize the HTML against the allowlist in § 10 before any DOM + insertion; +- **SHOULD** warn the user that the file is not fully conforming and was + recovered from the HTML; +- **SHOULD** regenerate the complete JSON at the next save, bringing the + project back to full conformance. + +--- + +## 5. `transcript.original.json` — the origin (optional, immutable) + +The transcription **as it came out of the engine**, before any human +intervention. It is non-reconstructible working state: the moment the user +edits, the original would be gone — and re-transcribing costs time/money and +never yields the exact same result. + +**Schema: identical to the `transcript` object** of `hyperaudio.json` +(§ 3.6) — `{ "words": [...], "paragraphs": [...] }`, times in seconds. A +diff between origin and working copy is then a direct comparison of equal +structures. + +Rules: + +- The writer writes it **once**, at transcription time (or at import, if the + origin is kept), and **MUST NOT** ever modify it afterwards. It is the + conceptual twin of the media: it enters at the start and is never touched. +- It **MUST NOT** contain `struck`: redaction is the user's work, absent from + the origin by definition. +- Speakers are those assigned by the engine (e.g. `"Speaker 0"`), not the + names the user will assign later. +- The reader **MUST** tolerate its absence and **MUST NOT** load it as the + working transcript, except on an explicit user action (e.g. "restore + original"). +- It is referenced by `provenance.originalTranscript` (§ 3.5); if the file + exists but the field is missing (or vice versa), the reader trusts the + file. + +What it enables (future features, none required in v1): a "what did I +change" diff view, restoring a word/region to the original, re-alignment, +engine quality metrics. + +**The origin always travels in the file**: stripping it from the save would +only make sense if it were public and retrievable online — it is not, so the +file is the only place it lives. There is no "save without origin" option in +v1. + +⚠️ **Privacy note:** the origin contains the *pre-redaction* text — words the +user struck out or deleted in the working copy are still there. Within a save +this creates no new exposure class (the container already includes the +original media, which contains all the audio), but any future *sharing* +feature that strips the file (e.g. "share without media") **MUST** consider +stripping this file too. + +--- + +## 6. `captions.vtt` — the current captions + +The project's sentence-level captions, in standard WebVTT. + +### 6.1 ⚠️ Transcript and captions MUST be allowed to diverge + +This is the most important conceptual point of the format, and the reason +captions are a file of their own rather than a derivative recomputed on open. + +**Transcript and captions are two representations of the same speech with two +different purposes:** + +- The **transcript** is the *display and editing* view: faithful word by + word, with word-level timing, redactions included. It is the document you + work on — the equivalent of the timeline. +- The **captions** are the *reading* view: text segmented into cues sized to + be **read** while watching — short lines (~37 characters), minimum + duration, sustainable reading speed (~17 characters/second), line breaks + placed where the sentence breathes. + +A good subtitle is **not** a faithful transcription: it may omit a +hesitation, compact a repetition, break a line at a different point, nudge a +cue by a few tenths of a second to give it time to be read. Whoever curates +subtitles in caption mode produces **irreplaceable user data**, not a +derivative. + +The contract is governed by `options.captions.updateFromTranscript`: + +| Value | Meaning | Reader obligation | +|---|---|---| +| `true` | The captions are **derived**: the editor regenerates them on every transcript edit. The saved VTT is the latest generation (included because it makes the file readable by a player with no segmentation logic). | May regenerate them freely. | +| `false` | The captions are **hand-curated** and legitimately diverge from the transcript. | **MUST NOT** regenerate or "fix" them to re-align them to the transcript. Touch them only on an explicit user action. | + +Corollary for every reader (including automated tools and LLMs processing the +format): **never "repair" a file by re-aligning captions and transcript**. If +they diverge with `updateFromTranscript: false`, the divergence is +intentional. + +### 6.2 What this file is NOT + +- It is not the word-level karaoke VTT: that is an artifact always + deterministically derivable from the transcript and **MUST NOT** be in the + container. +- It is not the burned-in caption of an export: that is produced from the + re-timed transcript at export time and is not working state. + +### 6.3 Multilingual captions (roadmap) + +`captions.vtt` at the root is and will remain **the primary track, forever** +— including in a multilingual future. Additional tracks will arrive as an +additive minor bump: `captions/.vtt` files declared in +`hyperaudio.json`. v1 readers will keep working by finding the primary; the +extra tracks are simply unknown files to ignore (§ 2). + +--- + +## 7. `media/` — the project media + +### 7.1 v1 rules (`kind: "original"`) + +- The file enters the container **byte for byte, never re-encoded**. The + original name is preserved (`media/`). +- In the zip, the media entry **MUST** use the **STORE** method (no + compression): media formats are already compressed; deflate would gain ~0% + while burning CPU on files hundreds of MB large. JSON/HTML/VTT are + compressed normally (deflate). Since 1.2, readers **MUST** treat a media + entry whose zip method is not STORE as non-conforming (§ 7.3 applies): a + compressed media entry defeats the size accounting of § 10.3. +- One media file per project. + +### 7.2 Future formulas (roadmap, not v1) + +The `media.kind` field is the enum that makes these evolutions a non-breaking +minor bump: + +| `kind` | Content | Status | +|---|---|---| +| `"original"` | The original file in `media/` | **v1 — the only initial formula** | +| `"audio-m4a"` | Only the audio track, re-encoded M4A/AAC: a compact save for projects where the video weighs GBs but the work is on the text. An **explicit user choice** at save time, never automatic. | future | +| `"link"` | No file in the container: `url` points to media on the web. The file is not self-contained and the editor states so openly. | **v1.1** (§ 7.2.1) | +| `"none"` | No media at all: a text-only project (e.g. a JSON/SRT/VTT import with no media attached). | **v1.2** (§ 7.2.2) | + +### 7.2.1 `kind: "link"` (since 1.1) + +- `url` **MUST** be an http(s) URL; `path` is `null`; the container has no + `media/` entry. +- The file is **not self-contained**, and readers state so openly on open. +- Writers **SHOULD** prefer embedding over linking: attempt to download the + remote media and save it as `kind: "original"` (whether this works is the + server's call — CORS); fall back to a link save only with the user informed. +- Readers open a link project by playing the URL directly (playback does not + require CORS). If the URL is unreachable, § 7.3 applies — degraded mode + with reconciliation is the recommended behaviour. + +### 7.2.2 `kind: "none"` (since 1.2) + +- A project with **no media at all** — the normal state of a text-only import + (JSON/SRT/VTT with no media attached). `path` and `url` are `null`; + `filename` **MAY** be `""`. +- Writers **MUST** use `"none"` rather than fabricating an `"original"` + descriptor that points at a nonexistent entry ("editable but not saveable" + was the alternative, and it is worse). +- Readers **MUST** load such projects with playback disabled, and **MAY** + offer to attach media; attaching upgrades the descriptor to `"original"` + (or `"link"`) at the next save. § 7.3 reconciliation does not apply — + nothing is *missing*. + +### 7.3 Media unavailable: degraded mode and reconciliation + +When the media is unusable — unknown `kind` (written by a newer version), +`kind: "link"` with an unreachable URL, or a tampered container — the reader +**MUST NOT** load the project as if nothing happened. Two conforming +behaviours: + +1. **Rejection** with a clear message (the default; this is what Hyperaudio + Lite Editor v1 does). +2. **Degraded mode** (MAY): load the text only (transcript/captions), + explicitly stating that the media is unavailable. + +In degraded mode the reader **MAY** offer **reconciliation**: ask the user to +provide the media file and re-attach it to the project. Verification criteria +in v1: `durationSeconds`, `sizeBytes`, `filename` (heuristic); once a +`sha256` checksum lands in the descriptor in v1.x, reconciliation becomes +certain. Re-attaching rewrites the `media` descriptor (back to +`kind: "original"`) at the next save. + +--- + +## 8. Versioning + +`formatVersion` is a `"major.minor"` string. + +- **minor bump** (`1.0` → `1.1`): backward-compatible additions — new fields, + new files in the container, new values where the reader has a safe fallback + behaviour. Readers **MUST ignore** unknown fields and files. +- **major bump** (`1.x` → `2.0`): breaking changes. A reader that encounters + a major version above its own **MUST reject** the file with a clear message + ("this project requires a newer version of the editor"), without attempting + a partial load. +- Writers always write the most recent version they know and **MUST NOT** + silently rewrite a file to a lower version. + +*Ignore-unknown + reject-major* is the pair of rules that lets the format +evolve for years without breaking native apps born later. + +Version history: **1.0** initial; **1.1** adds `media.kind: "link"` +(§ 7.2.1); **1.2** adds `media.kind: "none"` (§ 7.2.2), makes writer-side +preservation of unknown fields normative (§ 8.1), requires STORE for media +entries on read (§ 7.1), and pins the `media.path` segment rule and the +byte-measured size caps (§ 10.2, § 10.3). + +Documented exception: unknown `media.kind` → the project is not normally +loadable even within the same major; the behaviours of § 7.3 apply. + +### 8.1 Round-trip preservation (normative since 1.2) + +*Ignore-unknown* (readers) is only half the promise: a writer that rebuilds +`hyperaudio.json` from scratch destroys the very fields readers were told to +tolerate. On rewriting an opened project, writers **MUST** start from the +opened envelope and overwrite only the fields they own — including *inside* +known objects (`options`, `texts`, …), where unknown keys **MUST** be +preserved rather than the object replaced wholesale. A conforming open→save +round trip preserves every unknown top-level field and every unknown nested +field. + +--- + +## 9. What must NEVER be in the container + +1. **API keys, tokens, credentials** — of any service, in any field. +2. **Application preferences** (preferred engine, UI language, etc.) — only + *project* settings belong here. +3. **Derived artifacts**: edited media, burned-in captions, karaoke VTT, + re-timed transcript. +4. **Data from other projects**, or editor history. +5. **App/session identity and storage bookkeeping** — working-copy project + IDs, tab/session identifiers, dirty flags, autosave state. Project + identity is application state; two apps sharing a file must never fight + over it inside the container. + +--- + +## 10. Reader security (normative) + +A `.hyperaudio` file comes from the outside: the reader treats it as +**untrusted input**. + +### 10.1 Whitelist-read: never generic extraction + +The reader **MUST NOT** iterate the zip entries extracting them or writing +them to paths taken from the file. It reads **only entries with known +names**: `mimetype`, `hyperaudio.json`, `transcript.html`, +`transcript.original.json`, `captions.vtt`, plus the media indicated by +`media.path`. Every other entry is ignored (§ 2). This neutralises path +traversal (`../`, absolute paths) *by design*: no path from the file is ever +used as a write destination. + +### 10.2 Constraints on `media.path` + +- **MUST** match the pattern `media/`: exactly one non-empty + segment after `media/` — no `/` or `\\` anywhere within it, and the segment + **MUST NOT** be exactly `.` or `..`. A `..` **substring** inside an + otherwise normal filename (`mix..final.mp3`) is legal and **MUST** be + accepted — pinned in 1.2 after a reject-any-substring reading made + conforming containers unreadable. Writers **MUST** sanitize embedded media + filenames with this same rule. +- **MUST** correspond to an existing entry in the zip. +- Only that entry is read as media; any other file in `media/` is ignored. + +On violation, the reader **MUST** treat the media as unavailable (§ 7.3). + +### 10.3 Size limits (anti zip-bomb) + +Before decompressing, the reader **SHOULD** enforce reasonable ceilings on +the textual entries (indicative: 50 MB each for `hyperaudio.json`, +`transcript.html`, `transcript.original.json`, `captions.vtt` — a one-hour +transcription is on the order of hundreds of KB). Entries above the ceiling → +the file is treated as non-conforming. Since 1.2 the ceilings are pinned as +**UTF-8 bytes** (declared size pre-inflate and actual size post-inflate), not +UTF-16 code units; readers decode text entries with a **fatal** UTF-8 decoder +and treat invalid UTF-8 as non-conforming. + +### 10.4 JSON validation + +The reader **MUST** validate types and ranges before use: numeric times +finite and non-negative, `start ≤ end`, well-formed `formatVersion`, +`media.kind` among the handled values. Malformed or invalid JSON = +"unreadable JSON" → the recovery path from `transcript.html` applies (§ 4). + +### 10.5 Safe DOM construction + +- Primary path (from the JSON): the reader **MUST** build the DOM + programmatically, using `textContent` for all text — never `innerHTML` on + data coming from the file. +- Recovery path (from `transcript.html`): the reader **MUST** sanitize + against an allowlist before any DOM insertion — allowed elements: + `article`, `section`, `p`, `span`; allowed attributes: `data-m`, `data-d`, + `class` (value `speaker` only), `style` (only + `text-decoration: line-through`). Everything else — scripts, `on*` + handlers, iframes, links, images, other styles — is removed. + +### 10.6 Duplicate entries + +With whitelist-read, duplicate entries are not an attack vector (a single +entry per name is read — the one the zip library deterministically exposes). +Readers **SHOULD** nevertheless not depend on entry order, `mimetype` +excepted. + +--- + +## 11. Conformance checklist + +**A conforming writer:** + +- [ ] writes `mimetype` as the first entry, STORE, exact content +- [ ] writes `hyperaudio.json` with all required fields, times in seconds, + defaults (`space: true`, `struck: false`) not serialized +- [ ] writes `transcript.html` and `captions.vtt` consistent with the JSON of + the same save +- [ ] writes `transcript.original.json` once (at transcription time) and + never modifies it again +- [ ] writes the media byte for byte, STORE entry, name preserved — or, for + a link save (§ 7.2.1), no media entry and an http(s) `url` +- [ ] never writes keys/credentials/derived artifacts + +**A conforming reader:** + +- [ ] checks `format === "hyperaudio"` and applies the version rules (§ 8) +- [ ] reads only entries with known names; validates `media.path` and the + size limits (§ 10) +- [ ] validates JSON types and ranges before use (§ 10.4) +- [ ] loads the transcript from `hyperaudio.json`, building the DOM with + `textContent`; uses `transcript.html` only as recovery, sanitized + (§ 4, § 10.5) +- [ ] honours `captions.updateFromTranscript: false` (never + regenerate/re-align) +- [ ] never loads `transcript.original.json` as the working transcript + (explicit user action only) +- [ ] on unavailable media: rejects with a clear message, or degrades while + stating it (§ 7.3) +- [ ] ignores unknown fields and files +- [ ] tolerates the absence of `mimetype`, `transcript.html`, + `transcript.original.json`, `captions.vtt` + +--- + +## 12. Invariants and known traps + +| Invariant | Why it matters | +|---|---| +| JSON in **seconds** (float), DOM/HTML in **milliseconds** (integers) | The off-by-1000 error is the classic fatal bug of this domain. Conversion: `ms = round(s × 1000)`; maximum loss 0.5 ms, irrelevant. | +| Trailing space in the HTML span = `space: true` in the JSON | The word boundary is *data*, not formatting. Losing it glues words together. | +| `struck` travels in the save, is applied at export | Redactions are reversible as long as you are in the working file. | +| Captions ≠ transcript (§ 6.1) | Never "repair" the divergence: with `updateFromTranscript: false` it is intentional. | +| Origin ≠ working copy (§ 5) | `transcript.original.json` is immutable and pre-redaction: never load it in place of the working transcript, never "update" it. | +| The file is untrusted input (§ 10) | Whitelist-read, validation, DOM via `textContent`, sanitization of the HTML fallback. | +| UTF-8 everywhere | Media filenames included (the zip's UTF-8 flag). | +| One media file, original name | No ambiguity, no index to maintain. | + + + +
    +Worked example — a complete, valid container + +Zipping these entries (with `mimetype` first and uncompressed) produces a +valid `.hyperaudio` file (a 1.0 file — still valid under 1.1, which is a +purely additive minor bump). The example demonstrates the two key contracts: +caption divergence (§ 6.1 — the transcript contains the redacted hesitation +"ehm", the hand-curated captions omit it) and the immutable origin (§ 5 — the +engine produced "hyper" + "audio" as two lowercase words; the user merged +them into "Hyperaudio,"). + +### `mimetype` + +``` +application/vnd.hyperaudio+zip +``` + +### `hyperaudio.json` + +```json +{ + "format": "hyperaudio", + "formatVersion": "1.0", + "generator": { "name": "hyperaudio-lite-editor", "version": "0.8.2" }, + "created": "2026-07-10T09:00:00Z", + "modified": "2026-07-10T11:30:00Z", + + "media": { + "kind": "original", + "path": "media/intervista-maria.mp4", + "url": null, + "filename": "intervista-maria.mp4", + "mimeType": "video/mp4", + "durationSeconds": 62.5, + "sizeBytes": 48211337 + }, + + "options": { + "gapRemoval": { "enabled": true, "thresholdMs": 500, "bufferMs": 100 }, + "captions": { "updateFromTranscript": false }, + "view": { "showSpeakers": true, "showTimecodes": false } + }, + + "texts": { + "title": "Intervista con Maria — formato di salvataggio", + "language": "it", + "summary": "Maria e Piero presentano il nuovo formato di salvataggio .hyperaudio.", + "topics": ["hyperaudio", "formato file", "salvataggio"] + }, + + "provenance": { + "engine": "deepgram", + "model": "nova-3", + "transcribedAt": "2026-07-10T08:55:00Z", + "originalTranscript": "transcript.original.json" + }, + + "transcript": { + "words": [ + { "start": 0.32, "end": 0.84, "text": "Benvenuti" }, + { "start": 0.84, "end": 1.02, "text": "ehm", "struck": true }, + { "start": 1.1, "end": 1.3, "text": "a" }, + { "start": 1.3, "end": 2.1, "text": "Hyperaudio," }, + { "start": 2.3, "end": 2.55, "text": "il" }, + { "start": 2.55, "end": 3.1, "text": "modo" }, + { "start": 3.15, "end": 3.4, "text": "più" }, + { "start": 3.45, "end": 4.05, "text": "semplice" }, + { "start": 4.1, "end": 4.3, "text": "di" }, + { "start": 4.35, "end": 5.0, "text": "montare" }, + { "start": 5.05, "end": 5.3, "text": "un" }, + { "start": 5.35, "end": 6.1, "text": "video" }, + { "start": 6.15, "end": 6.5, "text": "trascritto." }, + { "start": 7.8, "end": 8.2, "text": "Grazie" }, + { "start": 8.25, "end": 8.7, "text": "Maria," }, + { "start": 8.9, "end": 9.3, "text": "oggi" }, + { "start": 9.35, "end": 9.9, "text": "parliamo" }, + { "start": 9.95, "end": 10.15, "text": "del" }, + { "start": 10.2, "end": 10.75, "text": "formato" }, + { "start": 10.8, "end": 10.95, "text": "di" }, + { "start": 11.0, "end": 11.85, "text": "salvataggio." } + ], + "paragraphs": [ + { "speaker": "Maria", "start": 0.32, "end": 6.5 }, + { "speaker": "Piero", "start": 7.8, "end": 11.85 } + ] + } +} +``` + +### `transcript.html` + +```html + +
    +

    + [Maria] + Benvenuti + ehm + a + Hyperaudio, + il + modo + più + semplice + di + montare + un + video + trascritto. +

    +

    + [Piero] + Grazie + Maria, + oggi + parliamo + del + formato + di + salvataggio. +

    +
    +``` + +### `transcript.original.json` + +```json +{ + "words": [ + { "start": 0.32, "end": 0.84, "text": "benvenuti" }, + { "start": 0.84, "end": 1.02, "text": "ehm" }, + { "start": 1.1, "end": 1.3, "text": "a" }, + { "start": 1.3, "end": 1.7, "text": "hyper" }, + { "start": 1.75, "end": 2.1, "text": "audio" }, + { "start": 2.3, "end": 2.55, "text": "il" }, + { "start": 2.55, "end": 3.1, "text": "modo" }, + { "start": 3.15, "end": 3.4, "text": "più" }, + { "start": 3.45, "end": 4.05, "text": "semplice" }, + { "start": 4.1, "end": 4.3, "text": "di" }, + { "start": 4.35, "end": 5.0, "text": "montare" }, + { "start": 5.05, "end": 5.3, "text": "un" }, + { "start": 5.35, "end": 6.1, "text": "video" }, + { "start": 6.15, "end": 6.5, "text": "trascritto" }, + { "start": 7.8, "end": 8.2, "text": "grazie" }, + { "start": 8.25, "end": 8.7, "text": "maria" }, + { "start": 8.9, "end": 9.3, "text": "oggi" }, + { "start": 9.35, "end": 9.9, "text": "parliamo" }, + { "start": 9.95, "end": 10.15, "text": "del" }, + { "start": 10.2, "end": 10.75, "text": "formato" }, + { "start": 10.8, "end": 10.95, "text": "di" }, + { "start": 11.0, "end": 11.85, "text": "salvataggio" } + ], + "paragraphs": [ + { "speaker": "Speaker 0", "start": 0.32, "end": 6.5 }, + { "speaker": "Speaker 1", "start": 7.8, "end": 11.85 } + ] +} +``` + +### `captions.vtt` + +``` +WEBVTT + +NOTE +HAND-CURATED captions (options.captions.updateFromTranscript = false). +They legitimately diverge from the transcript: the hesitation "ehm" is +omitted, line breaks are chosen for reading, and the second cue extends +past the end of its last word (6.5s -> 6.7s) to leave time to read it. +A conforming reader MUST NOT regenerate them or re-align them to the +transcript. + +00:00:00.320 --> 00:00:03.100 +Benvenuti a Hyperaudio, +il modo più semplice + +00:00:03.150 --> 00:00:06.700 +di montare un video trascritto. + +00:00:07.800 --> 00:00:11.850 +Grazie Maria, oggi parliamo +del formato di salvataggio. +``` + +### `media/` + +In a real file this folder contains the original media, byte for byte, with +its filename preserved (here: `intervista-maria.mp4`, a STORE entry). Omitted +from this example. diff --git a/index.html b/index.html index e6aaceb3..189070df 100644 --- a/index.html +++ b/index.html @@ -49,7 +49,7 @@ - + @@ -108,7 +108,7 @@ } - + @@ -371,13 +371,7 @@
    -
    -

    Recents

    -
    - -
    -
    +
    @@ -983,9 +977,8 @@

    Caption Regeneration

    - - + @@ -1017,7 +1010,7 @@

    Caption Regeneration

    - + @@ -1032,6 +1025,9 @@

    Caption Regeneration

    + + + diff --git a/js/editor-audio-cut.js b/js/editor-audio-cut.js index fadfb87a..e4f93d69 100644 --- a/js/editor-audio-cut.js +++ b/js/editor-audio-cut.js @@ -57,6 +57,34 @@ document.querySelector('#remove-gaps-threshold').addEventListener('input', applyGapSettings); document.querySelector('#remove-gaps-buffer').addEventListener('input', applyGapSettings); + // Read/apply for the project save module (js/hyperaudio-save.js): the gap + // settings are module-locals here, and a loaded .hyperaudio project must be + // able to restore them. Applying goes through the UI controls so the dialog + // stays in sync, then reuses the normal applyGapSettings() path. + window.getGapRemovalSettings = function () { + return { + enabled: removeGapsEnabled, + thresholdMs: Math.round(gapThreshold * 1000), + bufferMs: Math.round(gapBuffer * 1000), + }; + }; + window.applyGapRemovalSettings = function (settings) { + if (!settings || typeof settings !== 'object') return; + const enabledEl = document.querySelector('#remove-gaps-enabled'); + const thresholdEl = document.querySelector('#remove-gaps-threshold'); + const bufferEl = document.querySelector('#remove-gaps-buffer'); + if (typeof settings.enabled === 'boolean' && enabledEl) { + enabledEl.checked = settings.enabled; + } + if (Number.isFinite(settings.thresholdMs) && settings.thresholdMs > 0 && thresholdEl) { + thresholdEl.value = String(settings.thresholdMs); + } + if (Number.isFinite(settings.bufferMs) && settings.bufferMs >= 0 && bufferEl) { + bufferEl.value = String(settings.bufferMs); + } + applyGapSettings(); + }; + function updateRemoveGapsBtnState() { const dot = document.querySelector('#remove-gaps-active-dot'); if (!dot) return; diff --git a/js/editor-file-menu.js b/js/editor-file-menu.js index 17775dcc..354a0d30 100644 --- a/js/editor-file-menu.js +++ b/js/editor-file-menu.js @@ -1,11 +1,8 @@ /* Extracted verbatim from index.html (#334) — loaded as a classic script in the same document order. */ - // The FILE menu's Save/Load Local Storage dialogs are gone (#436): Recents - // autosaves everything (#435), rows rename/duplicate/delete inline (#434), - // and deleting the loaded entry offers Restore — so the dialogs had no - // remaining job. This just renders the initial Recents list. - loadLocalStorageOptions(); - + // The legacy Local Storage flows are gone (#451): projects live as + // .hyperaudio files (Save button / Import Project). Only the caption + // regeneration wiring remains — it was never persistence. document .querySelector("#regenerate-captions") .addEventListener("click", function () { diff --git a/js/html-json-converter.js b/js/html-json-converter.js index cc839e64..2a5f43da 100644 --- a/js/html-json-converter.js +++ b/js/html-json-converter.js @@ -22,6 +22,9 @@ * ] * } * - Times in SECONDS (floating point) + * - A redacted (struck-out) word carries "struck": true; the default (false) + * is not serialized. In HTML a redaction is the inline style + * text-decoration: line-through on the word span (see editor-audio-cut.js). * * HTML (Legacy format): *
    @@ -142,9 +145,10 @@ function jsonToHTML(jsonData) { const endMs = Math.round(word.end * 1000); const durationMs = endMs - startMs; const trail = word.space === false ? '' : ' '; + const strike = word.struck === true ? ' style="text-decoration: line-through;"' : ''; const gluedToPrev = wordIndex > 0 && paragraphWords[wordIndex - 1].space === false; const lead = wordIndex === 0 ? ' ' : gluedToPrev ? '' : '\n '; - html += `${lead}${escapeHTMLText(word.text)}${trail}`; + html += `${lead}${escapeHTMLText(word.text)}${trail}`; }); html += '\n'; @@ -161,9 +165,10 @@ function jsonToHTML(jsonData) { const endMs = Math.round(word.end * 1000); const durationMs = endMs - startMs; const trail = word.space === false ? '' : ' '; + const strike = word.struck === true ? ' style="text-decoration: line-through;"' : ''; const gluedToPrev = wordIndex > 0 && words[wordIndex - 1].space === false; const lead = wordIndex === 0 ? ' ' : gluedToPrev ? '' : '\n '; - html += `${lead}${escapeHTMLText(word.text)}${trail}`; + html += `${lead}${escapeHTMLText(word.text)}${trail}`; }); html += '\n'; @@ -252,6 +257,13 @@ function htmlToJSON(html) { word.space = false; } + // Preserve redactions: the editor marks a struck-out word with the + // inline style text-decoration: line-through (editor-audio-cut.js). + // Omitted (the common case) means not struck. + if (/line-through/.test(span.getAttribute('style') || '')) { + word.struck = true; + } + words.push(word); } }); diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js deleted file mode 100644 index 519b5fd5..00000000 --- a/js/hyperaudio-lite-editor-storage.js +++ /dev/null @@ -1,1200 +0,0 @@ -/* - * HyperTranscriptStorage class - * @param {string} hypertranscript - the html of the hypertranscript - * @param {string} video - the url of the video - * @param {string} summary - the text of the summary - * @param {array} topics - an array of topics - * @param {string} captions - VTT format - * @param {object} meta - entry metadata: display name, media key, timestamps, - * caption-sync flag (see below) - * @return {void} - */ -class HyperTranscriptStorage { - constructor(hypertranscript, video, summary, topics, captions, meta) { - this.hypertranscript = hypertranscript; - this.video = video; - this.summary = summary; - this.topics = topics; - this.captions = captions; - this.meta = meta; - } -} - -/* - * Storage model (#434) - * - * Entries are keyed by a STABLE GENERATED ID (`hyperaudio:doc:`), never by - * their display name. The name lives in meta.name, so renaming is a one-field - * update and two entries may share a display name candidate (the second gets a - * " (2)" suffix) without one overwriting the other. Cached local media in - * IndexedDB is keyed by meta.mediaKey — the doc key for new entries — so a - * rename never has to re-key a (possibly large) media blob. - * - * meta: { - * name: display name shown in Recents, - * mediaKey: IndexedDB key of the cached local media (if any), - * created / updated: epoch ms; `updated` drives the list order, - * updateCaptionsFromTranscript: existing caption-sync flag, - * } - * - * LEGACY entries (`.hyperaudio`, where the key IS the name) are migrated - * in place the first time the list renders: same JSON, new key, name/mediaKey - * carried into meta (media stays under its old key via mediaKey). An entry - * that does not parse is left on its legacy key — still listed, still - * deletable, and the defensive read path keeps clicks from throwing (#410). - */ - -const fileExtension = ".hyperaudio"; -const DOC_KEY_PREFIX = "hyperaudio:doc:"; -const MEDIA_DATABASE = "hyperaudioMedia"; -const MEDIA_STORE = "media"; - -// The storage key of the entry currently loaded in the editor (null when the -// document on screen has never been saved). Save updates this entry in place; -// delete clears it. -let activeDocKey = null; - -// docKey + '|' + blob-URL of the media most recently written to IndexedDB, so -// debounced autosaves skip re-encoding an unchanged blob (see the save path). -let savedMediaStamp = null; - -// True only while the auto-add save runs (hyperaudioInit). The engines dispatch -// that event BEFORE regenerating captions, so the caption track — and the -// summary/topics panels — still hold the PREVIOUS document's content at that -// moment; capturing them stamped the intro demo's captions into fresh entries. -// An auto-added entry stores no derived state: captions regenerate from the -// transcript on load, and the first edit-autosave captures the real ones. -let suppressDerivedCapture = false; - -function isDocKey(key) { - return typeof key === "string" && key.startsWith(DOC_KEY_PREFIX); -} - -function isLegacyKey(key) { - return typeof key === "string" && !isDocKey(key) && key.indexOf(fileExtension) > 0; -} - -function legacyNameFromKey(key) { - return key.substring(0, key.lastIndexOf(fileExtension)); -} - -function newDocKey() { - return DOC_KEY_PREFIX + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); -} - -// Display name of an entry: meta.name, else (legacy) the name embedded in the -// key, else a fallback so a malformed entry still renders as a row. -function entryName(key, entry) { - if (entry && entry.meta && typeof entry.meta.name === "string" && entry.meta.name !== "") { - return entry.meta.name; - } - if (isLegacyKey(key)) { - return legacyNameFromKey(key); - } - return "Untitled"; -} - -// IndexedDB key of an entry's cached media. Migrated entries carry their -// legacy name here; unparseable legacy entries fall back to the key's name so -// deleting one still clears its media. -function entryMediaKey(key, entry) { - if (entry && entry.meta && typeof entry.meta.mediaKey === "string" && entry.meta.mediaKey !== "") { - return entry.meta.mediaKey; - } - return isLegacyKey(key) ? legacyNameFromKey(key) : null; -} - -// A display name not used by any other entry: "name", else "name (2)", "name -// (3)", ... `excludeKey` lets an entry keep (or re-save under) its own name. -function uniqueEntryName(desired, storage, excludeKey) { - const names = new Set(); - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i); - if (key === excludeKey) continue; - if (isDocKey(key) || isLegacyKey(key)) { - names.add(entryName(key, readTranscriptEntry(key, storage))); - } - } - if (!names.has(desired)) return desired; - let n = 2; - while (names.has(`${desired} (${n})`)) n++; - return `${desired} (${n})`; -} - -// All saved entries as {key, name, updated}, last-edited first — an entry -// never edited since creation sorts by its creation date (save stamps both, -// migration stamps created). Ties, and entries with no date at all, break -// alphabetically. -function listDocEntries(storage) { - const rows = []; - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i); - if (!isDocKey(key) && !isLegacyKey(key)) continue; - const entry = readTranscriptEntry(key, storage); - const meta = (entry && entry.meta) || {}; - rows.push({ - key, - name: entryName(key, entry), - updated: meta.updated || meta.created || 0, - starred: meta.starred === true, - }); - } - rows.sort((a, b) => (b.updated - a.updated) || a.name.localeCompare(b.name)); - return rows; -} - -/* - * Star / unstar an entry (#440). Like rename, this deliberately does not - * touch `updated` — pinning must not reorder anything by itself. - * @return {boolean} whether the change was applied - */ -function setTranscriptStarred(fileKey, starred, storage = window.localStorage) { - const entry = readTranscriptEntry(fileKey, storage); - if (entry === null) return false; - entry.meta = Object.assign({}, entry.meta, { starred: starred === true }); - try { - storage.setItem(fileKey, JSON.stringify(entry)); - } catch (error) { - console.error('Error starring transcript:', error); - return false; - } - return true; -} - -// One-time upgrade of legacy name-keyed entries to ID-keyed entries. Runs -// every list render but is a no-op once nothing legacy-parseable remains. -function migrateLegacyEntries(storage) { - const legacyKeys = []; - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i); - if (isLegacyKey(key)) legacyKeys.push(key); - } - legacyKeys.forEach((key) => { - const entry = readTranscriptEntry(key, storage); - if (!entry || typeof entry.hypertranscript !== "string") return; // leave it; still listed + deletable - const name = legacyNameFromKey(key); - // The true creation date was never recorded — stamp migration time as the - // proxy so the entry sorts by date (updated || created) from here on. - entry.meta = Object.assign({}, entry.meta, { name, mediaKey: name, created: Date.now() }); - try { - storage.setItem(newDocKey(), JSON.stringify(entry)); - storage.removeItem(key); - } catch (e) { - // quota — keep the legacy key rather than risk losing the entry - console.error("Could not migrate saved transcript:", e); - } - }); -} - -/* - * Completely remove the existing caption and insert a fresh, empty one. - * - * On a Recents (or any media) load the