From 976f2e61531d79a316085f124a808e1e1361e58b Mon Sep 17 00:00:00 2001 From: Marco Scarselli Date: Fri, 10 Jul 2026 20:20:37 +0200 Subject: [PATCH 01/22] Preserve struck words in the HTML/JSON round-trip; expose gap-removal settings (#403) The converter dropped redactions (inline line-through) on both directions, so any JSON export/import silently resurrected every cut. Words now carry "struck": true (default not serialized). editor-audio-cut.js exposes getGapRemovalSettings/applyGapRemovalSettings so a loaded project can restore them through the normal UI path. --- js/editor-audio-cut.js | 28 ++++++++++++++++++++++++++++ js/html-json-converter.js | 16 ++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) 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/html-json-converter.js b/js/html-json-converter.js index 58713d30..2770ed72 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): *
@@ -114,9 +117,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}${word.text}${trail}`; + html += `${lead}${word.text}${trail}`; }); html += '\n'; @@ -133,9 +137,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}${word.text}${trail}`; + html += `${lead}${word.text}${trail}`; }); html += '\n'; @@ -222,6 +227,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); } }); From f8cc2ac2e12c03bdb6e8428ee0c9c18673f7ae39 Mon Sep 17 00:00:00 2001 From: Marco Scarselli Date: Fri, 10 Jul 2026 20:20:50 +0200 Subject: [PATCH 02/22] Add .hyperaudio project save/open: format, container, OPFS working copy, UI (#403) One module (js/hyperaudio-save.js) in five layers: format (build/validate hyperaudio.json) and container (zip/unzip, whitelist-read) are pure and node-tested; OPFS holds the single working copy with autosave; the bridge gathers/applies editor state; the UI adds Save/Open project to the file menu and restores the working copy at boot. JSZip 3.10.1 vendored (MIT branch of its dual license) and precached. 15 unit tests + 4 e2e (real download, OPFS restore across reload). --- __TEST__/e2e/project-save.spec.mjs | 150 ++++ __TEST__/unit/hyperaudio-save.test.mjs | 216 +++++ index.html | 3 + js/hyperaudio-save.js | 1071 ++++++++++++++++++++++++ js/vendor/README.md | 1 + js/vendor/jszip-3.10.1.min.js | 13 + package-lock.json | 99 +++ package.json | 7 +- serviceworker.js | 2 + 9 files changed, 1559 insertions(+), 3 deletions(-) create mode 100644 __TEST__/e2e/project-save.spec.mjs create mode 100644 __TEST__/unit/hyperaudio-save.test.mjs create mode 100644 js/hyperaudio-save.js create mode 100644 js/vendor/jszip-3.10.1.min.js diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs new file mode 100644 index 00000000..6ff0c327 --- /dev/null +++ b/__TEST__/e2e/project-save.spec.mjs @@ -0,0 +1,150 @@ +// .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'); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/index.html'); + await page.waitForSelector('#hypertranscript [data-m]'); +}); + +test('menu items and hidden input are injected', async ({ page }) => { + await expect(page.locator('#file-dropdown #project-save-hyperaudio')).toHaveText('Save Project (.hyperaudio)'); + await expect(page.locator('#file-dropdown #project-open-hyperaudio')).toHaveText('Open Project…'); + 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 + await expect(page.locator('#remove-gaps-enabled')).toBeChecked(); + await expect(page.locator('#remove-gaps-threshold')).toHaveValue('700'); + await expect(page.locator('#save-localstorage-filename')).toHaveValue('E2E Project'); + 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.querySelector('#project-save-hyperaudio').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'); + await expect(page.locator('#save-localstorage-filename')).toHaveValue('E2E Project'); + const src = await page.evaluate(() => document.querySelector('#hyperplayer').src); + expect(src).toMatch(/^blob:/); +}); diff --git a/__TEST__/unit/hyperaudio-save.test.mjs b/__TEST__/unit/hyperaudio-save.test.mjs new file mode 100644 index 00000000..391fe28d --- /dev/null +++ b/__TEST__/unit/hyperaudio-save.test.mjs @@ -0,0 +1,216 @@ +// Unit tests for the .hyperaudio save format (spec: docs/format/hyperaudio-format.md). +// 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')); +}); + +/* ---------- 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 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'); +}); diff --git a/index.html b/index.html index e4f18665..28fca97c 100644 --- a/index.html +++ b/index.html @@ -1052,6 +1052,9 @@

Load from Local Storage

+ + + diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js new file mode 100644 index 00000000..a1bdf5e5 --- /dev/null +++ b/js/hyperaudio-save.js @@ -0,0 +1,1071 @@ +/* + * ============================================================================ + * .hyperaudio PROJECT SAVE — format, container, OPFS working copy, UI + * ============================================================================ + * + * Implements the .hyperaudio format v1.0 (full spec + worked example: issue #403). + * The format in ten lines — a renamed ZIP; a working SAVE, never an export: + * mimetype first entry, STORE: "application/vnd.hyperaudio+zip" + * hyperaudio.json source of truth: format version + media descriptor + * + options + texts + provenance + transcript + * transcript.html editor-native copy (compat + sanitized recovery) + * transcript.original.json machine output, immutable, optional + * captions.vtt MAY legitimately diverge from the transcript + * (options.captions.updateFromTranscript: false) + * media/ byte-for-byte, never re-encoded, STORE entry + * JSON times in SECONDS (float), DOM in ms; defaults (space:true, struck:false) + * not serialized; readers ignore unknown entries/fields and reject newer majors; + * no API keys, no app preferences, no rendered artifacts in the container. + * + * Five internal layers; only the BRIDGE touches the editor's DOM: + * 1. FORMAT build/validate hyperaudio.json (pure — node-testable) + * 2. CONTAINER zip/unzip via JSZip, whitelist-read (pure — node-testable) + * 3. OPFS work/ = the exploded container, autosave, dirty state + * 4. BRIDGE gather() editor state / apply() a loaded project + * 5. UI menu items in #file-dropdown, hidden file input, boot restore + * + * The FORMAT and CONTAINER layers are exported for node --test and are the + * pieces a native app would reuse. + */ + +(function () { + 'use strict'; + + const FORMAT_NAME = 'hyperaudio'; + const FORMAT_VERSION = '1.0'; + const READER_MAJOR = 1; + const CONTAINER_MIMETYPE = 'application/vnd.hyperaudio+zip'; + const FILE_EXTENSION = '.hyperaudio'; + + const ENTRY = { + mimetype: 'mimetype', + json: 'hyperaudio.json', + html: 'transcript.html', + original: 'transcript.original.json', + captions: 'captions.vtt', + }; + const MEDIA_DIR = 'media/'; + + // Reader security (spec § 10): cap on text entries before/after inflating + // (anti zip-bomb — an hour of speech is hundreds of KB, 50 MB is generous). + const TEXT_ENTRY_MAX_BYTES = 50 * 1024 * 1024; + + // App-side soft warning thresholds for zipping media in memory (JSZip buffers + // the archive): warn, never block — a student must always be able to take + // their work out of the browser. + const LARGE_MEDIA_WARN_BYTES = 500 * 1024 * 1024; + const LARGE_MEDIA_WARN_BYTES_LOWMEM = 200 * 1024 * 1024; + + const WORK_DIR = 'work'; + const APP_STATE_FILE = 'app-state.json'; + // Synchronous boot hint: OPFS can only be probed async, so the autosave + // maintains this flag and boot reads it before deciding to restore. + const WORK_HINT_KEY = 'hyperaudioWorkPresent'; + + /* ========================================================================== + * 1. FORMAT — build/validate hyperaudio.json (pure) + * ======================================================================== */ + + // "major.minor" → {ok, major, minor, code?}. Malformed versions and majors + // above the reader's are not loadable (ignore-unknown / reject-major, § 8). + function checkFormatVersion(version) { + if (typeof version !== 'string') return { ok: false, code: 'version-malformed' }; + const m = version.match(/^(\d+)\.(\d+)$/); + if (m === null) return { ok: false, code: 'version-malformed' }; + const major = parseInt(m[1], 10); + const minor = parseInt(m[2], 10); + if (major > READER_MAJOR) return { ok: false, code: 'version-major', major, minor }; + return { ok: true, major, minor }; + } + + // media.path MUST be media/: one segment, no "..", nothing absolute + // (spec § 10.2). The zip is only ever read by known names, so this is the one + // file-supplied name we accept — and only in this shape. + function validateMediaPath(path) { + return typeof path === 'string' + && /^media\/[^/\\]+$/.test(path) + && path.indexOf('..') === -1; + } + + // Validate a parsed hyperaudio.json before use (spec § 10.4). Returns + // {ok, errors: [{code, message}]}; the first error's code drives the reader's + // behaviour (reject vs recovery, § 4). + function validateProjectJson(project) { + const errors = []; + const fail = (code, message) => { errors.push({ code, message }); }; + + if (project === null || typeof project !== 'object') { + fail('unreadable', 'not a JSON object'); + return { ok: false, errors }; + } + if (project.format !== FORMAT_NAME) { + fail('format', 'format is not "hyperaudio"'); + } + const version = checkFormatVersion(project.formatVersion); + if (!version.ok) { + fail(version.code, `formatVersion "${project.formatVersion}" is not loadable`); + } + + const media = project.media; + if (media === null || typeof media !== 'object') { + fail('media', 'missing media descriptor'); + } else if (media.kind === 'original') { + if (!validateMediaPath(media.path)) { + fail('media-path', `media.path "${media.path}" violates the media/ pattern`); + } + } else if (media.kind === 'link') { + if (typeof media.url !== 'string' || !/^https?:/i.test(media.url)) { + fail('media', 'media.kind "link" requires an http(s) url'); + } + } else { + fail('media-kind', `unknown media.kind "${media && media.kind}"`); + } + + const transcript = project.transcript; + if (transcript === null || typeof transcript !== 'object' || !Array.isArray(transcript.words)) { + fail('transcript', 'missing transcript.words'); + } else { + const badWord = transcript.words.find((w) => + w === null || typeof w !== 'object' + || typeof w.text !== 'string' + || !Number.isFinite(w.start) || !Number.isFinite(w.end) + || w.start < 0 || w.end < w.start); + if (badWord !== undefined) { + fail('transcript', 'transcript.words contains an invalid word entry'); + } + if (transcript.paragraphs !== undefined && !Array.isArray(transcript.paragraphs)) { + fail('transcript', 'transcript.paragraphs is not an array'); + } + } + + return { ok: errors.length === 0, errors }; + } + + // Assemble a complete hyperaudio.json object from gathered state. Defaults + // (space: true, struck: false) are already omitted by htmlToJSON; times are + // seconds throughout (the DOM's data-m/data-d are ms — ms = round(s × 1000)). + function buildProjectJson(state) { + const project = { + format: FORMAT_NAME, + formatVersion: FORMAT_VERSION, + generator: { + name: 'hyperaudio-lite-editor', + version: state.generatorVersion || '', + }, + created: state.created, + modified: state.modified, + media: state.media, + options: { + gapRemoval: state.options.gapRemoval, + captions: { updateFromTranscript: state.options.updateCaptionsFromTranscript !== false }, + view: state.options.view, + }, + texts: { + title: state.texts.title || '', + language: state.texts.language || '', + summary: state.texts.summary || '', + topics: Array.isArray(state.texts.topics) ? state.texts.topics : [], + }, + transcript: state.transcript, + }; + if (state.provenance && (state.provenance.engine || state.provenance.model)) { + project.provenance = Object.assign({}, state.provenance); + if (state.hasOriginal) { + project.provenance.originalTranscript = ENTRY.original; + } + } + return project; + } + + function serializeProjectJson(project) { + return JSON.stringify(project, null, 2); + } + + /* ========================================================================== + * 2. CONTAINER — zip/unzip (pure; JSZip implementation injected) + * ======================================================================== */ + + // Build the container. files: {json, html, originalJson?, captionsVtt?, + // media?: {name, data}}. The mimetype entry goes FIRST and STORED so the MIME + // type sits at byte offset 38 (EPUB convention, § 2.1); the media entry is + // STORED because media formats are already compressed. + function zipProject(files, JSZipImpl, outType) { + const zip = new JSZipImpl(); + zip.file(ENTRY.mimetype, CONTAINER_MIMETYPE, { compression: 'STORE' }); + zip.file(ENTRY.json, files.json); + if (files.html) zip.file(ENTRY.html, files.html); + if (files.originalJson) zip.file(ENTRY.original, files.originalJson); + if (files.captionsVtt) zip.file(ENTRY.captions, files.captionsVtt); + if (files.media) { + zip.file(MEDIA_DIR + files.media.name, files.media.data, { compression: 'STORE', binary: true }); + } + return zip.generateAsync({ + type: outType || 'uint8array', + compression: 'DEFLATE', + streamFiles: true, + }); + } + + // Whitelist-read of a container (spec § 10.1): only entries with known names + // are ever read; everything else is ignored. Returns {project, htmlText, + // captionsVtt, originalText, mediaData, warnings} — or {recovered: true, + // htmlText, ...} when hyperaudio.json is missing/unreadable but the HTML + // compatibility copy can be used (spec § 4 recovery). Throws {code, message} + // on rejection (version-major, media-kind, unreadable, entry-too-large). + async function unzipProject(data, JSZipImpl) { + const rejection = (code, message) => { + const err = new Error(message); + err.code = code; + return err; + }; + + let zip; + try { + zip = await JSZipImpl.loadAsync(data); + } catch (e) { + throw rejection('unreadable', 'not a readable zip archive'); + } + + const warnings = []; + + async function readTextEntry(name) { + const entry = zip.file(name); + if (entry === null) return null; + // Size cap before inflating when the metadata is available, and again + // after as a backstop (spec § 10.3). + const declared = entry._data && entry._data.uncompressedSize; + if (typeof declared === 'number' && declared > TEXT_ENTRY_MAX_BYTES) { + throw rejection('entry-too-large', `${name} exceeds the ${TEXT_ENTRY_MAX_BYTES} byte cap`); + } + const text = await entry.async('string'); + if (text.length > TEXT_ENTRY_MAX_BYTES) { + throw rejection('entry-too-large', `${name} exceeds the ${TEXT_ENTRY_MAX_BYTES} byte cap`); + } + return text; + } + + const mimetypeText = await readTextEntry(ENTRY.mimetype); + if (mimetypeText === null) { + warnings.push('missing mimetype entry (tolerated)'); + } else if (mimetypeText.trim() !== CONTAINER_MIMETYPE) { + warnings.push('unexpected mimetype entry (tolerated)'); + } + + const htmlText = await readTextEntry(ENTRY.html); + const captionsVtt = await readTextEntry(ENTRY.captions); + const originalText = await readTextEntry(ENTRY.original); + const jsonText = await readTextEntry(ENTRY.json); + + let project = null; + if (jsonText !== null) { + try { + project = JSON.parse(jsonText); + } catch (e) { + project = null; + } + } + + const recover = (why) => { + if (htmlText === null) { + throw rejection('unreadable', why + ' and no transcript.html to recover from'); + } + warnings.push(why + ' — recovered from transcript.html'); + return { recovered: true, project: null, htmlText, captionsVtt, originalText, mediaData: null, mediaEntryName: null, warnings }; + }; + + if (project === null) { + return recover('hyperaudio.json missing or unparseable'); + } + + const validation = validateProjectJson(project); + if (!validation.ok) { + const codes = validation.errors.map((e) => e.code); + // reject-major and unknown media.kind are hard refusals (spec § 8, § 7.3) + // — never silently recovered, the user needs the real message. + if (codes.indexOf('version-major') !== -1) { + throw rejection('version-major', 'this project requires a newer version of the editor'); + } + if (codes.indexOf('media-kind') !== -1) { + throw rejection('media-kind', 'this project uses a media format this editor does not support yet'); + } + return recover('hyperaudio.json failed validation (' + codes.join(', ') + ')'); + } + + let mediaData = null; + let mediaEntryName = null; + if (project.media.kind === 'original') { + const mediaEntry = zip.file(project.media.path); + if (mediaEntry === null) { + warnings.push('declared media entry is missing — media unavailable'); + } else { + mediaData = await mediaEntry.async('uint8array'); + mediaEntryName = project.media.path.slice(MEDIA_DIR.length); + } + } + + return { recovered: false, project, htmlText, captionsVtt, originalText, mediaData, mediaEntryName, warnings }; + } + + /* ========================================================================== + * Exports for node --test (pure layers only), then browser-only code. + * ======================================================================== */ + + const pure = { + FORMAT_NAME, FORMAT_VERSION, CONTAINER_MIMETYPE, ENTRY, MEDIA_DIR, + TEXT_ENTRY_MAX_BYTES, + checkFormatVersion, validateMediaPath, validateProjectJson, + buildProjectJson, serializeProjectJson, + zipProject, unzipProject, + }; + if (typeof module !== 'undefined' && module.exports) { + module.exports = pure; + } + if (typeof document === 'undefined' || typeof navigator === 'undefined') { + return; // node context: pure layers only + } + + /* ========================================================================== + * 3. OPFS — work/ is the exploded container; autosave; dirty state + * ======================================================================== */ + + const opfsAvailable = !!(navigator.storage && navigator.storage.getDirectory + && typeof FileSystemFileHandle !== 'undefined' + && FileSystemFileHandle.prototype.createWritable); + + async function getWorkDir(create) { + const root = await navigator.storage.getDirectory(); + return root.getDirectoryHandle(WORK_DIR, { create: !!create }); + } + + async function writeFileTo(dir, name, data) { + const handle = await dir.getFileHandle(name, { create: true }); + const writable = await handle.createWritable(); + await writable.write(data); + await writable.close(); + } + + async function readTextFrom(dir, name) { + try { + const handle = await dir.getFileHandle(name); + const file = await handle.getFile(); + return await file.text(); + } catch (e) { + return null; + } + } + + async function readMediaFileFromWork(filename) { + try { + const dir = await getWorkDir(false); + const mediaDir = await dir.getDirectoryHandle('media'); + const handle = await mediaDir.getFileHandle(filename); + return await handle.getFile(); + } catch (e) { + return null; + } + } + + async function clearWork() { + try { + const root = await navigator.storage.getDirectory(); + await root.removeEntry(WORK_DIR, { recursive: true }); + } catch (e) { /* nothing to clear */ } + try { localStorage.removeItem(WORK_HINT_KEY); } catch (e) { /* private mode */ } + } + + async function readAppState() { + try { + const root = await navigator.storage.getDirectory(); + const text = await readTextFrom(root, APP_STATE_FILE); + return text !== null ? JSON.parse(text) : {}; + } catch (e) { + return {}; + } + } + + async function patchAppState(patch) { + try { + const root = await navigator.storage.getDirectory(); + const state = Object.assign(await readAppState(), patch); + await writeFileTo(root, APP_STATE_FILE, JSON.stringify(state)); + return state; + } catch (e) { + return null; + } + } + + // The deterministic "dirty" rule (discussion doc § 13): work has been written + // since the last .hyperaudio download. Download marking is optimistic — the + // browser gives no completion signal for . + async function isDirty() { + if (!session.active) return false; + const state = await readAppState(); + return (state.lastWorkWriteAt || 0) > (state.lastDownloadAt || 0); + } + + /* ========================================================================== + * 4. BRIDGE — the only layer that touches the editor's DOM + * ======================================================================== */ + + // Everything the module knows about the open project. Hydrated on new + // transcript (hyperaudioInit), on open, and on boot restore. + const session = { + active: false, + created: null, + provenance: null, // {engine, model, transcribedAt} + provenanceAt: 0, // when the engine reported it (staleness guard) + language: '', + mediaFile: null, // the original File, captured at import + hasOriginal: false, + originalJson: null, // the origin as serialized JSON (in-memory copy; work/ holds it across reloads) + }; + let suppressCapture = false; // true while apply() replays a loaded project + let autosaveTimer = null; + + function nowIso() { + return new Date().toISOString(); + } + + function getEditorHtml() { + if (typeof getTranscriptData === 'function') { + return getTranscriptData(); + } + const el = document.querySelector('#hypertranscript'); + return el !== null ? el.innerHTML.replace(/ class=".*?"/g, '') : ''; + } + + function getCaptionsVtt() { + const track = document.querySelector('#hyperplayer-vtt'); + if (track === null || !track.src || !track.src.startsWith('data:')) return ''; + try { + return decodeURIComponent(track.src.split(',')[1] || ''); + } catch (e) { + return ''; + } + } + + function currentMediaDescriptor() { + const player = document.querySelector('#hyperplayer'); + const duration = player && Number.isFinite(player.duration) + ? Math.round(player.duration * 1000) / 1000 : 0; + const src = player !== null ? player.src : ''; + if (/^https?:/i.test(src)) { + // The player is on a remote URL (URL-mode transcription): any File + // captured for a PREVIOUS project is stale — the URL wins. + // Kept as a link descriptor in the WORKING COPY only — saveToFile() + // refuses to write it into a downloadable container (v1 writes kind + // "original" only; "link" is a future formula, spec § 7.2). + return { kind: 'link', path: null, url: src, filename: '', mimeType: '', durationSeconds: duration, sizeBytes: 0 }; + } + if (session.mediaFile !== null) { + return { + kind: 'original', + path: MEDIA_DIR + session.mediaFile.name, + url: null, + filename: session.mediaFile.name, + mimeType: session.mediaFile.type || '', + durationSeconds: duration, + sizeBytes: session.mediaFile.size, + }; + } + // Local media playing from a blob:/data: URL that we haven't captured yet + // (e.g. a legacy Recents load) — resolveMediaFile() materialises it lazily. + return { kind: 'original', path: MEDIA_DIR + 'media', url: null, filename: 'media', mimeType: '', durationSeconds: duration, sizeBytes: 0 }; + } + + // Make sure we hold the media as a File. Captured at import normally; for + // blob:/data: sources (legacy loads) fetch the player source once and name + // it from the MIME type. + async function resolveMediaFile() { + const player = document.querySelector('#hyperplayer'); + const src = player !== null ? player.src : ''; + // A remote URL in the player means URL-mode: any previously captured File + // belongs to an older project and must not be saved as this one's media. + if (!src || /^https?:/i.test(src)) return null; + if (session.mediaFile !== null) return session.mediaFile; + try { + const blob = await (await fetch(src)).blob(); + const ext = (blob.type.split('/')[1] || 'bin').split(';')[0]; + session.mediaFile = new File([blob], 'media.' + ext, { type: blob.type }); + return session.mediaFile; + } catch (e) { + return null; + } + } + + // Snapshot the full editor state for the writer. Pure DOM reads; the media + // bytes themselves are handled separately (write-once / resolve-on-demand). + function gather() { + const html = getEditorHtml(); + const transcript = htmlToJSON(html); + const versionMeta = document.querySelector('meta[name="version"]'); + const titleField = document.querySelector('#save-localstorage-filename'); + const summaryEl = document.getElementById('summary'); + const topicsEl = document.getElementById('topics'); + const media = currentMediaDescriptor(); + const title = (titleField !== null && titleField.value.trim() !== '') + ? titleField.value.trim() + : (media.filename || 'project'); + + return { + generatorVersion: versionMeta !== null ? versionMeta.content : '', + created: session.created || nowIso(), + modified: nowIso(), + media, + options: { + gapRemoval: typeof window.getGapRemovalSettings === 'function' + ? window.getGapRemovalSettings() + : { enabled: false, thresholdMs: 500, bufferMs: 100 }, + updateCaptionsFromTranscript: typeof updateCaptionsFromTranscript !== 'undefined' + ? updateCaptionsFromTranscript : true, + view: { + showSpeakers: !!(document.querySelector('#show-speakers') || {}).checked, + showTimecodes: !!(document.querySelector('#show-timecodes') || {}).checked, + }, + }, + texts: { + title, + language: session.language || '', + summary: summaryEl !== null ? summaryEl.textContent.trim() : '', + topics: topicsEl !== null + ? topicsEl.textContent.split(',').map((t) => t.trim()).filter((t) => t.length > 0) + : [], + }, + provenance: session.provenance, + hasOriginal: session.hasOriginal, + transcript, + html, + }; + } + + // Build the editor's transcript DOM from validated JSON — programmatically, + // textContent only (spec § 10.5: never innerHTML on file data). + function buildTranscriptDomFromJson(transcript) { + const words = transcript.words || []; + const paragraphs = (transcript.paragraphs && transcript.paragraphs.length > 0) + ? transcript.paragraphs + : [{ start: -Infinity, end: Infinity, speaker: null }]; + const article = document.createElement('article'); + const section = document.createElement('section'); + article.appendChild(section); + + paragraphs.forEach((paragraph) => { + const p = document.createElement('p'); + const paragraphWords = words.filter((w) => w.start >= paragraph.start && w.start < paragraph.end); + if (paragraph.speaker) { + const speaker = document.createElement('span'); + speaker.className = 'speaker'; + speaker.setAttribute('data-m', String(Math.max(0, Math.round(paragraph.start * 1000)))); + speaker.setAttribute('data-d', '0'); + speaker.textContent = '[' + paragraph.speaker + '] '; + p.appendChild(speaker); + } + paragraphWords.forEach((word) => { + const startMs = Math.round(word.start * 1000); + const span = document.createElement('span'); + span.setAttribute('data-m', String(startMs)); + span.setAttribute('data-d', String(Math.max(0, Math.round(word.end * 1000) - startMs))); + if (word.struck === true) span.style.textDecoration = 'line-through'; + span.textContent = word.text + (word.space === false ? '' : ' '); + p.appendChild(span); + }); + if (p.childNodes.length > 0) section.appendChild(p); + }); + return article; + } + + // Recovery sanitiser for transcript.html (spec § 10.5): allowlist rebuild — + // article/section/p/span, data-m/data-d integers, class "speaker", the + // line-through style. Everything else is dropped. + function sanitizeTranscriptHtml(htmlText) { + const doc = new DOMParser().parseFromString(htmlText, 'text/html'); + const article = document.createElement('article'); + const section = document.createElement('section'); + article.appendChild(section); + doc.querySelectorAll('p').forEach((sourceP) => { + const p = document.createElement('p'); + sourceP.querySelectorAll('span[data-m]').forEach((sourceSpan) => { + const m = parseInt(sourceSpan.getAttribute('data-m'), 10); + const d = parseInt(sourceSpan.getAttribute('data-d'), 10); + if (!Number.isFinite(m) || m < 0) return; + const span = document.createElement('span'); + span.setAttribute('data-m', String(m)); + span.setAttribute('data-d', String(Number.isFinite(d) && d >= 0 ? d : 0)); + if (sourceSpan.classList.contains('speaker')) span.className = 'speaker'; + if (/line-through/.test(sourceSpan.getAttribute('style') || '')) { + span.style.textDecoration = 'line-through'; + } + span.textContent = sourceSpan.textContent; + p.appendChild(span); + }); + if (p.childNodes.length > 0) section.appendChild(p); + }); + return article; + } + + // Replay a loaded project into the editor. Mirrors what the legacy + // renderTranscript() does for Recents, but builds the DOM safely from JSON. + function apply(loaded) { + suppressCapture = true; + try { + // Loading always lands in transcript mode; leave caption mode first. + if (typeof captionMode !== 'undefined' && captionMode === true) { + const backBtn = document.querySelector('#transcript-editor-btn'); + if (backBtn !== null) backBtn.click(); + } + + const article = loaded.recovered + ? sanitizeTranscriptHtml(loaded.htmlText) + : buildTranscriptDomFromJson(loaded.project.transcript); + const transcriptEl = document.querySelector('#hypertranscript'); + transcriptEl.replaceChildren(article); + + // Media: original file via an object URL; a "link" descriptor plays the + // remote URL directly (degraded is declared by the caller's messaging). + const player = document.querySelector('#hyperplayer'); + if (loaded.mediaFile) { + player.src = URL.createObjectURL(loaded.mediaFile); + } else if (!loaded.recovered && loaded.project.media.kind === 'link' && loaded.project.media.url) { + player.src = loaded.project.media.url; + } + + // Captions: fresh track (#356 stale-cue teardown), then the saved VTT. + const track = resetCaptionTrack(); + const options = loaded.recovered ? null : loaded.project.options; + if (typeof updateCaptionsFromTranscript !== 'undefined') { + updateCaptionsFromTranscript = options && options.captions + ? options.captions.updateFromTranscript !== false : true; + } + if (loaded.captionsVtt && track !== null) { + track.src = 'data:text/vtt,' + encodeURIComponent(loaded.captionsVtt); + track.kind = 'captions'; + if (player.textTracks[0] !== undefined) player.textTracks[0].mode = 'showing'; + const vttLink = document.querySelector('#download-vtt'); + if (vttLink !== null) vttLink.setAttribute('href', 'data:text/vtt,' + encodeURIComponent(loaded.captionsVtt)); + if (typeof populateCaptionEditorFromVtt === 'function') { + if (typeof captionCache !== 'undefined') captionCache = null; + populateCaptionEditorFromVtt(loaded.captionsVtt); + } + } else { + document.dispatchEvent(new CustomEvent('hyperaudioGenerateCaptionsFromTranscript')); + } + + if (options !== null) { + if (typeof window.applyGapRemovalSettings === 'function' && options.gapRemoval) { + window.applyGapRemovalSettings(options.gapRemoval); + } + const view = options.view || {}; + const speakersToggle = document.querySelector('#show-speakers'); + if (speakersToggle !== null && typeof view.showSpeakers === 'boolean' + && speakersToggle.checked !== view.showSpeakers) { + speakersToggle.checked = view.showSpeakers; + speakersToggle.dispatchEvent(new Event('change')); + } + const timecodesToggle = document.querySelector('#show-timecodes'); + if (timecodesToggle !== null && typeof view.showTimecodes === 'boolean' + && timecodesToggle.checked !== view.showTimecodes) { + timecodesToggle.checked = view.showTimecodes; + timecodesToggle.dispatchEvent(new Event('change')); + } + } + + // Texts (clean data — textContent, never innerHTML on file data). + const texts = loaded.recovered ? null : loaded.project.texts; + const summaryEl = document.getElementById('summary'); + const topicsEl = document.getElementById('topics'); + if (summaryEl !== null) summaryEl.textContent = texts !== null ? (texts.summary || '') : ''; + if (topicsEl !== null) topicsEl.textContent = texts !== null ? (texts.topics || []).join(', ') : ''; + const titleField = document.querySelector('#save-localstorage-filename'); + if (titleField !== null && texts !== null && texts.title) titleField.value = texts.title; + + const cleaned = transcriptEl.innerHTML.replace(/ class=".*?"/g, ''); + const htmlLink = document.querySelector('#download-html'); + if (htmlLink !== null) htmlLink.setAttribute('href', 'data:text/html,' + encodeURIComponent(cleaned)); + + // Re-init playback/highlighting on the fresh transcript, same as the + // legacy loaders do. + hyperaudio(); + document.dispatchEvent(new CustomEvent('hyperaudioTranscriptLoaded')); + } finally { + suppressCapture = false; + } + } + + /* ========================================================================== + * Project lifecycle: new project capture, autosave, save/open + * ======================================================================== */ + + async function writeWorkSnapshot() { + if (!opfsAvailable || !session.active) return; + try { + const state = gather(); + const dir = await getWorkDir(true); + await writeFileTo(dir, ENTRY.json, serializeProjectJson(buildProjectJson(state))); + await writeFileTo(dir, ENTRY.html, state.html); + const vtt = getCaptionsVtt(); + if (vtt !== '') await writeFileTo(dir, ENTRY.captions, vtt); + await patchAppState({ lastWorkWriteAt: Date.now() }); + try { localStorage.setItem(WORK_HINT_KEY, '1'); } catch (e) { /* private mode */ } + } catch (e) { + console.warn('hyperaudio-save: autosave failed', e); + } + } + + function scheduleAutosave() { + if (!opfsAvailable || !session.active || suppressCapture) return; + clearTimeout(autosaveTimer); + autosaveTimer = setTimeout(writeWorkSnapshot, 1500); + } + + async function writeMediaOnce() { + if (!opfsAvailable || session.mediaFile === null) return; + try { + const dir = await getWorkDir(true); + const mediaDir = await dir.getDirectoryHandle('media', { create: true }); + // one media per project: drop any previous file first + for await (const name of mediaDir.keys()) { + if (name !== session.mediaFile.name) await mediaDir.removeEntry(name).catch(() => {}); + } + await writeFileTo(mediaDir, session.mediaFile.name, session.mediaFile); + } catch (e) { + console.warn('hyperaudio-save: media write failed', e); + } + } + + // The origin (spec § 5): written once when a project is born from a + // transcription/import, immutable afterwards, never with struck flags. + async function writeOriginOnce(transcript) { + const clean = { + words: (transcript.words || []).map((w) => { + const word = { start: w.start, end: w.end, text: w.text }; + if (w.space === false) word.space = false; + return word; + }), + paragraphs: transcript.paragraphs || [], + }; + session.hasOriginal = true; + session.originalJson = JSON.stringify(clean, null, 2); + if (!opfsAvailable) return; + try { + const dir = await getWorkDir(true); + await writeFileTo(dir, ENTRY.original, session.originalJson); + } catch (e) { + console.warn('hyperaudio-save: origin write failed', e); + } + } + + // A NEW project begins whenever a transcription or import lands a fresh + // transcript (they all fire hyperaudioInit; legacy Recents loads call + // hyperaudio() directly and do NOT, so they never overwrite the origin). + async function onNewTranscript() { + if (suppressCapture) return; + const transcriptEl = document.querySelector('#hypertranscript'); + if (transcriptEl === null || transcriptEl.querySelector('span[data-m]') === null) return; + session.active = true; + session.created = nowIso(); + session.hasOriginal = false; + // Provenance is only this project's if the engine reported it moments ago + // (imports fire hyperaudioInit without any setTranscriptionInfo call — + // a previous transcription's provenance must not leak into them). + if (Date.now() - session.provenanceAt > 120000) { + session.provenance = null; + session.language = ''; + } + if (opfsAvailable) { + await clearWork(); + await writeOriginOnce(htmlToJSON(getEditorHtml())); + await resolveMediaFile(); + await writeMediaOnce(); + await writeWorkSnapshot(); + } + } + + async function saveToFile() { + const mediaFile = await resolveMediaFile(); + const player = document.querySelector('#hyperplayer'); + if (mediaFile === null) { + if (player !== null && /^https?:/i.test(player.src)) { + alert('This project references remote media (a URL). Version 1 of the .hyperaudio format only saves projects with a local media file — the "link" formula is coming later.'); + } else { + alert('No media loaded — there is nothing to save yet.'); + } + return; + } + + const lowMem = typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 4; + const warnAt = lowMem ? LARGE_MEDIA_WARN_BYTES_LOWMEM : LARGE_MEDIA_WARN_BYTES; + if (mediaFile.size > warnAt) { + const mb = Math.round(mediaFile.size / (1024 * 1024)); + if (!confirm(`The media file is large (~${mb} MB). Building the .hyperaudio file needs roughly that much memory and may take a while. Continue?`)) { + return; + } + } + + session.active = true; + if (session.created === null) session.created = nowIso(); + + const state = gather(); + // The origin travels in every save (spec § 5): the in-memory copy is the + // primary source (also covers browsers without OPFS); work/ carries it + // across reloads. + let originalJson = session.originalJson; + if (originalJson === null && opfsAvailable) { + try { + originalJson = await readTextFrom(await getWorkDir(false), ENTRY.original); + } catch (e) { /* no work dir yet */ } + } + state.hasOriginal = originalJson !== null; + + const JSZipImpl = await loadJSZip(); + const blob = await zipProject({ + json: serializeProjectJson(buildProjectJson(state)), + html: state.html, + originalJson, + captionsVtt: getCaptionsVtt() || null, + media: { name: mediaFile.name, data: mediaFile }, + }, JSZipImpl, 'blob'); + + const safeTitle = (state.texts.title || 'project') + .replace(/\.hyperaudio$/i, '') + .replace(/[\\/:*?"<>|]+/g, '-').trim() || 'project'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = safeTitle + FILE_EXTENSION; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60000); + + await patchAppState({ lastDownloadAt: Date.now() }); + } + + async function openFromFile(file) { + if (await isDirty()) { + const proceed = confirm('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.\n\nPress Cancel to go back (you can save it from FILE → Save Project first), or OK to replace it.'); + if (!proceed) return; + } + + let loaded; + try { + const JSZipImpl = await loadJSZip(); + loaded = await unzipProject(file, JSZipImpl); + } catch (e) { + const messages = { + 'version-major': 'This project was saved by a newer version of the editor and cannot be opened here. Please update the editor.', + 'media-kind': 'This project uses a media formula this editor does not support yet. Please update the editor.', + 'entry-too-large': 'This file contains an oversized entry and was refused for safety.', + 'unreadable': 'This is not a readable .hyperaudio file.', + }; + alert(messages[e.code] || messages['unreadable']); + return; + } + + if (loaded.mediaData !== null && loaded.mediaEntryName !== null) { + const mimeType = loaded.project !== null ? (loaded.project.media.mimeType || '') : ''; + loaded.mediaFile = new File([loaded.mediaData], loaded.mediaEntryName, { type: mimeType }); + } else { + loaded.mediaFile = null; + if (!loaded.recovered && loaded.project.media.kind === 'link') { + alert('Note: this project references its media by URL — it is not self-contained. Playback will use the remote URL.'); + } + } + + suppressCapture = true; + try { + apply(loaded); + + // Hydrate the session from the loaded project and seed work/ so the + // autosave continues from here. + session.active = true; + session.created = (!loaded.recovered && loaded.project.created) || nowIso(); + session.provenance = (!loaded.recovered && loaded.project.provenance) || null; + session.language = (!loaded.recovered && loaded.project.texts && loaded.project.texts.language) || ''; + session.mediaFile = loaded.mediaFile; + session.hasOriginal = loaded.originalText !== null; + session.originalJson = loaded.originalText; + + if (opfsAvailable) { + await clearWork(); + const dir = await getWorkDir(true); + if (loaded.originalText !== null) await writeFileTo(dir, ENTRY.original, loaded.originalText); + await writeMediaOnce(); + } + } finally { + suppressCapture = false; + } + await writeWorkSnapshot(); + + if (loaded.warnings.length > 0) { + console.warn('hyperaudio-save: opened with warnings:', loaded.warnings); + if (loaded.recovered) { + alert('The project file was not fully conformant; the transcript was recovered from its HTML copy. Saving again will produce a fully conformant file.'); + } + } + } + + // Boot restore: the synchronous localStorage hint decides whether to probe + // OPFS at all; the static demo transcript in index.html is simply replaced. + async function restoreFromWork() { + try { + const dir = await getWorkDir(false); + const jsonText = await readTextFrom(dir, ENTRY.json); + if (jsonText === null) { + try { localStorage.removeItem(WORK_HINT_KEY); } catch (e) { /* ignore */ } + return; + } + const project = JSON.parse(jsonText); + const validation = validateProjectJson(project); + if (!validation.ok) { + console.warn('hyperaudio-save: work copy failed validation, leaving demo', validation.errors); + return; + } + const mediaFile = project.media.kind === 'original' + ? await readMediaFileFromWork(project.media.filename) : null; + const captionsVtt = await readTextFrom(dir, ENTRY.captions); + const originalText = await readTextFrom(dir, ENTRY.original); + + apply({ recovered: false, project, captionsVtt, mediaFile }); + session.active = true; + session.created = project.created || nowIso(); + session.provenance = project.provenance || null; + session.language = (project.texts && project.texts.language) || ''; + session.mediaFile = mediaFile; + session.hasOriginal = originalText !== null; + session.originalJson = originalText; + } catch (e) { + console.warn('hyperaudio-save: restore failed, leaving demo', e); + } + } + + /* ========================================================================== + * 5. UI — menu items, hidden input, wiring (self-injected) + * ======================================================================== */ + + let jszipPromise = null; + function loadJSZip() { + if (window.JSZip) return Promise.resolve(window.JSZip); + if (jszipPromise === null) { + jszipPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'js/vendor/jszip-3.10.1.min.js'; + script.onload = () => resolve(window.JSZip); + script.onerror = () => { jszipPromise = null; reject(new Error('failed to load JSZip')); }; + document.head.appendChild(script); + }); + } + return jszipPromise; + } + + function injectUi() { + const dropdown = document.querySelector('#file-dropdown'); + if (dropdown === null) return; + dropdown.insertAdjacentHTML('beforeend', + '' + + '' + + '
  • Save Project (.hyperaudio)
  • ' + + '
  • Open Project…
  • '); + + const input = document.createElement('input'); + input.type = 'file'; + input.id = 'project-open-input'; + input.accept = FILE_EXTENSION; + input.style.display = 'none'; + document.body.appendChild(input); + + document.querySelector('#project-save-hyperaudio').addEventListener('click', () => { + saveToFile().catch((e) => { + console.error('hyperaudio-save: save failed', e); + alert('Saving the project failed: ' + e.message); + }); + }); + document.querySelector('#project-open-hyperaudio').addEventListener('click', () => { + input.value = ''; + input.click(); + }); + input.addEventListener('change', () => { + if (input.files.length === 1) { + openFromFile(input.files[0]).catch((e) => { + console.error('hyperaudio-save: open failed', e); + alert('Opening the project failed: ' + e.message); + }); + } + }); + } + + function wireCapture() { + // The original media File, captured at the existing engine file inputs — + // no engine code is touched. + ['#file-input', '#parakeet-file-input', '#deepgram-file', '#assemblyai-file', '#parakeet-hf-file'] + .forEach((selector) => { + const el = document.querySelector(selector); + if (el === null) return; + el.addEventListener('change', () => { + if (el.files && el.files.length === 1) { + session.mediaFile = el.files[0]; + } + }); + }); + + // New transcript (transcribe / JSON / SRT import) → new project + origin. + document.addEventListener('hyperaudioInit', () => { + onNewTranscript().catch((e) => console.warn('hyperaudio-save: capture failed', e)); + }); + + // Provenance: engines report service/model through setTranscriptionInfo. + const originalSetInfo = window.setTranscriptionInfo; + if (typeof originalSetInfo === 'function') { + window.setTranscriptionInfo = function (info) { + try { + session.provenance = { + engine: (info && info.service ? String(info.service) : '').toLowerCase(), + model: info && info.model ? String(info.model) : '', + transcribedAt: nowIso(), + }; + session.provenanceAt = Date.now(); + session.language = info && info.language ? String(info.language) : session.language; + } catch (e) { /* provenance is best-effort */ } + return originalSetInfo.apply(this, arguments); + }; + } + + // Autosave triggers: transcript edits, caption regeneration, option changes. + const transcriptEl = document.querySelector('#hypertranscript'); + if (transcriptEl !== null) { + transcriptEl.addEventListener('input', scheduleAutosave); + transcriptEl.addEventListener('blur', scheduleAutosave); + } + document.addEventListener('hyperaudioGenerateCaptionsFromTranscript', scheduleAutosave); + ['#remove-gaps-enabled', '#remove-gaps-threshold', '#remove-gaps-buffer', '#show-speakers', '#show-timecodes'] + .forEach((selector) => { + const el = document.querySelector(selector); + if (el !== null) { + el.addEventListener('change', scheduleAutosave); + el.addEventListener('input', scheduleAutosave); + } + }); + } + + function boot() { + injectUi(); + wireCapture(); + let hint = null; + try { hint = localStorage.getItem(WORK_HINT_KEY); } catch (e) { /* private mode */ } + if (opfsAvailable && hint === '1') { + restoreFromWork(); + } + } + + // Expose a small public API for other modules / the console. + window.HyperaudioSave = { + saveToFile, + openFromFile, + autosaveNow: writeWorkSnapshot, + isDirty, + opfsAvailable, + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/js/vendor/README.md b/js/vendor/README.md index 52aab946..4dfa9b3d 100644 --- a/js/vendor/README.md +++ b/js/vendor/README.md @@ -9,6 +9,7 @@ Third-party libraries vendored for offline use (#381). Loaded lazily by | `mediabunny-1.50.3.min.js` | [mediabunny](https://www.npmjs.com/package/mediabunny) | 1.50.3 | MPL-2.0 | `dist/bundles/mediabunny.min.mjs` | | `mediabunny-mp3-encoder-1.50.3.min.js` | [@mediabunny/mp3-encoder](https://www.npmjs.com/package/@mediabunny/mp3-encoder) | 1.50.3 | MPL-2.0 | `dist/bundles/mediabunny-mp3-encoder.min.mjs` | | `soundtouchjs-0.3.0.js` | [soundtouchjs](https://www.npmjs.com/package/soundtouchjs) | 0.3.0 | LGPL-2.1 | `dist/soundtouch.js` | +| `jszip-3.10.1.min.js` | [jszip](https://www.npmjs.com/package/jszip) | 3.10.1 | MIT (dual MIT/GPL-3.0; used under MIT) | `dist/jszip.min.js` | All files are unmodified copies of the packages' published dist builds; license headers are retained in each file. diff --git a/js/vendor/jszip-3.10.1.min.js b/js/vendor/jszip-3.10.1.min.js new file mode 100644 index 00000000..ff4cfd5e --- /dev/null +++ b/js/vendor/jszip-3.10.1.min.js @@ -0,0 +1,13 @@ +/*! + +JSZip v3.10.1 - A JavaScript class for generating and reading zip files + + +(c) 2009-2016 Stuart Knightley +Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. + +JSZip uses the library pako released under the MIT license : +https://github.com/nodeca/pako/blob/main/LICENSE +*/ + +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e>2,s=(3&t)<<4|r>>4,a=1>6:64,o=2>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(e){t.push({data:e,meta:t.meta})}},r.compressWorker=function(e){return new h("Deflate",e)},r.uncompressWorker=function(){return new h("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(e,t,r){"use strict";function A(e,t){var r,n="";for(r=0;r>>=8;return n}function n(e,t,r,n,i,s){var a,o,h=e.file,u=e.compression,l=s!==O.utf8encode,f=I.transformTo("string",s(h.name)),c=I.transformTo("string",O.utf8encode(h.name)),d=h.comment,p=I.transformTo("string",s(d)),m=I.transformTo("string",O.utf8encode(d)),_=c.length!==h.name.length,g=m.length!==d.length,b="",v="",y="",w=h.dir,k=h.date,x={crc32:0,compressedSize:0,uncompressedSize:0};t&&!r||(x.crc32=e.crc32,x.compressedSize=e.compressedSize,x.uncompressedSize=e.uncompressedSize);var S=0;t&&(S|=8),l||!_&&!g||(S|=2048);var z=0,C=0;w&&(z|=16),"UNIX"===i?(C=798,z|=function(e,t){var r=e;return e||(r=t?16893:33204),(65535&r)<<16}(h.unixPermissions,w)):(C=20,z|=function(e){return 63&(e||0)}(h.dosPermissions)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v=A(1,1)+A(B(f),4)+c,b+="up"+A(v.length,2)+v),g&&(y=A(1,1)+A(B(p),4)+m,b+="uc"+A(y.length,2)+y);var E="";return E+="\n\0",E+=A(S,2),E+=u.magic,E+=A(a,2),E+=A(o,2),E+=A(x.crc32,4),E+=A(x.compressedSize,4),E+=A(x.uncompressedSize,4),E+=A(f.length,2),E+=A(b.length,2),{fileRecord:R.LOCAL_FILE_HEADER+E+f+b,dirRecord:R.CENTRAL_FILE_HEADER+A(C,2)+E+A(p.length,2)+"\0\0\0\0"+A(z,4)+A(n,4)+f+b+p}}var I=e("../utils"),i=e("../stream/GenericWorker"),O=e("../utf8"),B=e("../crc32"),R=e("../signature");function s(e,t,r,n){i.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=t,this.zipPlatform=r,this.encodeFileName=n,this.streamFiles=e,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}I.inherits(s,i),s.prototype.push=function(e){var t=e.meta.percent||0,r=this.entriesCount,n=this._sources.length;this.accumulate?this.contentBuffer.push(e):(this.bytesWritten+=e.data.length,i.prototype.push.call(this,{data:e.data,meta:{currentFile:this.currentFile,percent:r?(t+100*(r-n-1))/r:100}}))},s.prototype.openedSource=function(e){this.currentSourceOffset=this.bytesWritten,this.currentFile=e.file.name;var t=this.streamFiles&&!e.file.dir;if(t){var r=n(e,t,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},s.prototype.closedSource=function(e){this.accumulate=!1;var t=this.streamFiles&&!e.file.dir,r=n(e,t,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(r.dirRecord),t)this.push({data:function(e){return R.DATA_DESCRIPTOR+A(e.crc32,4)+A(e.compressedSize,4)+A(e.uncompressedSize,4)}(e),meta:{percent:100}});else for(this.push({data:r.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},s.prototype.flush=function(){for(var e=this.bytesWritten,t=0;t=this.index;t--)r=(r<<8)+this.byteAt(t);return this.index+=e,r},readString:function(e){return n.transformTo("string",this.readData(e))},readData:function(){},lastIndexOfSignature:function(){},readAndCheckSignature:function(){},readDate:function(){var e=this.readInt(4);return new Date(Date.UTC(1980+(e>>25&127),(e>>21&15)-1,e>>16&31,e>>11&31,e>>5&63,(31&e)<<1))}},t.exports=i},{"../utils":32}],19:[function(e,t,r){"use strict";var n=e("./Uint8ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(e,t,r){"use strict";var n=e("./DataReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.byteAt=function(e){return this.data.charCodeAt(this.zero+e)},i.prototype.lastIndexOfSignature=function(e){return this.data.lastIndexOf(e)-this.zero},i.prototype.readAndCheckSignature=function(e){return e===this.readData(4)},i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./DataReader":18}],21:[function(e,t,r){"use strict";var n=e("./ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){if(this.checkOffset(e),0===e)return new Uint8Array(0);var t=this.data.subarray(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./ArrayReader":17}],22:[function(e,t,r){"use strict";var n=e("../utils"),i=e("../support"),s=e("./ArrayReader"),a=e("./StringReader"),o=e("./NodeBufferReader"),h=e("./Uint8ArrayReader");t.exports=function(e){var t=n.getTypeOf(e);return n.checkSupport(t),"string"!==t||i.uint8array?"nodebuffer"===t?new o(e):i.uint8array?new h(n.transformTo("uint8array",e)):new s(n.transformTo("array",e)):new a(e)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(e,t,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../utils");function s(e){n.call(this,"ConvertWorker to "+e),this.destType=e}i.inherits(s,n),s.prototype.processChunk=function(e){this.push({data:i.transformTo(this.destType,e.data),meta:e.meta})},t.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../crc32");function s(){n.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}e("../utils").inherits(s,n),s.prototype.processChunk=function(e){this.streamInfo.crc32=i(e.data,this.streamInfo.crc32||0),this.push(e)},t.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataLengthProbe for "+e),this.propName=e,this.withStreamInfo(e,0)}n.inherits(s,i),s.prototype.processChunk=function(e){if(e){var t=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=t+e.data.length}i.prototype.processChunk.call(this,e)},t.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataWorker");var t=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,e.then(function(e){t.dataIsReady=!0,t.data=e,t.max=e&&e.length||0,t.type=n.getTypeOf(e),t.isPaused||t._tickAndRepeat()},function(e){t.error(e)})}n.inherits(s,i),s.prototype.cleanUp=function(){i.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!i.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,n.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(n.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var e=null,t=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":e=this.data.substring(this.index,t);break;case"uint8array":e=this.data.subarray(this.index,t);break;case"array":case"nodebuffer":e=this.data.slice(this.index,t)}return this.index=t,this.push({data:e,meta:{percent:this.max?this.index/this.max*100:0}})},t.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(e,t,r){"use strict";function n(e){this.name=e||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}n.prototype={push:function(e){this.emit("data",e)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(e){this.emit("error",e)}return!0},error:function(e){return!this.isFinished&&(this.isPaused?this.generatedError=e:(this.isFinished=!0,this.emit("error",e),this.previous&&this.previous.error(e),this.cleanUp()),!0)},on:function(e,t){return this._listeners[e].push(t),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(e,t){if(this._listeners[e])for(var r=0;r "+e:e}},t.exports=n},{}],29:[function(e,t,r){"use strict";var h=e("../utils"),i=e("./ConvertWorker"),s=e("./GenericWorker"),u=e("../base64"),n=e("../support"),a=e("../external"),o=null;if(n.nodestream)try{o=e("../nodejs/NodejsStreamOutputAdapter")}catch(e){}function l(e,o){return new a.Promise(function(t,r){var n=[],i=e._internalType,s=e._outputType,a=e._mimeType;e.on("data",function(e,t){n.push(e),o&&o(t)}).on("error",function(e){n=[],r(e)}).on("end",function(){try{var e=function(e,t,r){switch(e){case"blob":return h.newBlob(h.transformTo("arraybuffer",t),r);case"base64":return u.encode(t);default:return h.transformTo(e,t)}}(s,function(e,t){var r,n=0,i=null,s=0;for(r=0;r>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t}(e)},s.utf8decode=function(e){return h.nodebuffer?o.transformTo("nodebuffer",e).toString("utf-8"):function(e){var t,r,n,i,s=e.length,a=new Array(2*s);for(t=r=0;t>10&1023,a[r++]=56320|1023&n)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(e=o.transformTo(h.uint8array?"uint8array":"array",e))},o.inherits(a,n),a.prototype.processChunk=function(e){var t=o.transformTo(h.uint8array?"uint8array":"array",e.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=t;(t=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),t.set(r,this.leftOver.length)}else t=this.leftOver.concat(t);this.leftOver=null}var n=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}(t),i=t;n!==t.length&&(h.uint8array?(i=t.subarray(0,n),this.leftOver=t.subarray(n,t.length)):(i=t.slice(0,n),this.leftOver=t.slice(n,t.length))),this.push({data:s.utf8decode(i),meta:e.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,n),l.prototype.processChunk=function(e){this.push({data:s.utf8encode(e.data),meta:e.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(e,t,a){"use strict";var o=e("./support"),h=e("./base64"),r=e("./nodejsUtils"),u=e("./external");function n(e){return e}function l(e,t){for(var r=0;r>8;this.dir=!!(16&this.externalFileAttributes),0==e&&(this.dosPermissions=63&this.externalFileAttributes),3==e&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||"/"!==this.fileNameStr.slice(-1)||(this.dir=!0)},parseZIP64ExtraField:function(){if(this.extraFields[1]){var e=n(this.extraFields[1].value);this.uncompressedSize===s.MAX_VALUE_32BITS&&(this.uncompressedSize=e.readInt(8)),this.compressedSize===s.MAX_VALUE_32BITS&&(this.compressedSize=e.readInt(8)),this.localHeaderOffset===s.MAX_VALUE_32BITS&&(this.localHeaderOffset=e.readInt(8)),this.diskNumberStart===s.MAX_VALUE_32BITS&&(this.diskNumberStart=e.readInt(4))}},readExtraFields:function(e){var t,r,n,i=e.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});e.index+4>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t},r.buf2binstring=function(e){return l(e,e.length)},r.binstring2buf=function(e){for(var t=new h.Buf8(e.length),r=0,n=t.length;r>10&1023,o[n++]=56320|1023&i)}return l(o,n)},r.utf8border=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}},{"./common":41}],43:[function(e,t,r){"use strict";t.exports=function(e,t,r,n){for(var i=65535&e|0,s=e>>>16&65535|0,a=0;0!==r;){for(r-=a=2e3>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}},{}],46:[function(e,t,r){"use strict";var h,c=e("../utils/common"),u=e("./trees"),d=e("./adler32"),p=e("./crc32"),n=e("./messages"),l=0,f=4,m=0,_=-2,g=-1,b=4,i=2,v=8,y=9,s=286,a=30,o=19,w=2*s+1,k=15,x=3,S=258,z=S+x+1,C=42,E=113,A=1,I=2,O=3,B=4;function R(e,t){return e.msg=n[t],t}function T(e){return(e<<1)-(4e.avail_out&&(r=e.avail_out),0!==r&&(c.arraySet(e.output,t.pending_buf,t.pending_out,r,e.next_out),e.next_out+=r,t.pending_out+=r,e.total_out+=r,e.avail_out-=r,t.pending-=r,0===t.pending&&(t.pending_out=0))}function N(e,t){u._tr_flush_block(e,0<=e.block_start?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,F(e.strm)}function U(e,t){e.pending_buf[e.pending++]=t}function P(e,t){e.pending_buf[e.pending++]=t>>>8&255,e.pending_buf[e.pending++]=255&t}function L(e,t){var r,n,i=e.max_chain_length,s=e.strstart,a=e.prev_length,o=e.nice_match,h=e.strstart>e.w_size-z?e.strstart-(e.w_size-z):0,u=e.window,l=e.w_mask,f=e.prev,c=e.strstart+S,d=u[s+a-1],p=u[s+a];e.prev_length>=e.good_match&&(i>>=2),o>e.lookahead&&(o=e.lookahead);do{if(u[(r=t)+a]===p&&u[r+a-1]===d&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do{}while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&sh&&0!=--i);return a<=e.lookahead?a:e.lookahead}function j(e){var t,r,n,i,s,a,o,h,u,l,f=e.w_size;do{if(i=e.window_size-e.lookahead-e.strstart,e.strstart>=f+(f-z)){for(c.arraySet(e.window,e.window,f,f,0),e.match_start-=f,e.strstart-=f,e.block_start-=f,t=r=e.hash_size;n=e.head[--t],e.head[t]=f<=n?n-f:0,--r;);for(t=r=f;n=e.prev[--t],e.prev[t]=f<=n?n-f:0,--r;);i+=f}if(0===e.strm.avail_in)break;if(a=e.strm,o=e.window,h=e.strstart+e.lookahead,u=i,l=void 0,l=a.avail_in,u=x)for(s=e.strstart-e.insert,e.ins_h=e.window[s],e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x)if(n=u._tr_tally(e,e.strstart-e.match_start,e.match_length-x),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=x){for(e.match_length--;e.strstart++,e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x&&e.match_length<=e.prev_length){for(i=e.strstart+e.lookahead-x,n=u._tr_tally(e,e.strstart-1-e.prev_match,e.prev_length-x),e.lookahead-=e.prev_length-1,e.prev_length-=2;++e.strstart<=i&&(e.ins_h=(e.ins_h<e.pending_buf_size-5&&(r=e.pending_buf_size-5);;){if(e.lookahead<=1){if(j(e),0===e.lookahead&&t===l)return A;if(0===e.lookahead)break}e.strstart+=e.lookahead,e.lookahead=0;var n=e.block_start+r;if((0===e.strstart||e.strstart>=n)&&(e.lookahead=e.strstart-n,e.strstart=n,N(e,!1),0===e.strm.avail_out))return A;if(e.strstart-e.block_start>=e.w_size-z&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):(e.strstart>e.block_start&&(N(e,!1),e.strm.avail_out),A)}),new M(4,4,8,4,Z),new M(4,5,16,8,Z),new M(4,6,32,32,Z),new M(4,4,16,16,W),new M(8,16,32,32,W),new M(8,16,128,128,W),new M(8,32,128,256,W),new M(32,128,258,1024,W),new M(32,258,258,4096,W)],r.deflateInit=function(e,t){return Y(e,t,v,15,8,0)},r.deflateInit2=Y,r.deflateReset=K,r.deflateResetKeep=G,r.deflateSetHeader=function(e,t){return e&&e.state?2!==e.state.wrap?_:(e.state.gzhead=t,m):_},r.deflate=function(e,t){var r,n,i,s;if(!e||!e.state||5>8&255),U(n,n.gzhead.time>>16&255),U(n,n.gzhead.time>>24&255),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,255&n.gzhead.os),n.gzhead.extra&&n.gzhead.extra.length&&(U(n,255&n.gzhead.extra.length),U(n,n.gzhead.extra.length>>8&255)),n.gzhead.hcrc&&(e.adler=p(e.adler,n.pending_buf,n.pending,0)),n.gzindex=0,n.status=69):(U(n,0),U(n,0),U(n,0),U(n,0),U(n,0),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,3),n.status=E);else{var a=v+(n.w_bits-8<<4)<<8;a|=(2<=n.strategy||n.level<2?0:n.level<6?1:6===n.level?2:3)<<6,0!==n.strstart&&(a|=32),a+=31-a%31,n.status=E,P(n,a),0!==n.strstart&&(P(n,e.adler>>>16),P(n,65535&e.adler)),e.adler=1}if(69===n.status)if(n.gzhead.extra){for(i=n.pending;n.gzindex<(65535&n.gzhead.extra.length)&&(n.pending!==n.pending_buf_size||(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending!==n.pending_buf_size));)U(n,255&n.gzhead.extra[n.gzindex]),n.gzindex++;n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),n.gzindex===n.gzhead.extra.length&&(n.gzindex=0,n.status=73)}else n.status=73;if(73===n.status)if(n.gzhead.name){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.gzindex=0,n.status=91)}else n.status=91;if(91===n.status)if(n.gzhead.comment){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.status=103)}else n.status=103;if(103===n.status&&(n.gzhead.hcrc?(n.pending+2>n.pending_buf_size&&F(e),n.pending+2<=n.pending_buf_size&&(U(n,255&e.adler),U(n,e.adler>>8&255),e.adler=0,n.status=E)):n.status=E),0!==n.pending){if(F(e),0===e.avail_out)return n.last_flush=-1,m}else if(0===e.avail_in&&T(t)<=T(r)&&t!==f)return R(e,-5);if(666===n.status&&0!==e.avail_in)return R(e,-5);if(0!==e.avail_in||0!==n.lookahead||t!==l&&666!==n.status){var o=2===n.strategy?function(e,t){for(var r;;){if(0===e.lookahead&&(j(e),0===e.lookahead)){if(t===l)return A;break}if(e.match_length=0,r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++,r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):3===n.strategy?function(e,t){for(var r,n,i,s,a=e.window;;){if(e.lookahead<=S){if(j(e),e.lookahead<=S&&t===l)return A;if(0===e.lookahead)break}if(e.match_length=0,e.lookahead>=x&&0e.lookahead&&(e.match_length=e.lookahead)}if(e.match_length>=x?(r=u._tr_tally(e,1,e.match_length-x),e.lookahead-=e.match_length,e.strstart+=e.match_length,e.match_length=0):(r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++),r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):h[n.level].func(n,t);if(o!==O&&o!==B||(n.status=666),o===A||o===O)return 0===e.avail_out&&(n.last_flush=-1),m;if(o===I&&(1===t?u._tr_align(n):5!==t&&(u._tr_stored_block(n,0,0,!1),3===t&&(D(n.head),0===n.lookahead&&(n.strstart=0,n.block_start=0,n.insert=0))),F(e),0===e.avail_out))return n.last_flush=-1,m}return t!==f?m:n.wrap<=0?1:(2===n.wrap?(U(n,255&e.adler),U(n,e.adler>>8&255),U(n,e.adler>>16&255),U(n,e.adler>>24&255),U(n,255&e.total_in),U(n,e.total_in>>8&255),U(n,e.total_in>>16&255),U(n,e.total_in>>24&255)):(P(n,e.adler>>>16),P(n,65535&e.adler)),F(e),0=r.w_size&&(0===s&&(D(r.head),r.strstart=0,r.block_start=0,r.insert=0),u=new c.Buf8(r.w_size),c.arraySet(u,t,l-r.w_size,r.w_size,0),t=u,l=r.w_size),a=e.avail_in,o=e.next_in,h=e.input,e.avail_in=l,e.next_in=0,e.input=t,j(r);r.lookahead>=x;){for(n=r.strstart,i=r.lookahead-(x-1);r.ins_h=(r.ins_h<>>=y=v>>>24,p-=y,0===(y=v>>>16&255))C[s++]=65535&v;else{if(!(16&y)){if(0==(64&y)){v=m[(65535&v)+(d&(1<>>=y,p-=y),p<15&&(d+=z[n++]<>>=y=v>>>24,p-=y,!(16&(y=v>>>16&255))){if(0==(64&y)){v=_[(65535&v)+(d&(1<>>=y,p-=y,(y=s-a)>3,d&=(1<<(p-=w<<3))-1,e.next_in=n,e.next_out=s,e.avail_in=n>>24&255)+(e>>>8&65280)+((65280&e)<<8)+((255&e)<<24)}function s(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new I.Buf16(320),this.work=new I.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function a(e){var t;return e&&e.state?(t=e.state,e.total_in=e.total_out=t.total=0,e.msg="",t.wrap&&(e.adler=1&t.wrap),t.mode=P,t.last=0,t.havedict=0,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new I.Buf32(n),t.distcode=t.distdyn=new I.Buf32(i),t.sane=1,t.back=-1,N):U}function o(e){var t;return e&&e.state?((t=e.state).wsize=0,t.whave=0,t.wnext=0,a(e)):U}function h(e,t){var r,n;return e&&e.state?(n=e.state,t<0?(r=0,t=-t):(r=1+(t>>4),t<48&&(t&=15)),t&&(t<8||15=s.wsize?(I.arraySet(s.window,t,r-s.wsize,s.wsize,0),s.wnext=0,s.whave=s.wsize):(n<(i=s.wsize-s.wnext)&&(i=n),I.arraySet(s.window,t,r-n,i,s.wnext),(n-=i)?(I.arraySet(s.window,t,r-n,n,0),s.wnext=n,s.whave=s.wsize):(s.wnext+=i,s.wnext===s.wsize&&(s.wnext=0),s.whave>>8&255,r.check=B(r.check,E,2,0),l=u=0,r.mode=2;break}if(r.flags=0,r.head&&(r.head.done=!1),!(1&r.wrap)||(((255&u)<<8)+(u>>8))%31){e.msg="incorrect header check",r.mode=30;break}if(8!=(15&u)){e.msg="unknown compression method",r.mode=30;break}if(l-=4,k=8+(15&(u>>>=4)),0===r.wbits)r.wbits=k;else if(k>r.wbits){e.msg="invalid window size",r.mode=30;break}r.dmax=1<>8&1),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=3;case 3:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>8&255,E[2]=u>>>16&255,E[3]=u>>>24&255,r.check=B(r.check,E,4,0)),l=u=0,r.mode=4;case 4:for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>8),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=5;case 5:if(1024&r.flags){for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>>8&255,r.check=B(r.check,E,2,0)),l=u=0}else r.head&&(r.head.extra=null);r.mode=6;case 6:if(1024&r.flags&&(o<(d=r.length)&&(d=o),d&&(r.head&&(k=r.head.extra_len-r.length,r.head.extra||(r.head.extra=new Array(r.head.extra_len)),I.arraySet(r.head.extra,n,s,d,k)),512&r.flags&&(r.check=B(r.check,n,d,s)),o-=d,s+=d,r.length-=d),r.length))break e;r.length=0,r.mode=7;case 7:if(2048&r.flags){if(0===o)break e;for(d=0;k=n[s+d++],r.head&&k&&r.length<65536&&(r.head.name+=String.fromCharCode(k)),k&&d>9&1,r.head.done=!0),e.adler=r.check=0,r.mode=12;break;case 10:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>=7&l,l-=7&l,r.mode=27;break}for(;l<3;){if(0===o)break e;o--,u+=n[s++]<>>=1)){case 0:r.mode=14;break;case 1:if(j(r),r.mode=20,6!==t)break;u>>>=2,l-=2;break e;case 2:r.mode=17;break;case 3:e.msg="invalid block type",r.mode=30}u>>>=2,l-=2;break;case 14:for(u>>>=7&l,l-=7&l;l<32;){if(0===o)break e;o--,u+=n[s++]<>>16^65535)){e.msg="invalid stored block lengths",r.mode=30;break}if(r.length=65535&u,l=u=0,r.mode=15,6===t)break e;case 15:r.mode=16;case 16:if(d=r.length){if(o>>=5,l-=5,r.ndist=1+(31&u),u>>>=5,l-=5,r.ncode=4+(15&u),u>>>=4,l-=4,286>>=3,l-=3}for(;r.have<19;)r.lens[A[r.have++]]=0;if(r.lencode=r.lendyn,r.lenbits=7,S={bits:r.lenbits},x=T(0,r.lens,0,19,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid code lengths set",r.mode=30;break}r.have=0,r.mode=19;case 19:for(;r.have>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=_,l-=_,r.lens[r.have++]=b;else{if(16===b){for(z=_+2;l>>=_,l-=_,0===r.have){e.msg="invalid bit length repeat",r.mode=30;break}k=r.lens[r.have-1],d=3+(3&u),u>>>=2,l-=2}else if(17===b){for(z=_+3;l>>=_)),u>>>=3,l-=3}else{for(z=_+7;l>>=_)),u>>>=7,l-=7}if(r.have+d>r.nlen+r.ndist){e.msg="invalid bit length repeat",r.mode=30;break}for(;d--;)r.lens[r.have++]=k}}if(30===r.mode)break;if(0===r.lens[256]){e.msg="invalid code -- missing end-of-block",r.mode=30;break}if(r.lenbits=9,S={bits:r.lenbits},x=T(D,r.lens,0,r.nlen,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid literal/lengths set",r.mode=30;break}if(r.distbits=6,r.distcode=r.distdyn,S={bits:r.distbits},x=T(F,r.lens,r.nlen,r.ndist,r.distcode,0,r.work,S),r.distbits=S.bits,x){e.msg="invalid distances set",r.mode=30;break}if(r.mode=20,6===t)break e;case 20:r.mode=21;case 21:if(6<=o&&258<=h){e.next_out=a,e.avail_out=h,e.next_in=s,e.avail_in=o,r.hold=u,r.bits=l,R(e,c),a=e.next_out,i=e.output,h=e.avail_out,s=e.next_in,n=e.input,o=e.avail_in,u=r.hold,l=r.bits,12===r.mode&&(r.back=-1);break}for(r.back=0;g=(C=r.lencode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,r.length=b,0===g){r.mode=26;break}if(32&g){r.back=-1,r.mode=12;break}if(64&g){e.msg="invalid literal/length code",r.mode=30;break}r.extra=15&g,r.mode=22;case 22:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}r.was=r.length,r.mode=23;case 23:for(;g=(C=r.distcode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,64&g){e.msg="invalid distance code",r.mode=30;break}r.offset=b,r.extra=15&g,r.mode=24;case 24:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}if(r.offset>r.dmax){e.msg="invalid distance too far back",r.mode=30;break}r.mode=25;case 25:if(0===h)break e;if(d=c-h,r.offset>d){if((d=r.offset-d)>r.whave&&r.sane){e.msg="invalid distance too far back",r.mode=30;break}p=d>r.wnext?(d-=r.wnext,r.wsize-d):r.wnext-d,d>r.length&&(d=r.length),m=r.window}else m=i,p=a-r.offset,d=r.length;for(hd?(m=R[T+a[v]],A[I+a[v]]):(m=96,0),h=1<>S)+(u-=h)]=p<<24|m<<16|_|0,0!==u;);for(h=1<>=1;if(0!==h?(E&=h-1,E+=h):E=0,v++,0==--O[b]){if(b===w)break;b=t[r+a[v]]}if(k>>7)]}function U(e,t){e.pending_buf[e.pending++]=255&t,e.pending_buf[e.pending++]=t>>>8&255}function P(e,t,r){e.bi_valid>d-r?(e.bi_buf|=t<>d-e.bi_valid,e.bi_valid+=r-d):(e.bi_buf|=t<>>=1,r<<=1,0<--t;);return r>>>1}function Z(e,t,r){var n,i,s=new Array(g+1),a=0;for(n=1;n<=g;n++)s[n]=a=a+r[n-1]<<1;for(i=0;i<=t;i++){var o=e[2*i+1];0!==o&&(e[2*i]=j(s[o]++,o))}}function W(e){var t;for(t=0;t>1;1<=r;r--)G(e,s,r);for(i=h;r=e.heap[1],e.heap[1]=e.heap[e.heap_len--],G(e,s,1),n=e.heap[1],e.heap[--e.heap_max]=r,e.heap[--e.heap_max]=n,s[2*i]=s[2*r]+s[2*n],e.depth[i]=(e.depth[r]>=e.depth[n]?e.depth[r]:e.depth[n])+1,s[2*r+1]=s[2*n+1]=i,e.heap[1]=i++,G(e,s,1),2<=e.heap_len;);e.heap[--e.heap_max]=e.heap[1],function(e,t){var r,n,i,s,a,o,h=t.dyn_tree,u=t.max_code,l=t.stat_desc.static_tree,f=t.stat_desc.has_stree,c=t.stat_desc.extra_bits,d=t.stat_desc.extra_base,p=t.stat_desc.max_length,m=0;for(s=0;s<=g;s++)e.bl_count[s]=0;for(h[2*e.heap[e.heap_max]+1]=0,r=e.heap_max+1;r<_;r++)p<(s=h[2*h[2*(n=e.heap[r])+1]+1]+1)&&(s=p,m++),h[2*n+1]=s,u>=7;n>>=1)if(1&r&&0!==e.dyn_ltree[2*t])return o;if(0!==e.dyn_ltree[18]||0!==e.dyn_ltree[20]||0!==e.dyn_ltree[26])return h;for(t=32;t>>3,(s=e.static_len+3+7>>>3)<=i&&(i=s)):i=s=r+5,r+4<=i&&-1!==t?J(e,t,r,n):4===e.strategy||s===i?(P(e,2+(n?1:0),3),K(e,z,C)):(P(e,4+(n?1:0),3),function(e,t,r,n){var i;for(P(e,t-257,5),P(e,r-1,5),P(e,n-4,4),i=0;i>>8&255,e.pending_buf[e.d_buf+2*e.last_lit+1]=255&t,e.pending_buf[e.l_buf+e.last_lit]=255&r,e.last_lit++,0===t?e.dyn_ltree[2*r]++:(e.matches++,t--,e.dyn_ltree[2*(A[r]+u+1)]++,e.dyn_dtree[2*N(t)]++),e.last_lit===e.lit_bufsize-1},r._tr_align=function(e){P(e,2,3),L(e,m,z),function(e){16===e.bi_valid?(U(e,e.bi_buf),e.bi_buf=0,e.bi_valid=0):8<=e.bi_valid&&(e.pending_buf[e.pending++]=255&e.bi_buf,e.bi_buf>>=8,e.bi_valid-=8)}(e)}},{"../utils/common":41}],53:[function(e,t,r){"use strict";t.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(e,t,r){(function(e){!function(r,n){"use strict";if(!r.setImmediate){var i,s,t,a,o=1,h={},u=!1,l=r.document,e=Object.getPrototypeOf&&Object.getPrototypeOf(r);e=e&&e.setTimeout?e:r,i="[object process]"==={}.toString.call(r.process)?function(e){process.nextTick(function(){c(e)})}:function(){if(r.postMessage&&!r.importScripts){var e=!0,t=r.onmessage;return r.onmessage=function(){e=!1},r.postMessage("","*"),r.onmessage=t,e}}()?(a="setImmediate$"+Math.random()+"$",r.addEventListener?r.addEventListener("message",d,!1):r.attachEvent("onmessage",d),function(e){r.postMessage(a+e,"*")}):r.MessageChannel?((t=new MessageChannel).port1.onmessage=function(e){c(e.data)},function(e){t.port2.postMessage(e)}):l&&"onreadystatechange"in l.createElement("script")?(s=l.documentElement,function(e){var t=l.createElement("script");t.onreadystatechange=function(){c(e),t.onreadystatechange=null,s.removeChild(t),t=null},s.appendChild(t)}):function(e){setTimeout(c,0,e)},e.setImmediate=function(e){"function"!=typeof e&&(e=new Function(""+e));for(var t=new Array(arguments.length-1),r=0;r= 0.4.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -889,6 +904,13 @@ "node": ">=0.12.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.1.tgz", @@ -898,6 +920,29 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -1057,6 +1102,13 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -1678,6 +1730,13 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -1707,6 +1766,22 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1769,6 +1844,20 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -1778,6 +1867,16 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/stylehacks": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.0.0.tgz", diff --git a/package.json b/package.json index fd748f92..01aa3465 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "devDependencies": { + "@playwright/test": "^1.49.0", "autoprefixer": "^10.4.14", "cssnano": "^6.0.1", "daisyui": "^4.4.2", + "jszip": "^3.10.1", "postcss": "^8.4.24", - "tailwindcss": "^3.3.2", - "@playwright/test": "^1.49.0" + "tailwindcss": "^3.3.2" }, "name": "hyperaudio-lite-editor", "private": true, @@ -14,4 +15,4 @@ "test:unit": "node --test \"__TEST__/unit/**/*.test.mjs\"", "test:e2e": "playwright test" } -} \ No newline at end of file +} diff --git a/serviceworker.js b/serviceworker.js index e1650512..1ffaa6d8 100644 --- a/serviceworker.js +++ b/serviceworker.js @@ -9,6 +9,8 @@ self.addEventListener("install", function (e) { "./js/vendor/mediabunny-1.50.3.min.js", "./js/vendor/mediabunny-mp3-encoder-1.50.3.min.js", "./js/vendor/soundtouchjs-0.3.0.js", + // Vendored zip library — precached so saving .hyperaudio projects works offline. + "./js/vendor/jszip-3.10.1.min.js", ]); }) ); From 4d9a99545b71f0e0c23c3c830d62da333a981e74 Mon Sep 17 00:00:00 2001 From: Marco Scarselli Date: Fri, 10 Jul 2026 21:26:55 +0200 Subject: [PATCH 03/22] Remote media: opportunistic embed with declared link fallback + reconciliation (#403) Format 1.1 (additive minor bump): media.kind "link" is now writable. Save on a URL-mode project first tries to fetch and embed the media byte-for-byte (the server's CORS policy decides); when refused, the project is saved with a declared URL-only link descriptor after user confirmation. On open, an unreachable link URL (playback needs no CORS, so the player's error event is the only honest signal) or an original-kind container missing its media entry offers reconciliation per spec 7.3: re-attach a local copy, verified heuristically (size/filename, duration once metadata loads); the next save is self-contained again. --- __TEST__/unit/hyperaudio-save.test.mjs | 30 ++++- js/hyperaudio-save.js | 161 ++++++++++++++++++++++--- 2 files changed, 176 insertions(+), 15 deletions(-) diff --git a/__TEST__/unit/hyperaudio-save.test.mjs b/__TEST__/unit/hyperaudio-save.test.mjs index 391fe28d..27f8fabc 100644 --- a/__TEST__/unit/hyperaudio-save.test.mjs +++ b/__TEST__/unit/hyperaudio-save.test.mjs @@ -1,4 +1,4 @@ -// Unit tests for the .hyperaudio save format (spec: docs/format/hyperaudio-format.md). +// 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. @@ -108,6 +108,18 @@ test('validateProjectJson: flags unknown kind, bad path, bad words', () => { 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', () => { @@ -155,6 +167,22 @@ test('container: round-trip preserves project, media bytes, captions, origin', a 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); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index a1bdf5e5..6436b73d 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -3,7 +3,9 @@ * .hyperaudio PROJECT SAVE — format, container, OPFS working copy, UI * ============================================================================ * - * Implements the .hyperaudio format v1.0 (full spec + worked example: issue #403). + * Implements the .hyperaudio format v1.1 (full spec + worked example: issue #403; + * 1.1 adds media.kind "link": remote media embedded when CORS allows, saved as + * a declared URL-only link otherwise, reconciled on open per spec § 7.3). * The format in ten lines — a renamed ZIP; a working SAVE, never an export: * mimetype first entry, STORE: "application/vnd.hyperaudio+zip" * hyperaudio.json source of truth: format version + media descriptor @@ -32,7 +34,7 @@ 'use strict'; const FORMAT_NAME = 'hyperaudio'; - const FORMAT_VERSION = '1.0'; + const FORMAT_VERSION = '1.1'; // 1.1 adds media.kind "link" (spec § 7.2) const READER_MAJOR = 1; const CONTAINER_MIMETYPE = 'application/vnd.hyperaudio+zip'; const FILE_EXTENSION = '.hyperaudio'; @@ -416,6 +418,8 @@ provenanceAt: 0, // when the engine reported it (staleness guard) language: '', mediaFile: null, // the original File, captured at import + mediaFileFromUrl: null, // set when mediaFile was fetched from this remote URL (embed, § 7.2) + pendingReconcile: null, // media descriptor awaiting reconciliation (§ 7.3) hasOriginal: false, originalJson: null, // the origin as serialized JSON (in-memory copy; work/ holds it across reloads) }; @@ -450,11 +454,21 @@ ? Math.round(player.duration * 1000) / 1000 : 0; const src = player !== null ? player.src : ''; if (/^https?:/i.test(src)) { - // The player is on a remote URL (URL-mode transcription): any File - // captured for a PREVIOUS project is stale — the URL wins. - // Kept as a link descriptor in the WORKING COPY only — saveToFile() - // refuses to write it into a downloadable container (v1 writes kind - // "original" only; "link" is a future formula, spec § 7.2). + // The player is on a remote URL (URL-mode transcription): a File captured + // for a PREVIOUS project is stale — the URL wins. The exception is a file + // fetched from this very URL (opportunistic embed, § 7.2): that IS this + // project's media and the save is self-contained. + if (session.mediaFile !== null && session.mediaFileFromUrl === src) { + return { + kind: 'original', + path: MEDIA_DIR + session.mediaFile.name, + url: null, + filename: session.mediaFile.name, + mimeType: session.mediaFile.type || '', + durationSeconds: duration, + sizeBytes: session.mediaFile.size, + }; + } return { kind: 'link', path: null, url: src, filename: '', mimeType: '', durationSeconds: duration, sizeBytes: 0 }; } if (session.mediaFile !== null) { @@ -764,6 +778,8 @@ session.active = true; session.created = nowIso(); session.hasOriginal = false; + session.mediaFileFromUrl = null; + session.pendingReconcile = null; // Provenance is only this project's if the engine reported it moments ago // (imports fire hyperaudioInit without any setTranscriptionInfo call — // a previous transcription's provenance must not leak into them). @@ -780,21 +796,52 @@ } } + // Opportunistic embed (§ 7.2): try to download the remote media so the save + // is self-contained. Whether this works is the server's call (CORS). + async function fetchRemoteMediaFile(url) { + const response = await fetch(url); + if (!response.ok) throw new Error('HTTP ' + response.status); + const blob = await response.blob(); + let name = ''; + try { name = decodeURIComponent(new URL(url).pathname.split('/').pop() || ''); } catch (e) { /* fallback below */ } + if (name === '' || name.indexOf('.') === -1) { + const ext = ((blob.type || '').split('/')[1] || 'bin').split(';')[0]; + name = (name || 'media') + '.' + ext; + } + return new File([blob], name, { type: blob.type || '' }); + } + async function saveToFile() { - const mediaFile = await resolveMediaFile(); + let mediaFile = await resolveMediaFile(); const player = document.querySelector('#hyperplayer'); - if (mediaFile === null) { - if (player !== null && /^https?:/i.test(player.src)) { - alert('This project references remote media (a URL). Version 1 of the .hyperaudio format only saves projects with a local media file — the "link" formula is coming later.'); + const remoteSrc = player !== null && /^https?:/i.test(player.src) ? player.src : null; + let saveAsLink = false; + + if (mediaFile === null && remoteSrc !== null) { + if (session.mediaFile !== null && session.mediaFileFromUrl === remoteSrc) { + mediaFile = session.mediaFile; // already embedded by a previous save of this project } else { - alert('No media loaded — there is nothing to save yet.'); + try { + mediaFile = await fetchRemoteMediaFile(remoteSrc); + session.mediaFile = mediaFile; + session.mediaFileFromUrl = remoteSrc; + await writeMediaOnce(); // the working copy becomes self-contained too + } catch (e) { + // CORS or network said no: fall back to a link save (§ 7.2), declared. + const proceed = confirm('The remote media cannot be downloaded by the browser (the server does not allow it), so it cannot be embedded in the file.\n\nSave the project with a LINK to the URL instead? The file will contain all your work, but playing it back will need internet access and the URL staying available.'); + if (!proceed) return; + saveAsLink = true; + } } + } + if (mediaFile === null && !saveAsLink) { + alert('No media loaded — there is nothing to save yet.'); return; } const lowMem = typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 4; const warnAt = lowMem ? LARGE_MEDIA_WARN_BYTES_LOWMEM : LARGE_MEDIA_WARN_BYTES; - if (mediaFile.size > warnAt) { + if (mediaFile !== null && mediaFile.size > warnAt) { const mb = Math.round(mediaFile.size / (1024 * 1024)); if (!confirm(`The media file is large (~${mb} MB). Building the .hyperaudio file needs roughly that much memory and may take a while. Continue?`)) { return; @@ -822,7 +869,7 @@ html: state.html, originalJson, captionsVtt: getCaptionsVtt() || null, - media: { name: mediaFile.name, data: mediaFile }, + media: mediaFile !== null ? { name: mediaFile.name, data: mediaFile } : null, }, JSZipImpl, 'blob'); const safeTitle = (state.texts.title || 'project') @@ -859,6 +906,7 @@ return; } + let reconcileNow = null; // § 7.3: original-kind container missing its media entry if (loaded.mediaData !== null && loaded.mediaEntryName !== null) { const mimeType = loaded.project !== null ? (loaded.project.media.mimeType || '') : ''; loaded.mediaFile = new File([loaded.mediaData], loaded.mediaEntryName, { type: mimeType }); @@ -866,6 +914,8 @@ loaded.mediaFile = null; if (!loaded.recovered && loaded.project.media.kind === 'link') { alert('Note: this project references its media by URL — it is not self-contained. Playback will use the remote URL.'); + } else if (!loaded.recovered && loaded.project.media.kind === 'original') { + reconcileNow = loaded.project.media; } } @@ -880,6 +930,9 @@ session.provenance = (!loaded.recovered && loaded.project.provenance) || null; session.language = (!loaded.recovered && loaded.project.texts && loaded.project.texts.language) || ''; session.mediaFile = loaded.mediaFile; + session.mediaFileFromUrl = null; + session.pendingReconcile = (!loaded.recovered && loaded.project.media.kind === 'link') + ? loaded.project.media : null; session.hasOriginal = loaded.originalText !== null; session.originalJson = loaded.originalText; @@ -900,6 +953,58 @@ alert('The project file was not fully conformant; the transcript was recovered from its HTML copy. Saving again will produce a fully conformant file.'); } } + + if (reconcileNow !== null) { + offerMediaReconciliation(reconcileNow); + } + } + + /* ========================================================================== + * Media reconciliation (spec § 7.3): when a project's media is unavailable + * (link URL unreachable, or an original-kind container missing its media + * entry), offer to re-attach a local copy. Verification is heuristic — size + * and filename when recorded, duration once metadata loads — and the next + * save makes the project self-contained again (kind "original"). + * ======================================================================== */ + + let reconcileTarget = null; + let reconcileInput = null; + + function offerMediaReconciliation(desc) { + const why = desc.url + ? 'The media URL this project points to cannot be played (offline, moved, or gone).' + : 'The media file is missing from the project container.'; + if (!confirm(why + '\n\nThe text is loaded and editable. If you have the media on this computer you can re-attach it now — the next save will make the project self-contained again.\n\nChoose the media file?')) { + return; + } + reconcileTarget = desc; + reconcileInput.value = ''; + reconcileInput.click(); + } + + function attachReconciledMedia(file, desc) { + const doubts = []; + if (desc.sizeBytes > 0 && desc.sizeBytes !== file.size) doubts.push('its size differs from the saved project'); + if (desc.filename && desc.filename !== file.name) doubts.push('its name differs (project media was "' + desc.filename + '")'); + if (doubts.length > 0 + && !confirm('This may not be the right file: ' + doubts.join('; ') + '.\n\nAttach it anyway?')) { + return; + } + const player = document.querySelector('#hyperplayer'); + session.mediaFile = file; + session.mediaFileFromUrl = null; + session.pendingReconcile = null; + player.src = URL.createObjectURL(file); + if (desc.durationSeconds > 0) { + player.addEventListener('loadedmetadata', function check() { + player.removeEventListener('loadedmetadata', check); + if (Number.isFinite(player.duration) && Math.abs(player.duration - desc.durationSeconds) > 2) { + alert('Warning: the attached media lasts ~' + Math.round(player.duration) + 's but the project was saved with ~' + Math.round(desc.durationSeconds) + 's — it may be the wrong file.'); + } + }); + } + writeMediaOnce(); + scheduleAutosave(); } // Boot restore: the synchronous localStorage hint decides whether to probe @@ -929,6 +1034,8 @@ session.provenance = project.provenance || null; session.language = (project.texts && project.texts.language) || ''; session.mediaFile = mediaFile; + session.mediaFileFromUrl = null; + session.pendingReconcile = project.media.kind === 'link' ? project.media : null; session.hasOriginal = originalText !== null; session.originalJson = originalText; } catch (e) { @@ -971,6 +1078,19 @@ input.style.display = 'none'; document.body.appendChild(input); + reconcileInput = document.createElement('input'); + reconcileInput.type = 'file'; + reconcileInput.id = 'project-reconcile-input'; + reconcileInput.accept = 'audio/*,video/*'; + reconcileInput.style.display = 'none'; + document.body.appendChild(reconcileInput); + reconcileInput.addEventListener('change', () => { + if (reconcileInput.files.length === 1 && reconcileTarget !== null) { + attachReconciledMedia(reconcileInput.files[0], reconcileTarget); + reconcileTarget = null; + } + }); + document.querySelector('#project-save-hyperaudio').addEventListener('click', () => { saveToFile().catch((e) => { console.error('hyperaudio-save: save failed', e); @@ -1001,10 +1121,23 @@ el.addEventListener('change', () => { if (el.files && el.files.length === 1) { session.mediaFile = el.files[0]; + session.mediaFileFromUrl = null; } }); }); + // Reconciliation trigger for link projects (§ 7.3): the URL doesn't need + // CORS to play, so reachability can only be judged by the player itself. + const playerEl = document.querySelector('#hyperplayer'); + if (playerEl !== null) { + playerEl.addEventListener('error', () => { + const desc = session.pendingReconcile; + if (desc === null || !desc.url || playerEl.src !== desc.url) return; + session.pendingReconcile = null; // one shot + offerMediaReconciliation(desc); + }); + } + // New transcript (transcribe / JSON / SRT import) → new project + origin. document.addEventListener('hyperaudioInit', () => { onNewTranscript().catch((e) => console.warn('hyperaudio-save: capture failed', e)); From ac2e75620d6863037093483797b08ced165f98bd Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Fri, 31 Jul 2026 12:32:57 +0200 Subject: [PATCH 04/22] Post-merge repairs: session-carried title, a11y on hidden inputs, spec updated to current markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deleted legacy title field (#439) was gather()'s only memory of the project title, so an open→save round trip silently degraded the title to the media filename. The session now carries texts.title across apply/gather (cleared on new transcription/import); #449's UI field slots in as the preferred source when it exists, and the e2e asserts the title through the save round-trip instead of the deleted field — including after an OPFS boot restore. The module's hidden file inputs get the #402 treatment (title="" + aria-label), fixing the a11y regression sweep. Cache-busters for the two changed files point at 0.10.0 — the .hyperaudio model is the next minor. --- __TEST__/e2e/project-save.spec.mjs | 11 ++++++++--- index.html | 4 ++-- js/hyperaudio-save.js | 16 ++++++++++++---- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index 6ff0c327..8837b381 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -91,10 +91,10 @@ test('opening a .hyperaudio lands transcript, redaction, captions, options and t const trackSrc = await page.evaluate(() => document.querySelector('#hyperplayer-vtt').src); expect(decodeURIComponent(trackSrc.split(',')[1])).toContain('Benvenuti a Hyperaudio'); - // options and texts + // 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('#save-localstorage-filename')).toHaveValue('E2E Project'); await expect(page.locator('#summary')).toHaveText('summary text'); expect(dialogs).toEqual([]); // a conformant file opens without any alert @@ -144,7 +144,12 @@ test('the working copy survives a reload (OPFS restore)', async ({ page }, testI 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'); - await expect(page.locator('#save-localstorage-filename')).toHaveValue('E2E Project'); 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.querySelector('#project-save-hyperaudio').click()); + expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); }); diff --git a/index.html b/index.html index a5f7c70f..c3bf095f 100644 --- a/index.html +++ b/index.html @@ -49,7 +49,7 @@ - + @@ -1033,7 +1033,7 @@

    Caption Regeneration

    - + diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 6436b73d..4132a239 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -422,6 +422,8 @@ pendingReconcile: null, // media descriptor awaiting reconciliation (§ 7.3) hasOriginal: false, originalJson: null, // the origin as serialized JSON (in-memory copy; work/ holds it across reloads) + title: '', // project title; the legacy title field is gone (#439), so + // the session carries it across gather/apply (#449 adds UI) }; let suppressCapture = false; // true while apply() replays a loaded project let autosaveTimer = null; @@ -513,13 +515,13 @@ const html = getEditorHtml(); const transcript = htmlToJSON(html); const versionMeta = document.querySelector('meta[name="version"]'); - const titleField = document.querySelector('#save-localstorage-filename'); + const titleField = document.querySelector('#project-title'); // #449's field, when present const summaryEl = document.getElementById('summary'); const topicsEl = document.getElementById('topics'); const media = currentMediaDescriptor(); const title = (titleField !== null && titleField.value.trim() !== '') ? titleField.value.trim() - : (media.filename || 'project'); + : (session.title || media.filename || 'project'); return { generatorVersion: versionMeta !== null ? versionMeta.content : '', @@ -689,8 +691,9 @@ const topicsEl = document.getElementById('topics'); if (summaryEl !== null) summaryEl.textContent = texts !== null ? (texts.summary || '') : ''; if (topicsEl !== null) topicsEl.textContent = texts !== null ? (texts.topics || []).join(', ') : ''; - const titleField = document.querySelector('#save-localstorage-filename'); - if (titleField !== null && texts !== null && texts.title) titleField.value = texts.title; + session.title = (texts !== null && texts.title) ? texts.title : ''; + const titleField = document.querySelector('#project-title'); + if (titleField !== null) titleField.value = session.title; const cleaned = transcriptEl.innerHTML.replace(/ class=".*?"/g, ''); const htmlLink = document.querySelector('#download-html'); @@ -780,6 +783,7 @@ session.hasOriginal = false; session.mediaFileFromUrl = null; session.pendingReconcile = null; + session.title = ''; // Provenance is only this project's if the engine reported it moments ago // (imports fire hyperaudioInit without any setTranscriptionInfo call — // a previous transcription's provenance must not leak into them). @@ -1076,6 +1080,8 @@ input.id = 'project-open-input'; input.accept = FILE_EXTENSION; input.style.display = 'none'; + input.title = ''; // #402: suppress the native "No file chosen" tooltip + input.setAttribute('aria-label', 'Open a .hyperaudio project file'); document.body.appendChild(input); reconcileInput = document.createElement('input'); @@ -1083,6 +1089,8 @@ reconcileInput.id = 'project-reconcile-input'; reconcileInput.accept = 'audio/*,video/*'; reconcileInput.style.display = 'none'; + reconcileInput.title = ''; // #402 + reconcileInput.setAttribute('aria-label', 'Locate the project’s media file'); document.body.appendChild(reconcileInput); reconcileInput.addEventListener('change', () => { if (reconcileInput.files.length === 1 && reconcileTarget !== null) { From 18ac902750a818ffce2471860d2828b5a3b88baa Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Fri, 31 Jul 2026 13:20:37 +0200 Subject: [PATCH 05/22] Coexistence: a legacy Recents load ends the .hyperaudio project session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module correctly ignores Recents loads for project identity (they fire hyperaudioTranscriptLoaded, not hyperaudioInit) — but its autosave listens to hyperaudioGenerateCaptionsFromTranscript, which a Recents load fires when regenerating captions. A capture after the swap wrote the RECENTS doc's content into the OLD project's working copy: a franken work/ and a spurious "changes that were never downloaded" replace-warning on the next open (reported in live testing minutes after the rebase landed). A Recents load is the one document replacement that fires hyperaudioTranscriptLoaded outside our own apply()'s suppressCapture window, so the module now ends the session there: captures stop, the boot hint clears (a reload must not resurrect a project the user navigated away from), work/ is left inert for the next session to overwrite. Interim rule until #448's identity generations and #451's legacy-storage removal. The regression test drives the exact reported flow: open a container, switch to a Recents doc, re-open — no dialog, no late capture. --- __TEST__/e2e/project-save.spec.mjs | 34 ++++++++++++++++++++++++++++++ js/hyperaudio-save.js | 18 ++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index 8837b381..ff622801 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -153,3 +153,37 @@ test('the working copy survives a reload (OPFS restore)', async ({ page }, testI await page.evaluate(() => document.querySelector('#project-save-hyperaudio').click()); expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); }); + +test('a legacy Recents load ends the project session: no spurious replace-warning, no franken capture', async ({ page }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); + await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === '1'); + + // switch to a Recents doc (the legacy world) — its load regenerates captions, + // which used to trigger a project capture of the WRONG document's content + await page.evaluate(() => { + localStorage.setItem('hyperaudio:doc:e2e', JSON.stringify({ + hypertranscript: '

    RECENTS-DOC

    ', + video: 'https://example.com/a.mp3', summary: '', topics: [], + meta: { name: 'legacy doc', updated: 5000 }, + })); + loadLocalStorageOptions(); + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'legacy doc').click(); + }); + await expect(page.locator('#hypertranscript')).toContainText('RECENTS-DOC'); + + // the session ended: boot hint cleared (a reload won't resurrect the project), + // and pending captures stopped + await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === null); + await page.waitForTimeout(2000); // outlive the autosave debounce — no late capture may re-set it + expect(await page.evaluate(() => localStorage.getItem('hyperaudioWorkPresent'))).toBeNull(); + + // re-opening a .hyperaudio must NOT warn about "changes never downloaded" — + // the on-screen doc belongs to Recents, not an unsaved project + // reset the input as the menu-click path does (#project-open-hyperaudio + // clears value before click) — a second identical selection otherwise + // doesn't re-fire change + await page.evaluate(() => { document.getElementById('project-open-input').value = ''; }); + await openFixture(page, testInfo, dialogs); + expect(dialogs).toEqual([]); +}); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 4132a239..01a953e6 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -1151,6 +1151,24 @@ onNewTranscript().catch((e) => console.warn('hyperaudio-save: capture failed', e)); }); + // A legacy Recents load replaces the document OUTSIDE the project system: + // it fires hyperaudioTranscriptLoaded but not hyperaudioInit, and never + // runs under our apply's suppressCapture. The project session must end — + // a capture after the swap would write the Recents doc's content under + // the OLD project's identity (a franken working copy, and a spurious + // "changes never downloaded" warning on the next open). The boot hint + // clears so a reload doesn't resurrect a project the user navigated away + // from; work/ itself is left inert and the next session overwrites it. + // Interim coexistence rule until #448 (identity generations) / #451 + // (legacy-storage removal). + document.addEventListener('hyperaudioTranscriptLoaded', () => { + if (suppressCapture) return; // our own apply() also fires this event + clearTimeout(autosaveTimer); + session.active = false; + session.title = ''; + try { localStorage.removeItem(WORK_HINT_KEY); } catch (e) { /* private mode */ } + }); + // Provenance: engines report service/model through setTranscriptionInfo. const originalSetInfo = window.setTranscriptionInfo; if (typeof originalSetInfo === 'function') { From 71a8332f58b04f913c0b89231de5e62a6e339b6c Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Fri, 31 Jul 2026 13:58:07 +0200 Subject: [PATCH 06/22] Format v1.2: spec extracted to docs/, parity fixes pinned (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The normative spec moves out of #403's comment into docs/hyperaudio-format.md as v1.2, freezing the cross-implementation decisions while there is no installed base to migrate: - media.kind "none" (§ 7.2.2): text-only projects are conforming — the writer emits "none" instead of fabricating an "original" descriptor pointing at nothing, and the reader accepts it without a missing-media warning. - Writer-side envelope preservation (§ 8.1, normative): rewrites start from the opened envelope and overwrite only editor-owned fields, merging inside known objects — unknown top-level and nested fields survive an open→save round trip. The session keeps the raw parsed envelope; a fresh document clears it. - media.path segment rule pinned (§ 10.2): exactly one non-empty segment, no separators of either convention, only the EXACT "." / ".." segments rejected — a ".." substring ("mix..final.mp3") is legal. The old any-substring rejection made conforming containers written elsewhere unreadable. A writer-side sanitizeMediaFilename mirrors the rule at descriptor construction and zip entry creation (an unsanitized "../evil.wav" previously produced literal traversal entries in the archive). - Size caps in UTF-8 bytes with fatal decoding (§ 10.3): text.length counted UTF-16 units, and invalid UTF-8 silently decoded to replacement characters here while native readers rejected the file. - Media entries MUST be STORED on read (§ 7.1): a compressed media entry defeats the size accounting and is refused. JSZip exposes the method only via _data; the unit suite pins that access so an upgrade fails loudly. - App/session identity is explicitly excluded from the container (§ 9). docs/format-fixtures/ holds the conformance-case manifest (currently executable-first in the unit lane; checked-in binary fixtures are the next #447 step). FORMAT_VERSION → 1.2. 68 unit / 75 e2e green. --- __TEST__/unit/hyperaudio-save.test.mjs | 100 +++ docs/format-fixtures/README.md | 25 + docs/hyperaudio-format.md | 863 +++++++++++++++++++++++++ js/hyperaudio-save.js | 114 +++- 4 files changed, 1075 insertions(+), 27 deletions(-) create mode 100644 docs/format-fixtures/README.md create mode 100644 docs/hyperaudio-format.md diff --git a/__TEST__/unit/hyperaudio-save.test.mjs b/__TEST__/unit/hyperaudio-save.test.mjs index 27f8fabc..74511544 100644 --- a/__TEST__/unit/hyperaudio-save.test.mjs +++ b/__TEST__/unit/hyperaudio-save.test.mjs @@ -242,3 +242,103 @@ test('container: unknown entries in the zip are ignored (whitelist-read)', async 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/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..c47e40e9 --- /dev/null +++ b/docs/hyperaudio-format.md @@ -0,0 +1,863 @@ +# 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. + +### 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/js/hyperaudio-save.js b/js/hyperaudio-save.js index 01a953e6..8e3dc9e6 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -3,9 +3,12 @@ * .hyperaudio PROJECT SAVE — format, container, OPFS working copy, UI * ============================================================================ * - * Implements the .hyperaudio format v1.1 (full spec + worked example: issue #403; - * 1.1 adds media.kind "link": remote media embedded when CORS allows, saved as - * a declared URL-only link otherwise, reconciled on open per spec § 7.3). + * Implements the .hyperaudio format v1.2 (normative spec: + * docs/hyperaudio-format.md — originated in issue #403). 1.1 added media.kind + * "link" (remote media embedded when CORS allows, declared URL-only link + * otherwise, reconciled on open per § 7.3); 1.2 adds media.kind "none", + * writer-side envelope preservation (§ 8.1), and pins the media.path segment + * rule, byte-measured caps and STORE-only media (§ 7.1, § 10.2, § 10.3). * The format in ten lines — a renamed ZIP; a working SAVE, never an export: * mimetype first entry, STORE: "application/vnd.hyperaudio+zip" * hyperaudio.json source of truth: format version + media descriptor @@ -34,7 +37,7 @@ 'use strict'; const FORMAT_NAME = 'hyperaudio'; - const FORMAT_VERSION = '1.1'; // 1.1 adds media.kind "link" (spec § 7.2) + const FORMAT_VERSION = '1.2'; // 1.2: kind "none", envelope preservation, pinned path/caps (spec § 8) const READER_MAJOR = 1; const CONTAINER_MIMETYPE = 'application/vnd.hyperaudio+zip'; const FILE_EXTENSION = '.hyperaudio'; @@ -80,13 +83,27 @@ return { ok: true, major, minor }; } - // media.path MUST be media/: one segment, no "..", nothing absolute - // (spec § 10.2). The zip is only ever read by known names, so this is the one - // file-supplied name we accept — and only in this shape. + // media.path MUST be media/: exactly one non-empty segment, no + // separators of either convention, and the segment must not be the exact + // traversal tokens "." or ".." (spec § 10.2, pinned in 1.2). A ".." + // SUBSTRING in a normal filename ("mix..final.mp3") is legal — rejecting it + // made conforming containers written elsewhere unreadable. function validateMediaPath(path) { - return typeof path === 'string' - && /^media\/[^/\\]+$/.test(path) - && path.indexOf('..') === -1; + if (typeof path !== 'string' || path.indexOf(MEDIA_DIR) !== 0) return false; + const segment = path.slice(MEDIA_DIR.length); + if (segment === '' || /[/\\]/.test(segment)) return false; + if (segment === '.' || segment === '..') return false; + return true; + } + + // Writer-side mirror of the same rule (spec § 10.2): one portable segment. + // Used everywhere a filename becomes a media/ entry, an OPFS name, or a + // descriptor path — writer and reader MUST share the rule. + function sanitizeMediaFilename(name) { + let out = String(name === null || name === undefined ? '' : name) + .replace(/[/\\]/g, '_').trim(); + if (out === '' || out === '.' || out === '..') out = 'media'; + return out; } // Validate a parsed hyperaudio.json before use (spec § 10.4). Returns @@ -119,6 +136,8 @@ if (typeof media.url !== 'string' || !/^https?:/i.test(media.url)) { fail('media', 'media.kind "link" requires an http(s) url'); } + } else if (media.kind === 'none') { + // a text-only project (spec § 7.2.2): nothing further to validate } else { fail('media-kind', `unknown media.kind "${media && media.kind}"`); } @@ -147,7 +166,16 @@ // (space: true, struck: false) are already omitted by htmlToJSON; times are // seconds throughout (the DOM's data-m/data-d are ms — ms = round(s × 1000)). function buildProjectJson(state) { - const project = { + // Round-trip preservation (spec § 8.1, normative since 1.2): start from + // the opened envelope and overwrite only editor-owned fields — including + // INSIDE known objects, where unknown keys are merged, not replaced. + // Rebuilding from scratch destroyed the very fields readers are told to + // ignore-and-tolerate. + const base = state.envelope !== null && state.envelope !== undefined + ? structuredClone(state.envelope) : {}; + const baseOptions = (base.options !== null && typeof base.options === 'object') ? base.options : {}; + const baseTexts = (base.texts !== null && typeof base.texts === 'object') ? base.texts : {}; + const project = Object.assign(base, { format: FORMAT_NAME, formatVersion: FORMAT_VERSION, generator: { @@ -157,19 +185,19 @@ created: state.created, modified: state.modified, media: state.media, - options: { + options: Object.assign({}, baseOptions, { gapRemoval: state.options.gapRemoval, - captions: { updateFromTranscript: state.options.updateCaptionsFromTranscript !== false }, - view: state.options.view, - }, - texts: { + captions: Object.assign({}, baseOptions.captions, { updateFromTranscript: state.options.updateCaptionsFromTranscript !== false }), + view: Object.assign({}, baseOptions.view, state.options.view), + }), + texts: Object.assign({}, baseTexts, { title: state.texts.title || '', language: state.texts.language || '', summary: state.texts.summary || '', topics: Array.isArray(state.texts.topics) ? state.texts.topics : [], - }, + }), transcript: state.transcript, - }; + }); if (state.provenance && (state.provenance.engine || state.provenance.model)) { project.provenance = Object.assign({}, state.provenance); if (state.hasOriginal) { @@ -199,7 +227,7 @@ if (files.originalJson) zip.file(ENTRY.original, files.originalJson); if (files.captionsVtt) zip.file(ENTRY.captions, files.captionsVtt); if (files.media) { - zip.file(MEDIA_DIR + files.media.name, files.media.data, { compression: 'STORE', binary: true }); + zip.file(MEDIA_DIR + sanitizeMediaFilename(files.media.name), files.media.data, { compression: 'STORE', binary: true }); } return zip.generateAsync({ type: outType || 'uint8array', @@ -239,11 +267,19 @@ if (typeof declared === 'number' && declared > TEXT_ENTRY_MAX_BYTES) { throw rejection('entry-too-large', `${name} exceeds the ${TEXT_ENTRY_MAX_BYTES} byte cap`); } - const text = await entry.async('string'); - if (text.length > TEXT_ENTRY_MAX_BYTES) { + const bytes = await entry.async('uint8array'); + if (bytes.byteLength > TEXT_ENTRY_MAX_BYTES) { throw rejection('entry-too-large', `${name} exceeds the ${TEXT_ENTRY_MAX_BYTES} byte cap`); } - return text; + // The cap is UTF-8 BYTES and decoding is fatal (spec § 10.3, 1.2): + // text.length counted UTF-16 units, so multibyte text could blow past + // the cap, and invalid UTF-8 silently decoded to replacement characters + // here while native readers rejected the same file. + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (e) { + throw rejection('entry-invalid-utf8', `${name} is not valid UTF-8`); + } } const mimetypeText = await readTextEntry(ENTRY.mimetype); @@ -300,6 +336,15 @@ if (mediaEntry === null) { warnings.push('declared media entry is missing — media unavailable'); } else { + // spec § 7.1 (1.2): the media entry MUST be STORED — a compressed + // media entry defeats § 10.3's size accounting. JSZip only exposes + // the method via _data (private); the unit suite pins this so a + // JSZip upgrade that moves it fails loudly, and absence of the + // field fails open (no false rejections). + const comp = mediaEntry._data && mediaEntry._data.compression; + if (comp && comp.magic && comp.magic !== '\x00\x00') { + throw rejection('media-compressed', 'the media entry is compressed; the format requires STORE'); + } mediaData = await mediaEntry.async('uint8array'); mediaEntryName = project.media.path.slice(MEDIA_DIR.length); } @@ -315,7 +360,7 @@ const pure = { FORMAT_NAME, FORMAT_VERSION, CONTAINER_MIMETYPE, ENTRY, MEDIA_DIR, TEXT_ENTRY_MAX_BYTES, - checkFormatVersion, validateMediaPath, validateProjectJson, + checkFormatVersion, validateMediaPath, sanitizeMediaFilename, validateProjectJson, buildProjectJson, serializeProjectJson, zipProject, unzipProject, }; @@ -424,6 +469,9 @@ originalJson: null, // the origin as serialized JSON (in-memory copy; work/ holds it across reloads) title: '', // project title; the legacy title field is gone (#439), so // the session carries it across gather/apply (#449 adds UI) + envelope: null, // the opened project's raw parsed hyperaudio.json — + // rewrites start from it so unknown fields survive + // the round trip (spec § 8.1, normative since 1.2) }; let suppressCapture = false; // true while apply() replays a loaded project let autosaveTimer = null; @@ -461,11 +509,12 @@ // fetched from this very URL (opportunistic embed, § 7.2): that IS this // project's media and the save is self-contained. if (session.mediaFile !== null && session.mediaFileFromUrl === src) { + const safeName = sanitizeMediaFilename(session.mediaFile.name); return { kind: 'original', - path: MEDIA_DIR + session.mediaFile.name, + path: MEDIA_DIR + safeName, url: null, - filename: session.mediaFile.name, + filename: safeName, mimeType: session.mediaFile.type || '', durationSeconds: duration, sizeBytes: session.mediaFile.size, @@ -474,16 +523,22 @@ return { kind: 'link', path: null, url: src, filename: '', mimeType: '', durationSeconds: duration, sizeBytes: 0 }; } if (session.mediaFile !== null) { + const safeName = sanitizeMediaFilename(session.mediaFile.name); return { kind: 'original', - path: MEDIA_DIR + session.mediaFile.name, + path: MEDIA_DIR + safeName, url: null, - filename: session.mediaFile.name, + filename: safeName, mimeType: session.mediaFile.type || '', durationSeconds: duration, sizeBytes: session.mediaFile.size, }; } + if (src === '') { + // No media at all (a text-only import): a "none" project (spec § 7.2.2) + // — never a fabricated "original" descriptor pointing at nothing. + return { kind: 'none', path: null, url: null, filename: '', mimeType: '', durationSeconds: 0, sizeBytes: 0 }; + } // Local media playing from a blob:/data: URL that we haven't captured yet // (e.g. a legacy Recents load) — resolveMediaFile() materialises it lazily. return { kind: 'original', path: MEDIA_DIR + 'media', url: null, filename: 'media', mimeType: '', durationSeconds: duration, sizeBytes: 0 }; @@ -525,6 +580,7 @@ return { generatorVersion: versionMeta !== null ? versionMeta.content : '', + envelope: session.envelope, created: session.created || nowIso(), modified: nowIso(), media, @@ -784,6 +840,7 @@ session.mediaFileFromUrl = null; session.pendingReconcile = null; session.title = ''; + session.envelope = null; // a fresh document has no envelope to preserve // Provenance is only this project's if the engine reported it moments ago // (imports fire hyperaudioInit without any setTranscriptionInfo call — // a previous transcription's provenance must not leak into them). @@ -904,6 +961,8 @@ 'version-major': 'This project was saved by a newer version of the editor and cannot be opened here. Please update the editor.', 'media-kind': 'This project uses a media formula this editor does not support yet. Please update the editor.', 'entry-too-large': 'This file contains an oversized entry and was refused for safety.', + 'entry-invalid-utf8': 'This file contains invalid text encoding and was refused.', + 'media-compressed': 'This file stores its media compressed, which the format forbids — it was refused for safety.', 'unreadable': 'This is not a readable .hyperaudio file.', }; alert(messages[e.code] || messages['unreadable']); @@ -939,6 +998,7 @@ ? loaded.project.media : null; session.hasOriginal = loaded.originalText !== null; session.originalJson = loaded.originalText; + session.envelope = loaded.recovered ? null : loaded.project; if (opfsAvailable) { await clearWork(); From 334fcf65e51d54076093f18c23bdad47cda1a086 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Sat, 1 Aug 2026 09:20:16 +0200 Subject: [PATCH 07/22] Open: validate the file before the replace-confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a non-conforming file over a dirty project asked "opening this file will REPLACE it — OK?" and THEN refused the file: the user consented to a replacement that never happened (found in live testing with a compressed-media container). The unzip/validation now runs first; the replace-confirmation only appears for a file that can actually be opened — the prepare → confirm → apply ordering #448 formalizes. Regression test drives the exact flow: dirty project + bad container → exactly one dialog (the refusal), project untouched. --- __TEST__/e2e/project-save.spec.mjs | 37 ++++++++++++++++++++++++++++++ js/hyperaudio-save.js | 14 +++++++---- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index ff622801..fe9c3cf7 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -187,3 +187,40 @@ test('a legacy Recents load ends the project session: no spurious replace-warnin await openFixture(page, testInfo, dialogs); expect(dialogs).toEqual([]); }); + +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 page.waitForTimeout(800); + + // exactly ONE dialog — the refusal; never the replace-confirmation first + expect(dialogs.length).toBe(1); + expect(dialogs[0]).toContain('media compressed'); + expect(dialogs[0]).not.toContain('REPLACE'); + // and the dirty project is untouched + await expect(page.locator('#hypertranscript')).toContainText('EDITED'); +}); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 8e3dc9e6..53b7802e 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -947,11 +947,10 @@ } async function openFromFile(file) { - if (await isDirty()) { - const proceed = confirm('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.\n\nPress Cancel to go back (you can save it from FILE → Save Project first), or OK to replace it.'); - if (!proceed) return; - } - + // Parse and validate BEFORE the replace-confirmation: asking permission + // to replace the current project and THEN refusing the file meant the + // user consented to a replacement that never happened (prepare → confirm + // → apply, the #448 ordering). let loaded; try { const JSZipImpl = await loadJSZip(); @@ -969,6 +968,11 @@ return; } + if (await isDirty()) { + const proceed = confirm('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.\n\nPress Cancel to go back (you can save it from FILE → Save Project first), or OK to replace it.'); + if (!proceed) return; + } + let reconcileNow = null; // § 7.3: original-kind container missing its media entry if (loaded.mediaData !== null && loaded.mediaEntryName !== null) { const mimeType = loaded.project !== null ? (loaded.project.media.mimeType || '') : ''; From bfa3e71d4fa4abc7a77b5492901ed9b50931adf5 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Sat, 1 Aug 2026 09:32:14 +0200 Subject: [PATCH 08/22] Warn before a Recents switch discards undownloaded project changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a legacy Recents doc discards the project session exactly like Open Project replaces it — but only Open Project warned; an edit made after the last save was silently discarded on switch (reported in live testing). The switch now gets the same confirmation, cancelable at capture phase before the legacy loader swaps the DOM. The decision needs a SYNCHRONOUS dirty signal inside a click handler, which async OPFS-timestamp isDirty() cannot give — so this introduces sessionEdited, #448's markEdited in embryo: set on every capture trigger and on a fresh transcription (never-downloaded by definition), cleared on save-download, on open (the file IS the downloaded state), and on session end. E2e covers the warn-then-switch flow, the no-more-warnings-after-switch state, and the reset on Save Project. --- __TEST__/e2e/project-save.spec.mjs | 53 ++++++++++++++++++++++++++++++ js/hyperaudio-save.js | 26 +++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index fe9c3cf7..5ef08ea8 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -224,3 +224,56 @@ test('an unopenable file is refused BEFORE the replace-confirmation, project unt // and the dirty project is untouched await expect(page.locator('#hypertranscript')).toContainText('EDITED'); }); + +test('switching to a Recents doc warns when the project has undownloaded changes', async ({ page }, testInfo) => { + const dialogs = []; + await openFixture(page, testInfo, dialogs); // registers an accept-all dialog handler + // edit → undownloaded changes (the sync flag sets on the input trigger) + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'DIRTY '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.evaluate(() => { + localStorage.setItem('hyperaudio:doc:e2e', JSON.stringify({ + hypertranscript: '

    RECENTS-DOC

    ', + video: 'https://example.com/a.mp3', summary: '', topics: [], + meta: { name: 'legacy doc', updated: 5000 }, + })); + loadLocalStorageOptions(); + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'legacy doc').click(); + }); + // the warning fired (and the auto-accept let the switch proceed) + expect(dialogs.some((d) => d.includes('DISCARD'))).toBe(true); + await expect(page.locator('#hypertranscript')).toContainText('RECENTS-DOC'); + // the session ended with the switch: switching again warns no more + const count = dialogs.length; + await page.evaluate(() => { [...document.querySelectorAll('.file-item')][0].click(); }); + await page.waitForTimeout(300); + expect(dialogs.length).toBe(count); +}); + +test('after Save Project, switching to a Recents doc does not warn', 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 })); + }); + // download the project — changes are now saved, the flag resets + const downloadPromise = page.waitForEvent('download'); + await page.evaluate(() => document.querySelector('#project-save-hyperaudio').click()); + await downloadPromise; + await page.evaluate(() => { + localStorage.setItem('hyperaudio:doc:e2e', JSON.stringify({ + hypertranscript: '

    RECENTS-DOC

    ', + video: 'https://example.com/a.mp3', summary: '', topics: [], + meta: { name: 'legacy doc', updated: 5000 }, + })); + loadLocalStorageOptions(); + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'legacy doc').click(); + }); + await expect(page.locator('#hypertranscript')).toContainText('RECENTS-DOC'); + expect(dialogs.some((d) => d.includes('DISCARD'))).toBe(false); +}); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 53b7802e..6ca64f29 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -474,6 +474,11 @@ // the round trip (spec § 8.1, normative since 1.2) }; let suppressCapture = false; // true while apply() replays a loaded project + // Synchronous "undownloaded changes" flag (#448's markEdited in embryo): + // isDirty() is async (OPFS timestamps), but the Recents-switch guard must + // decide inside a click handler. Set on every capture trigger and on a new + // transcription; cleared on save-download, open, and session end. + let sessionEdited = false; let autosaveTimer = null; function nowIso() { @@ -786,6 +791,7 @@ function scheduleAutosave() { if (!opfsAvailable || !session.active || suppressCapture) return; + sessionEdited = true; clearTimeout(autosaveTimer); autosaveTimer = setTimeout(writeWorkSnapshot, 1500); } @@ -841,6 +847,7 @@ session.pendingReconcile = null; session.title = ''; session.envelope = null; // a fresh document has no envelope to preserve + sessionEdited = true; // a fresh transcription IS undownloaded work // Provenance is only this project's if the engine reported it moments ago // (imports fire hyperaudioInit without any setTranscriptionInfo call — // a previous transcription's provenance must not leak into them). @@ -943,6 +950,7 @@ a.click(); setTimeout(() => URL.revokeObjectURL(url), 60000); + sessionEdited = false; await patchAppState({ lastDownloadAt: Date.now() }); } @@ -1003,6 +1011,7 @@ session.hasOriginal = loaded.originalText !== null; session.originalJson = loaded.originalText; session.envelope = loaded.recovered ? null : loaded.project; + sessionEdited = false; // the opened file IS the downloaded state if (opfsAvailable) { await clearWork(); @@ -1225,11 +1234,28 @@ // from; work/ itself is left inert and the next session overwrites it. // Interim coexistence rule until #448 (identity generations) / #451 // (legacy-storage removal). + // Switching to a Recents doc discards the project session exactly like + // Open Project replaces it — so it gets the same warning, cancelable at + // capture phase BEFORE the legacy loader swaps the DOM. (Without this, + // an edit made after the last save was silently discarded on switch.) + document.addEventListener('click', (event) => { + if (!session.active || !sessionEdited) return; + const target = event.target; + if (!target || !target.closest || target.tagName === 'INPUT') return; + if (target.closest('.file-item') === null) return; + const proceed = confirm('The current project has changes that were never downloaded as a .hyperaudio file. Switching to a saved transcript will DISCARD them.\n\nPress Cancel to stay (you can save it from FILE → Save Project first), or OK to discard and switch.'); + if (!proceed) { + event.preventDefault(); + event.stopPropagation(); + } + }, true); + document.addEventListener('hyperaudioTranscriptLoaded', () => { if (suppressCapture) return; // our own apply() also fires this event clearTimeout(autosaveTimer); session.active = false; session.title = ''; + sessionEdited = false; try { localStorage.removeItem(WORK_HINT_KEY); } catch (e) { /* private mode */ } }); From a31e156422d5da888aacef1c36c8f65006858b72 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Sat, 1 Aug 2026 11:31:53 +0200 Subject: [PATCH 09/22] Designed modals replace every native alert/confirm in the project flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 13 native dialog sites in hyperaudio-save.js become one shared daisyUI modal (#project-dialog): styled like the rest of the app, labeled buttons instead of OK/Cancel prose ("Replace project", "Discard and switch", "Save with link", "Choose media file", "Attach anyway"), keyboard accessible (primary focused, Escape cancels), and non-blocking. The Recents-switch guard needed a new shape: the modal is async but the click must be decided synchronously, so the guard now intercepts the row click unconditionally, asks, and replays the click on confirmation. That also made the Cancel path e2e-testable for the first time — auto-accepted native confirms couldn't exercise it — so the spec now covers: decline keeps the edit and stays put; confirm replays and switches; no modal after the session ends or after a save; and zero native dialogs anywhere in the flows. --- __TEST__/e2e/project-save.spec.mjs | 54 ++++++++++--- js/hyperaudio-save.js | 119 +++++++++++++++++++++++------ 2 files changed, 139 insertions(+), 34 deletions(-) diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index 5ef08ea8..7cb0e2a8 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -62,6 +62,19 @@ async function openFixture(page, testInfo, dialogs) { 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]'); @@ -215,12 +228,15 @@ test('an unopenable file is refused BEFORE the replace-confirmation, project unt await page.evaluate(() => { document.getElementById('project-open-input').value = ''; }); await page.setInputFiles('#project-open-input', badPath); - await page.waitForTimeout(800); + await awaitModal(page); - // exactly ONE dialog — the refusal; never the replace-confirmation first - expect(dialogs.length).toBe(1); - expect(dialogs[0]).toContain('media compressed'); - expect(dialogs[0]).not.toContain('REPLACE'); + // 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'); }); @@ -234,7 +250,7 @@ test('switching to a Recents doc warns when the project has undownloaded changes span.textContent = 'DIRTY '; span.dispatchEvent(new Event('input', { bubbles: true })); }); - await page.evaluate(() => { + const clickLegacyDoc = () => page.evaluate(() => { localStorage.setItem('hyperaudio:doc:e2e', JSON.stringify({ hypertranscript: '

    RECENTS-DOC

    ', video: 'https://example.com/a.mp3', summary: '', topics: [], @@ -243,14 +259,27 @@ test('switching to a Recents doc warns when the project has undownloaded changes loadLocalStorageOptions(); [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'legacy doc').click(); }); - // the warning fired (and the auto-accept let the switch proceed) - expect(dialogs.some((d) => d.includes('DISCARD'))).toBe(true); + + // Cancel path: the click is intercepted, the modal declined, nothing changes + await clickLegacyDoc(); + await awaitModal(page); + expect(await projectModal(page)).toContain('DISCARD'); + await expect(page.locator('#hypertranscript')).toContainText('DIRTY'); // blocked before the swap + await page.click('#project-dialog-cancel'); + expect(await projectModal(page)).toBeNull(); + await expect(page.locator('#hypertranscript')).toContainText('DIRTY'); // edit survives Cancel + + // Confirm path: the click replays and the switch proceeds + await clickLegacyDoc(); + await awaitModal(page); + await page.click('#project-dialog-confirm'); await expect(page.locator('#hypertranscript')).toContainText('RECENTS-DOC'); - // the session ended with the switch: switching again warns no more - const count = dialogs.length; + + // the session ended with the switch: switching again shows no modal await page.evaluate(() => { [...document.querySelectorAll('.file-item')][0].click(); }); await page.waitForTimeout(300); - expect(dialogs.length).toBe(count); + expect(await projectModal(page)).toBeNull(); + expect(dialogs).toEqual([]); // and never a native dialog anywhere }); test('after Save Project, switching to a Recents doc does not warn', async ({ page }, testInfo) => { @@ -275,5 +304,6 @@ test('after Save Project, switching to a Recents doc does not warn', async ({ pa [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'legacy doc').click(); }); await expect(page.locator('#hypertranscript')).toContainText('RECENTS-DOC'); - expect(dialogs.some((d) => d.includes('DISCARD'))).toBe(false); + expect(await projectModal(page)).toBeNull(); // switched directly, no warning + expect(dialogs).toEqual([]); }); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 6ca64f29..4fb3eb24 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -879,6 +879,73 @@ return new File([blob], name, { type: blob.type || '' }); } + /* -------------------------------------------------------------------------- + * Designed dialogs — the app's daisyUI modal instead of native + * alert()/confirm(): styled like the rest of the UI, non-blocking, keyboard + * accessible (Enter/primary focused, Escape cancels). One shared element; + * paragraphs split on blank lines. projectConfirm resolves true/false; + * projectAlert resolves when dismissed. + * ------------------------------------------------------------------------ */ + let dialogEl = null; + function ensureDialog() { + if (dialogEl !== null) return dialogEl; + dialogEl = document.createElement('div'); + dialogEl.id = 'project-dialog'; + dialogEl.className = 'modal'; + dialogEl.setAttribute('role', 'dialog'); + dialogEl.setAttribute('aria-modal', 'true'); + dialogEl.innerHTML = ''; + document.body.appendChild(dialogEl); + return dialogEl; + } + function projectDialog(message, opts) { + opts = opts || {}; + const el = ensureDialog(); + const msg = el.querySelector('#project-dialog-message'); + msg.textContent = ''; + String(message).split('\n\n').forEach((para, i) => { + const pEl = document.createElement('p'); + pEl.textContent = para; + if (i > 0) pEl.style.marginTop = '12px'; + msg.appendChild(pEl); + }); + const confirmBtn = el.querySelector('#project-dialog-confirm'); + const cancelBtn = el.querySelector('#project-dialog-cancel'); + confirmBtn.textContent = opts.confirmLabel || 'OK'; + cancelBtn.textContent = opts.cancelLabel || 'Cancel'; + cancelBtn.style.display = opts.cancel === false ? 'none' : ''; + el.classList.add('modal-open'); + return new Promise((resolve) => { + const done = (result) => { + el.classList.remove('modal-open'); + confirmBtn.removeEventListener('click', onOk); + cancelBtn.removeEventListener('click', onCancel); + document.removeEventListener('keydown', onKey, true); + resolve(result); + }; + const onOk = () => done(true); + const onCancel = () => done(false); + const onKey = (e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + done(opts.cancel === false); + } + }; + confirmBtn.addEventListener('click', onOk); + cancelBtn.addEventListener('click', onCancel); + document.addEventListener('keydown', onKey, true); + confirmBtn.focus(); + }); + } + const projectAlert = (message) => projectDialog(message, { cancel: false }); + const projectConfirm = (message, confirmLabel, cancelLabel) => + projectDialog(message, { confirmLabel: confirmLabel, cancelLabel: cancelLabel }); + async function saveToFile() { let mediaFile = await resolveMediaFile(); const player = document.querySelector('#hyperplayer'); @@ -896,14 +963,14 @@ await writeMediaOnce(); // the working copy becomes self-contained too } catch (e) { // CORS or network said no: fall back to a link save (§ 7.2), declared. - const proceed = confirm('The remote media cannot be downloaded by the browser (the server does not allow it), so it cannot be embedded in the file.\n\nSave the project with a LINK to the URL instead? The file will contain all your work, but playing it back will need internet access and the URL staying available.'); + const proceed = await projectConfirm('The remote media cannot be downloaded by the browser (the server does not allow it), so it cannot be embedded in the file.\n\nSave the project with a LINK to the URL instead? The file will contain all your work, but playing it back will need internet access and the URL staying available.', 'Save with link', 'Cancel'); if (!proceed) return; saveAsLink = true; } } } if (mediaFile === null && !saveAsLink) { - alert('No media loaded — there is nothing to save yet.'); + await projectAlert('No media loaded — there is nothing to save yet.'); return; } @@ -911,7 +978,7 @@ const warnAt = lowMem ? LARGE_MEDIA_WARN_BYTES_LOWMEM : LARGE_MEDIA_WARN_BYTES; if (mediaFile !== null && mediaFile.size > warnAt) { const mb = Math.round(mediaFile.size / (1024 * 1024)); - if (!confirm(`The media file is large (~${mb} MB). Building the .hyperaudio file needs roughly that much memory and may take a while. Continue?`)) { + if (!(await projectConfirm(`The media file is large (~${mb} MB). Building the .hyperaudio file needs roughly that much memory and may take a while.`, 'Continue', 'Cancel'))) { return; } } @@ -972,12 +1039,12 @@ 'media-compressed': 'This file stores its media compressed, which the format forbids — it was refused for safety.', 'unreadable': 'This is not a readable .hyperaudio file.', }; - alert(messages[e.code] || messages['unreadable']); + await projectAlert(messages[e.code] || messages['unreadable']); return; } if (await isDirty()) { - const proceed = confirm('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.\n\nPress Cancel to go back (you can save it from FILE → Save Project first), or OK to replace it.'); + const proceed = await projectConfirm('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.\n\nYou can save it first from FILE → Save Project.', 'Replace project', 'Cancel'); if (!proceed) return; } @@ -988,7 +1055,7 @@ } else { loaded.mediaFile = null; if (!loaded.recovered && loaded.project.media.kind === 'link') { - alert('Note: this project references its media by URL — it is not self-contained. Playback will use the remote URL.'); + await projectAlert('Note: this project references its media by URL — it is not self-contained. Playback will use the remote URL.'); } else if (!loaded.recovered && loaded.project.media.kind === 'original') { reconcileNow = loaded.project.media; } @@ -1027,7 +1094,7 @@ if (loaded.warnings.length > 0) { console.warn('hyperaudio-save: opened with warnings:', loaded.warnings); if (loaded.recovered) { - alert('The project file was not fully conformant; the transcript was recovered from its HTML copy. Saving again will produce a fully conformant file.'); + await projectAlert('The project file was not fully conformant; the transcript was recovered from its HTML copy. Saving again will produce a fully conformant file.'); } } @@ -1047,24 +1114,23 @@ let reconcileTarget = null; let reconcileInput = null; - function offerMediaReconciliation(desc) { + async function offerMediaReconciliation(desc) { const why = desc.url ? 'The media URL this project points to cannot be played (offline, moved, or gone).' : 'The media file is missing from the project container.'; - if (!confirm(why + '\n\nThe text is loaded and editable. If you have the media on this computer you can re-attach it now — the next save will make the project self-contained again.\n\nChoose the media file?')) { - return; - } + const proceed = await projectConfirm(why + '\n\nThe text is loaded and editable. If you have the media on this computer you can re-attach it now — the next save will make the project self-contained again.', 'Choose media file', 'Not now'); + if (!proceed) return; reconcileTarget = desc; reconcileInput.value = ''; reconcileInput.click(); } - function attachReconciledMedia(file, desc) { + async function attachReconciledMedia(file, desc) { const doubts = []; if (desc.sizeBytes > 0 && desc.sizeBytes !== file.size) doubts.push('its size differs from the saved project'); if (desc.filename && desc.filename !== file.name) doubts.push('its name differs (project media was "' + desc.filename + '")'); if (doubts.length > 0 - && !confirm('This may not be the right file: ' + doubts.join('; ') + '.\n\nAttach it anyway?')) { + && !(await projectConfirm('This may not be the right file: ' + doubts.join('; ') + '.', 'Attach anyway', 'Cancel'))) { return; } const player = document.querySelector('#hyperplayer'); @@ -1076,7 +1142,7 @@ player.addEventListener('loadedmetadata', function check() { player.removeEventListener('loadedmetadata', check); if (Number.isFinite(player.duration) && Math.abs(player.duration - desc.durationSeconds) > 2) { - alert('Warning: the attached media lasts ~' + Math.round(player.duration) + 's but the project was saved with ~' + Math.round(desc.durationSeconds) + 's — it may be the wrong file.'); + projectAlert('Warning: the attached media lasts ~' + Math.round(player.duration) + 's but the project was saved with ~' + Math.round(desc.durationSeconds) + 's — it may be the wrong file.'); } }); } @@ -1175,7 +1241,7 @@ document.querySelector('#project-save-hyperaudio').addEventListener('click', () => { saveToFile().catch((e) => { console.error('hyperaudio-save: save failed', e); - alert('Saving the project failed: ' + e.message); + projectAlert('Saving the project failed: ' + e.message); }); }); document.querySelector('#project-open-hyperaudio').addEventListener('click', () => { @@ -1186,7 +1252,7 @@ if (input.files.length === 1) { openFromFile(input.files[0]).catch((e) => { console.error('hyperaudio-save: open failed', e); - alert('Opening the project failed: ' + e.message); + projectAlert('Opening the project failed: ' + e.message); }); } }); @@ -1238,16 +1304,25 @@ // Open Project replaces it — so it gets the same warning, cancelable at // capture phase BEFORE the legacy loader swaps the DOM. (Without this, // an edit made after the last save was silently discarded on switch.) + let switchApproved = null; // the row whose next click was confirmed via the modal document.addEventListener('click', (event) => { if (!session.active || !sessionEdited) return; const target = event.target; if (!target || !target.closest || target.tagName === 'INPUT') return; - if (target.closest('.file-item') === null) return; - const proceed = confirm('The current project has changes that were never downloaded as a .hyperaudio file. Switching to a saved transcript will DISCARD them.\n\nPress Cancel to stay (you can save it from FILE → Save Project first), or OK to discard and switch.'); - if (!proceed) { - event.preventDefault(); - event.stopPropagation(); - } + const item = target.closest('.file-item'); + if (item === null) return; + if (switchApproved === item) { switchApproved = null; return; } // the replay passes through + // The modal is async but the click must be decided NOW — intercept + // unconditionally, ask, and replay the click on confirmation. + event.preventDefault(); + event.stopPropagation(); + projectConfirm('The current project has changes that were never downloaded as a .hyperaudio file. Switching to a saved transcript will DISCARD them.\n\nYou can save it first from FILE → Save Project.', 'Discard and switch', 'Cancel') + .then((proceed) => { + if (proceed) { + switchApproved = item; + item.click(); + } + }); }, true); document.addEventListener('hyperaudioTranscriptLoaded', () => { From d8ac68d9668c5a78779dba73c0734f4a5f478c41 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Sat, 1 Aug 2026 11:47:42 +0200 Subject: [PATCH 10/22] Flush the pending Recents autosave before the document is replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recents edits autosave on a 2s debounce; replacing the document inside that window silently dropped the last edits — the timer fired after the swap, against the replacing document (reported switching between two Recents docs, but transcribe-over and .hyperaudio-open-over lost the tail the same way, and this predates the format branch — it shipped with 0.9.0's autosave). flushRecentsAutosave() runs any pending save immediately and is called on all three replacement paths: the Recents row click, the hyperaudioInit auto-add handler, and (cross-module, guarded) hyperaudio-save's openFromFile before apply. Regression e2e: edit, switch within the debounce, verify the entry carries the edit and switching back shows it. --- __TEST__/e2e/storage.spec.mjs | 29 +++++++++++++++ js/hyperaudio-lite-editor-storage.js | 55 +++++++++++++++++++--------- js/hyperaudio-save.js | 6 +++ 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index 6873ec1b..b8156507 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -501,6 +501,35 @@ test('an entry predating mediaRef clears the stamp instead of offering stale med expect(await page.evaluate(() => document.getElementById('hyperplayer').dataset.mediaRef)).toBeUndefined(); }); +test('switching docs flushes the pending autosave — the last edits are not dropped', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + // edit beta, then switch to alpha IMMEDIATELY — inside the 2s debounce + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'FLUSH-ME '; + span.dispatchEvent(new Event('input', { bubbles: true })); + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'alpha').click(); + }); + await page.waitForTimeout(300); + // the edit landed in beta's entry despite the debounce not having elapsed + const saved = 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)).hypertranscript; + }); + expect(saved).toContain('FLUSH-ME'); + // and switching back shows it + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + await expect(page.locator('#hypertranscript')).toContainText('FLUSH-ME'); +}); + test('a filename containing markup renders as text (#410)', async ({ page }) => { await seed(page); await page.evaluate(() => { diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index 519b5fd5..4c80df5c 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -780,33 +780,51 @@ function maybeShowAutosaveNotice(storage = window.localStorage) { } let autosaveTimer = null; +let autosavePending = false; function scheduleAutosave() { + autosavePending = true; clearTimeout(autosaveTimer); - autosaveTimer = setTimeout(() => { - // A pending Restore means the on-screen doc was just deleted on purpose — - // silently recreating it on the next keystroke would fight that; the - // Restore button is the explicit path back. - if (document.querySelector('#recents-notice[data-has-action="true"]') !== null) return; - - if (activeDocKey === null) { - // A never-saved document (the intro demo, or a deleted doc whose Restore - // offer was dismissed): the first edit brings it into Recents (#436), so - // "everything you touch is in Recents" holds with no manual save. - saveHyperTranscriptToLocalStorage(mediaDisplayName()); - maybeShowAutosaveNotice(); - } else { - const entry = readTranscriptEntry(activeDocKey, window.localStorage); - saveHyperTranscriptToLocalStorage(entry !== null ? entryName(activeDocKey, entry) : undefined); - } - loadLocalStorageOptions(); // reflect the new "updated" order - }, AUTOSAVE_DEBOUNCE_MS); + autosaveTimer = setTimeout(runPendingAutosave, AUTOSAVE_DEBOUNCE_MS); +} + +function runPendingAutosave() { + autosavePending = false; + // A pending Restore means the on-screen doc was just deleted on purpose — + // silently recreating it on the next keystroke would fight that; the + // Restore button is the explicit path back. + if (document.querySelector('#recents-notice[data-has-action="true"]') !== null) return; + + if (activeDocKey === null) { + // A never-saved document (the intro demo, or a deleted doc whose Restore + // offer was dismissed): the first edit brings it into Recents (#436), so + // "everything you touch is in Recents" holds with no manual save. + saveHyperTranscriptToLocalStorage(mediaDisplayName()); + maybeShowAutosaveNotice(); + } else { + const entry = readTranscriptEntry(activeDocKey, window.localStorage); + saveHyperTranscriptToLocalStorage(entry !== null ? entryName(activeDocKey, entry) : undefined); + } + loadLocalStorageOptions(); // reflect the new "updated" order +} + +// Run any pending debounced autosave NOW — called before the document is +// replaced (a Recents switch, a new transcription/import, a .hyperaudio +// open). Without this, edits made in the last AUTOSAVE_DEBOUNCE_MS before a +// switch were silently dropped: the timer fired after the swap, against the +// replacing document. Global on purpose: hyperaudio-save.js calls it before +// applying an opened project. +function flushRecentsAutosave() { + if (!autosavePending) return; + clearTimeout(autosaveTimer); + runPendingAutosave(); } if (typeof document !== 'undefined') { // A new transcription/import: always a NEW entry (never overwrite whatever // was active before), named after its media. window.document.addEventListener('hyperaudioInit', () => { + flushRecentsAutosave(); // the replaced doc's last edits must land first hideRestoreNotice(); activeDocKey = null; suppressDerivedCapture = true; @@ -944,6 +962,7 @@ function fileSelectHandleClick(event) { if (event.target.classList && event.target.classList.contains('recents-rename-input')) { return; } + flushRecentsAutosave(); // the outgoing doc's last edits must land first loadHyperTranscriptFromLocalStorage(event.currentTarget.getAttribute("data-key")); markActiveRecentsRow(); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 4fb3eb24..94f2aa7b 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -1048,6 +1048,12 @@ if (!proceed) return; } + // A legacy Recents doc may hold a pending debounced autosave — its last + // edits must land before this open replaces the document. + if (typeof flushRecentsAutosave === 'function') { + try { flushRecentsAutosave(); } catch (e) { /* legacy module absent */ } + } + let reconcileNow = null; // § 7.3: original-kind container missing its media entry if (loaded.mediaData !== null && loaded.mediaEntryName !== null) { const mimeType = loaded.project !== null ? (loaded.project.media.mimeType || '') : ''; From 715a44124a994530c3065753fa09487b6195db32 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Sat, 1 Aug 2026 11:53:45 +0200 Subject: [PATCH 11/22] Destructive confirmations: red confirm button, safe default focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Discard and switch" and "Replace project" — the two dialogs where unsaved work dies — get a danger variant: the confirm goes btn-error red (matching the Recents armed-delete convention) and default focus moves to Cancel, so Enter through a half-read dialog cannot destroy work. Neutral choices (save-with-link, reconciliation, large-media) stay primary. Spec asserts both the styling and the focus target. --- __TEST__/e2e/project-save.spec.mjs | 5 +++++ js/hyperaudio-save.js | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index 7cb0e2a8..318f4b85 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -264,6 +264,11 @@ test('switching to a Recents doc warns when the project has undownloaded changes await clickLegacyDoc(); await awaitModal(page); expect(await projectModal(page)).toContain('DISCARD'); + // destructive confirm: red button, and the SAFE button holds default focus + expect(await page.evaluate(() => ({ + danger: document.getElementById('project-dialog-confirm').classList.contains('btn-error'), + focused: document.activeElement && document.activeElement.id, + }))).toEqual({ danger: true, focused: 'project-dialog-cancel' }); await expect(page.locator('#hypertranscript')).toContainText('DIRTY'); // blocked before the swap await page.click('#project-dialog-cancel'); expect(await projectModal(page)).toBeNull(); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 94f2aa7b..1caf26fe 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -919,6 +919,10 @@ confirmBtn.textContent = opts.confirmLabel || 'OK'; cancelBtn.textContent = opts.cancelLabel || 'Cancel'; cancelBtn.style.display = opts.cancel === false ? 'none' : ''; + // Destructive confirmations (work dies): the confirm goes btn-error red — + // matching the Recents armed-delete convention — and the SAFE button takes + // the default focus, so Enter through a half-read dialog cannot destroy. + confirmBtn.className = opts.danger === true ? 'btn btn-error' : 'btn btn-primary'; el.classList.add('modal-open'); return new Promise((resolve) => { const done = (result) => { @@ -939,12 +943,14 @@ confirmBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); document.addEventListener('keydown', onKey, true); - confirmBtn.focus(); + (opts.danger === true ? cancelBtn : confirmBtn).focus(); }); } const projectAlert = (message) => projectDialog(message, { cancel: false }); const projectConfirm = (message, confirmLabel, cancelLabel) => projectDialog(message, { confirmLabel: confirmLabel, cancelLabel: cancelLabel }); + const projectConfirmDanger = (message, confirmLabel, cancelLabel) => + projectDialog(message, { confirmLabel: confirmLabel, cancelLabel: cancelLabel, danger: true }); async function saveToFile() { let mediaFile = await resolveMediaFile(); @@ -1044,7 +1050,7 @@ } if (await isDirty()) { - const proceed = await projectConfirm('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.\n\nYou can save it first from FILE → Save Project.', 'Replace project', 'Cancel'); + const proceed = await projectConfirmDanger('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.\n\nYou can save it first from FILE → Save Project.', 'Replace project', 'Cancel'); if (!proceed) return; } @@ -1322,7 +1328,7 @@ // unconditionally, ask, and replay the click on confirmation. event.preventDefault(); event.stopPropagation(); - projectConfirm('The current project has changes that were never downloaded as a .hyperaudio file. Switching to a saved transcript will DISCARD them.\n\nYou can save it first from FILE → Save Project.', 'Discard and switch', 'Cancel') + projectConfirmDanger('The current project has changes that were never downloaded as a .hyperaudio file. Switching to a saved transcript will DISCARD them.\n\nYou can save it first from FILE → Save Project.', 'Discard and switch', 'Cancel') .then((proceed) => { if (proceed) { switchApproved = item; From ec6e3d7949d4d68e1d5fc3f7711b2640f5d28b89 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Sat, 1 Aug 2026 12:12:50 +0200 Subject: [PATCH 12/22] =?UTF-8?q?Fix=20the=20danger=20button's=20resting?= =?UTF-8?q?=20fill=20=E2=80=94=20btn-error=20was=20purged=20to=20fragments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tailwind-min.css predates any btn-error in markup, so the purge kept only the hover/active fills and resting text color; the resting background rule was gone, leaving the destructive confirm looking like a default button (caught in manual review — the earlier existence grep matched a fragment and I didn't check which rule survived). Explicit resting-state rule from the theme vars, scoped to the dialog; css cache-buster to 0.10.0. --- css/hyperaudio-lite-editor.css | 10 ++++++++++ index.html | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index 52e6f6e7..ba6da03c 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -1171,3 +1171,13 @@ 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)); +} diff --git a/index.html b/index.html index c3bf095f..3f5d97bd 100644 --- a/index.html +++ b/index.html @@ -108,7 +108,7 @@ } - + From ed7e1a662e5cef06e36dc942f1386a029cd382c3 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Sat, 1 Aug 2026 12:39:56 +0200 Subject: [PATCH 13/22] Unsaved-changes dialogs gain "Save and open" / "Save and switch" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advising "you can save it first from FILE → Save Project" while making the user cancel, find the menu, save, and redo the action was the friction the button solves — the classic Save / Don't Save / Cancel triad, matching the native app's convention. The save button is the safe-and-constructive default (purple, holds focus — Enter saves and continues, and can never destroy work); the destructive confirm stays red; Cancel stays ghost. saveToFile now reports success (false on every abandoned path: link fallback declined, large-media cancelled, no media) and the continuation aborts unless the save actually completed — "Save and open" must never fall through to a replace after an abandoned save. Spec: focus/label assertions updated; new e2e drives Save-and-switch end to end (download observed, then the switch completes). --- __TEST__/e2e/project-save.spec.mjs | 32 ++++++++++++++++++++-- js/hyperaudio-save.js | 44 ++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index 318f4b85..ca82a1f8 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -264,11 +264,13 @@ test('switching to a Recents doc warns when the project has undownloaded changes await clickLegacyDoc(); await awaitModal(page); expect(await projectModal(page)).toContain('DISCARD'); - // destructive confirm: red button, and the SAFE button holds default focus + // destructive confirm: red button; default focus on the safe-and-constructive + // Save option, so Enter can never destroy work 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, - }))).toEqual({ danger: true, focused: 'project-dialog-cancel' }); + }))).toEqual({ danger: true, saveLabel: 'Save and switch', focused: 'project-dialog-extra' }); await expect(page.locator('#hypertranscript')).toContainText('DIRTY'); // blocked before the swap await page.click('#project-dialog-cancel'); expect(await projectModal(page)).toBeNull(); @@ -287,6 +289,32 @@ test('switching to a Recents doc warns when the project has undownloaded changes expect(dialogs).toEqual([]); // and never a native dialog anywhere }); +test('"Save and switch" downloads the project, then completes the switch', 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 })); + }); + await page.evaluate(() => { + localStorage.setItem('hyperaudio:doc:e2e', JSON.stringify({ + hypertranscript: '

    RECENTS-DOC

    ', + video: 'https://example.com/a.mp3', summary: '', topics: [], + meta: { name: 'legacy doc', updated: 5000 }, + })); + loadLocalStorageOptions(); + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'legacy doc').click(); + }); + await awaitModal(page); + const downloadPromise = page.waitForEvent('download'); + await page.click('#project-dialog-extra'); + const download = await downloadPromise; // the save happened… + expect(download.suggestedFilename()).toContain('.hyperaudio'); + await expect(page.locator('#hypertranscript')).toContainText('RECENTS-DOC'); // …then the switch + expect(dialogs).toEqual([]); +}); + test('after Save Project, switching to a Recents doc does not warn', async ({ page }, testInfo) => { const dialogs = []; await openFixture(page, testInfo, dialogs); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 1caf26fe..90330407 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -898,6 +898,7 @@ + '
    ' + ''; document.body.appendChild(dialogEl); @@ -916,9 +917,12 @@ }); const confirmBtn = el.querySelector('#project-dialog-confirm'); const cancelBtn = el.querySelector('#project-dialog-cancel'); + const extraBtn = el.querySelector('#project-dialog-extra'); confirmBtn.textContent = opts.confirmLabel || 'OK'; cancelBtn.textContent = opts.cancelLabel || 'Cancel'; cancelBtn.style.display = opts.cancel === false ? 'none' : ''; + extraBtn.textContent = opts.extraLabel || ''; + extraBtn.style.display = opts.extraLabel ? '' : 'none'; // Destructive confirmations (work dies): the confirm goes btn-error red — // matching the Recents armed-delete convention — and the SAFE button takes // the default focus, so Enter through a half-read dialog cannot destroy. @@ -929,11 +933,13 @@ el.classList.remove('modal-open'); confirmBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); + extraBtn.removeEventListener('click', onExtra); document.removeEventListener('keydown', onKey, true); resolve(result); }; const onOk = () => done(true); const onCancel = () => done(false); + const onExtra = () => done('extra'); const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); @@ -942,8 +948,11 @@ }; confirmBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); + extraBtn.addEventListener('click', onExtra); document.addEventListener('keydown', onKey, true); - (opts.danger === true ? cancelBtn : confirmBtn).focus(); + // default focus: the safe-and-constructive extra (Save…) when present, + // else Cancel for destructive dialogs, else the confirm + (opts.extraLabel ? extraBtn : (opts.danger === true ? cancelBtn : confirmBtn)).focus(); }); } const projectAlert = (message) => projectDialog(message, { cancel: false }); @@ -970,14 +979,14 @@ } catch (e) { // CORS or network said no: fall back to a link save (§ 7.2), declared. const proceed = await projectConfirm('The remote media cannot be downloaded by the browser (the server does not allow it), so it cannot be embedded in the file.\n\nSave the project with a LINK to the URL instead? The file will contain all your work, but playing it back will need internet access and the URL staying available.', 'Save with link', 'Cancel'); - if (!proceed) return; + if (!proceed) return false; saveAsLink = true; } } } if (mediaFile === null && !saveAsLink) { await projectAlert('No media loaded — there is nothing to save yet.'); - return; + return false; } const lowMem = typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 4; @@ -985,7 +994,7 @@ if (mediaFile !== null && mediaFile.size > warnAt) { const mb = Math.round(mediaFile.size / (1024 * 1024)); if (!(await projectConfirm(`The media file is large (~${mb} MB). Building the .hyperaudio file needs roughly that much memory and may take a while.`, 'Continue', 'Cancel'))) { - return; + return false; } } @@ -1025,6 +1034,7 @@ sessionEdited = false; await patchAppState({ lastDownloadAt: Date.now() }); + return true; } async function openFromFile(file) { @@ -1050,8 +1060,15 @@ } if (await isDirty()) { - const proceed = await projectConfirmDanger('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.\n\nYou can save it first from FILE → Save Project.', 'Replace project', 'Cancel'); - if (!proceed) return; + const choice = await projectDialog('The current project has changes that were never downloaded as a .hyperaudio file. Opening this file will REPLACE it.', { + confirmLabel: 'Replace project', cancelLabel: 'Cancel', danger: true, extraLabel: 'Save and open', + }); + if (choice === false) return; + if (choice === 'extra') { + let saved = false; + try { saved = await saveToFile(); } catch (e) { saved = false; } + if (saved !== true) return; // the save was abandoned — replacing now would lose it + } } // A legacy Recents doc may hold a pending debounced autosave — its last @@ -1328,12 +1345,17 @@ // unconditionally, ask, and replay the click on confirmation. event.preventDefault(); event.stopPropagation(); - projectConfirmDanger('The current project has changes that were never downloaded as a .hyperaudio file. Switching to a saved transcript will DISCARD them.\n\nYou can save it first from FILE → Save Project.', 'Discard and switch', 'Cancel') - .then((proceed) => { - if (proceed) { - switchApproved = item; - item.click(); + projectDialog('The current project has changes that were never downloaded as a .hyperaudio file. Switching to a saved transcript will DISCARD them.', { + confirmLabel: 'Discard and switch', cancelLabel: 'Cancel', danger: true, extraLabel: 'Save and switch', + }).then(async (choice) => { + if (choice === false) return; + if (choice === 'extra') { + let saved = false; + try { saved = await saveToFile(); } catch (e) { saved = false; } + if (saved !== true) return; // abandoned save — stay on the project } + switchApproved = item; + item.click(); }); }, true); From 66df671b54210fb52f7e7f9795167029447f79c6 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Sat, 1 Aug 2026 12:55:41 +0200 Subject: [PATCH 14/22] Destructive dialogs: two buttons + closable modal; tightened copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unsaved-changes triads drop the explicit Cancel: the modal gains the app's standard top-right ✕ (matching the export/transcribe/captions modals) and ✕/Escape act as cancel, leaving the action row as the real choice — safe path (Save and open / Save and switch, purple, default focus) vs destructive path (Discard and open / Discard and switch, red). Neutral dialogs keep their named negative buttons; dismissing an OK-alert acknowledges it. Copy per review: "The current project has changes not yet saved as a .hyperaudio file. Opening a new project / Switching to a saved transcript will DISCARD them." — "not yet saved" over "never downloaded" now that the dialog contains the Save button, and DISCARD symmetry across both flows (the open confirm becomes "Discard and open" to match its own sentence). --- __TEST__/e2e/project-save.spec.mjs | 7 ++++--- js/hyperaudio-save.js | 20 ++++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs index ca82a1f8..9ec60362 100644 --- a/__TEST__/e2e/project-save.spec.mjs +++ b/__TEST__/e2e/project-save.spec.mjs @@ -270,11 +270,12 @@ test('switching to a Recents doc warns when the project has undownloaded changes danger: document.getElementById('project-dialog-confirm').classList.contains('btn-error'), saveLabel: document.getElementById('project-dialog-extra').textContent, focused: document.activeElement && document.activeElement.id, - }))).toEqual({ danger: true, saveLabel: 'Save and switch', focused: 'project-dialog-extra' }); + cancelHidden: document.getElementById('project-dialog-cancel').style.display === 'none', + }))).toEqual({ danger: true, saveLabel: 'Save and switch', focused: 'project-dialog-extra', cancelHidden: true }); await expect(page.locator('#hypertranscript')).toContainText('DIRTY'); // blocked before the swap - await page.click('#project-dialog-cancel'); + await page.click('#project-dialog-close'); // the ✕ IS the cancel (two-button rule) expect(await projectModal(page)).toBeNull(); - await expect(page.locator('#hypertranscript')).toContainText('DIRTY'); // edit survives Cancel + await expect(page.locator('#hypertranscript')).toContainText('DIRTY'); // edit survives dismissal // Confirm path: the click replays and the switch proceeds await clickLegacyDoc(); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js index 90330407..bef46bcd 100644 --- a/js/hyperaudio-save.js +++ b/js/hyperaudio-save.js @@ -894,8 +894,9 @@ dialogEl.className = 'modal'; dialogEl.setAttribute('role', 'dialog'); dialogEl.setAttribute('aria-modal', 'true'); - dialogEl.innerHTML = '