diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs new file mode 100644 index 0000000..6ff0c32 --- /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 0000000..27f8fab --- /dev/null +++ b/__TEST__/unit/hyperaudio-save.test.mjs @@ -0,0 +1,244 @@ +// Unit tests for the .hyperaudio save format (spec: issue #403). +// Exercises the pure FORMAT and CONTAINER layers of js/hyperaudio-save.js — +// version rules, validation, container round-trip, whitelist-read and the +// mimetype-first convention — plus the struck round-trip in the converter. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const save = require('../../js/hyperaudio-save.js'); +const { jsonToHTML } = require('../../js/html-json-converter.js'); +const JSZip = require('jszip'); + +function sampleTranscript() { + return { + words: [ + { start: 0.32, end: 0.84, text: 'Benvenuti' }, + { start: 0.84, end: 1.02, text: 'ehm', struck: true }, + { start: 1.1, end: 1.3, text: 'a' }, + ], + paragraphs: [{ speaker: 'Maria', start: 0.32, end: 6.5 }], + }; +} + +function sampleState() { + return { + generatorVersion: '0.8.2', + created: '2026-07-10T09:00:00Z', + modified: '2026-07-10T11:30:00Z', + media: { + kind: 'original', path: 'media/test.mp4', url: null, filename: 'test.mp4', + mimeType: 'video/mp4', durationSeconds: 62.5, sizeBytes: 4, + }, + options: { + gapRemoval: { enabled: true, thresholdMs: 500, bufferMs: 100 }, + updateCaptionsFromTranscript: false, + view: { showSpeakers: true, showTimecodes: false }, + }, + texts: { title: 'Intervista', language: 'it', summary: 'riassunto', topics: ['hyperaudio'] }, + provenance: { engine: 'deepgram', model: 'nova-3', transcribedAt: '2026-07-10T08:55:00Z' }, + hasOriginal: true, + transcript: sampleTranscript(), + }; +} + +/* ---------- FORMAT ---------- */ + +test('checkFormatVersion: accepts same-major, rejects higher major and malformed', () => { + assert.equal(save.checkFormatVersion('1.0').ok, true); + assert.equal(save.checkFormatVersion('1.7').ok, true); // future minor: ignore-unknown + assert.deepEqual(save.checkFormatVersion('2.0').code, 'version-major'); + assert.equal(save.checkFormatVersion('banana').code, 'version-malformed'); + assert.equal(save.checkFormatVersion(1.0).code, 'version-malformed'); + assert.equal(save.checkFormatVersion('1.0.3').code, 'version-malformed'); +}); + +test('validateMediaPath: one segment under media/, no traversal, no absolutes', () => { + assert.equal(save.validateMediaPath('media/video.mp4'), true); + assert.equal(save.validateMediaPath('media/città è.mp4'), true); + assert.equal(save.validateMediaPath('media/../evil'), false); + assert.equal(save.validateMediaPath('media/sub/dir.mp4'), false); + assert.equal(save.validateMediaPath('/etc/passwd'), false); + assert.equal(save.validateMediaPath('media\\evil'), false); + assert.equal(save.validateMediaPath('other/file.mp4'), false); + assert.equal(save.validateMediaPath(null), false); +}); + +test('buildProjectJson: complete shape; provenance carries originalTranscript', () => { + const project = save.buildProjectJson(sampleState()); + assert.equal(project.format, 'hyperaudio'); + assert.equal(project.formatVersion, save.FORMAT_VERSION); + assert.equal(project.media.filename, 'test.mp4'); + assert.equal(project.options.captions.updateFromTranscript, false); + assert.equal(project.texts.title, 'Intervista'); + assert.equal(project.provenance.originalTranscript, 'transcript.original.json'); + assert.equal(project.transcript.words[1].struck, true); +}); + +test('buildProjectJson: provenance omitted when unknown', () => { + const state = sampleState(); + state.provenance = null; + const project = save.buildProjectJson(state); + assert.equal(project.provenance, undefined); +}); + +test('validateProjectJson: accepts a conformant project', () => { + const result = save.validateProjectJson(save.buildProjectJson(sampleState())); + assert.deepEqual(result, { ok: true, errors: [] }); +}); + +test('validateProjectJson: flags unknown kind, bad path, bad words', () => { + const good = () => save.buildProjectJson(sampleState()); + + let p = good(); + p.media.kind = 'hologram'; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'media-kind')); + + p = good(); + p.media.path = 'media/../evil.mp4'; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'media-path')); + + p = good(); + p.transcript.words[0].end = -1; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'transcript')); + + p = good(); + delete p.format; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'format')); +}); + +test('validateProjectJson: link kind needs an http(s) url, and nothing else', () => { + const p = save.buildProjectJson(sampleState()); + p.media = { kind: 'link', path: null, url: 'https://example.org/media.mp3', filename: '', mimeType: '', durationSeconds: 62.5, sizeBytes: 0 }; + assert.deepEqual(save.validateProjectJson(p), { ok: true, errors: [] }); + + p.media.url = 'file:///etc/passwd'; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'media')); + + p.media.url = null; + assert.ok(save.validateProjectJson(p).errors.some((e) => e.code === 'media')); +}); + +/* ---------- converter: struck round-trip (writer side) ---------- */ + +test('jsonToHTML: struck word carries the line-through style, others do not', () => { + const html = jsonToHTML(sampleTranscript()); + assert.match(html, /ehm <\/span>/); + assert.match(html, /Benvenuti <\/span>/); +}); + +/* ---------- CONTAINER ---------- */ + +function sampleFiles() { + const project = save.buildProjectJson(sampleState()); + return { + json: save.serializeProjectJson(project), + html: '

Benvenuti

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

Hi

'); + const out = await zip.generateAsync({ type: 'uint8array' }); + const loaded = await save.unzipProject(out, JSZip); + assert.equal(loaded.recovered, true); + assert.match(loaded.htmlText, /Hi/); + assert.ok(loaded.warnings.length > 0); +}); + +test('container: no json and no html is unreadable', async () => { + const zip = new JSZip(); + zip.file('random.txt', 'nothing to see'); + const out = await zip.generateAsync({ type: 'uint8array' }); + await assert.rejects(() => save.unzipProject(out, JSZip), (e) => e.code === 'unreadable'); +}); + +test('container: missing mimetype entry is tolerated with a warning', async () => { + const files = sampleFiles(); + const zip = new JSZip(); + zip.file('hyperaudio.json', files.json); + zip.file('media/test.mp4', files.media.data); + const out = await zip.generateAsync({ type: 'uint8array' }); + const loaded = await save.unzipProject(out, JSZip); + assert.equal(loaded.recovered, false); + assert.ok(loaded.warnings.some((w) => /mimetype/.test(w))); +}); + +test('container: unknown entries in the zip are ignored (whitelist-read)', async () => { + const files = sampleFiles(); + const zip = new JSZip(); + zip.file('mimetype', save.CONTAINER_MIMETYPE, { compression: 'STORE' }); + zip.file('hyperaudio.json', files.json); + zip.file('media/test.mp4', files.media.data); + zip.file('../../../evil.sh', 'echo pwned'); + zip.file('extra/unknown.bin', new Uint8Array([9, 9])); + const out = await zip.generateAsync({ type: 'uint8array' }); + const loaded = await save.unzipProject(out, JSZip); + assert.equal(loaded.recovered, false); + assert.equal(loaded.project.format, 'hyperaudio'); +}); diff --git a/index.html b/index.html index e4f1866..28fca97 100644 --- a/index.html +++ b/index.html @@ -1052,6 +1052,9 @@

Load from Local Storage

+ + + diff --git a/js/editor-audio-cut.js b/js/editor-audio-cut.js index fadfb87..e4f93d6 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 58713d3..2770ed7 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); } }); diff --git a/js/hyperaudio-save.js b/js/hyperaudio-save.js new file mode 100644 index 0000000..6436b73 --- /dev/null +++ b/js/hyperaudio-save.js @@ -0,0 +1,1204 @@ +/* + * ============================================================================ + * .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). + * 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.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'; + + 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 + 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) + }; + 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): 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) { + 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; + 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). + 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(); + } + } + + // 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() { + let mediaFile = await resolveMediaFile(); + const player = document.querySelector('#hyperplayer'); + 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 { + 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 !== 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; + } + } + + 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: mediaFile !== null ? { name: mediaFile.name, data: mediaFile } : null, + }, 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; + } + + 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 }); + } 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.'); + } else if (!loaded.recovered && loaded.project.media.kind === 'original') { + reconcileNow = loaded.project.media; + } + } + + 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.mediaFileFromUrl = null; + session.pendingReconcile = (!loaded.recovered && loaded.project.media.kind === 'link') + ? loaded.project.media : null; + 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.'); + } + } + + 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 + // 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.mediaFileFromUrl = null; + session.pendingReconcile = project.media.kind === 'link' ? project.media : null; + 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); + + 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); + 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]; + 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)); + }); + + // 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 52aab94..4dfa9b3 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 0000000..ff4cfd5 --- /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 fd748f9..01aa346 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 e165051..1ffaa6d 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", ]); }) );