Save/open projects as a single .hyperaudio file (#403) - #404
Merged
Conversation
… settings (#403) The converter dropped redactions (inline line-through) on both directions, so any JSON export/import silently resurrected every cut. Words now carry "struck": true (default not serialized). editor-audio-cut.js exposes getGapRemovalSettings/applyGapRemovalSettings so a loaded project can restore them through the normal UI path.
…py, UI (#403) One module (js/hyperaudio-save.js) in five layers: format (build/validate hyperaudio.json) and container (zip/unzip, whitelist-read) are pure and node-tested; OPFS holds the single working copy with autosave; the bridge gathers/applies editor state; the UI adds Save/Open project to the file menu and restores the working copy at boot. JSZip 3.10.1 vendored (MIT branch of its dual license) and precached. 15 unit tests + 4 e2e (real download, OPFS restore across reload).
…ciliation (#403) Format 1.1 (additive minor bump): media.kind "link" is now writable. Save on a URL-mode project first tries to fetch and embed the media byte-for-byte (the server's CORS policy decides); when refused, the project is saved with a declared URL-only link descriptor after user confirmation. On open, an unreachable link URL (playback needs no CORS, so the player's error event is the only honest signal) or an original-kind container missing its media entry offers reconciliation per spec 7.3: re-attach a local copy, verified heuristically (size/filename, duration once metadata loads); the next save is self-contained again.
This was referenced Jul 31, 2026
Closed
Closed
Phase B: workerized container build, streaming, multi-project recovery, quota (#403 follow-ups)
#452
Open
…ation with #406 escaping; union the service-worker precache list
…c updated to current markup The deleted legacy title field (#439) was gather()'s only memory of the project title, so an open→save round trip silently degraded the title to the media filename. The session now carries texts.title across apply/gather (cleared on new transcription/import); #449's UI field slots in as the preferred source when it exists, and the e2e asserts the title through the save round-trip instead of the deleted field — including after an OPFS boot restore. The module's hidden file inputs get the #402 treatment (title="" + aria-label), fixing the a11y regression sweep. Cache-busters for the two changed files point at 0.10.0 — the .hyperaudio model is the next minor.
Member
|
Rebased onto current
Suites on the merged branch: 61 unit / 74 e2e, all green. Next slices land against this branch or as follow-ups per the Phase A sequence: #447 (parity), #448 (save integrity), #449 (explicit-save UX + bridge hook), #450 (second-tab guard), #451 (legacy deletion). Note: #453 (the redaction fix cherry-picked from this branch's first commit) is open against main — once it merges, the next main merge here will be a no-op for that commit. |
The module correctly ignores Recents loads for project identity (they fire hyperaudioTranscriptLoaded, not hyperaudioInit) — but its autosave listens to hyperaudioGenerateCaptionsFromTranscript, which a Recents load fires when regenerating captions. A capture after the swap wrote the RECENTS doc's content into the OLD project's working copy: a franken work/ and a spurious "changes that were never downloaded" replace-warning on the next open (reported in live testing minutes after the rebase landed). A Recents load is the one document replacement that fires hyperaudioTranscriptLoaded outside our own apply()'s suppressCapture window, so the module now ends the session there: captures stop, the boot hint clears (a reload must not resurrect a project the user navigated away from), work/ is left inert for the next session to overwrite. Interim rule until #448's identity generations and #451's legacy-storage removal. The regression test drives the exact reported flow: open a container, switch to a Recents doc, re-open — no dialog, no late capture.
The normative spec moves out of #403's comment into docs/hyperaudio-format.md as v1.2, freezing the cross-implementation decisions while there is no installed base to migrate: - media.kind "none" (§ 7.2.2): text-only projects are conforming — the writer emits "none" instead of fabricating an "original" descriptor pointing at nothing, and the reader accepts it without a missing-media warning. - Writer-side envelope preservation (§ 8.1, normative): rewrites start from the opened envelope and overwrite only editor-owned fields, merging inside known objects — unknown top-level and nested fields survive an open→save round trip. The session keeps the raw parsed envelope; a fresh document clears it. - media.path segment rule pinned (§ 10.2): exactly one non-empty segment, no separators of either convention, only the EXACT "." / ".." segments rejected — a ".." substring ("mix..final.mp3") is legal. The old any-substring rejection made conforming containers written elsewhere unreadable. A writer-side sanitizeMediaFilename mirrors the rule at descriptor construction and zip entry creation (an unsanitized "../evil.wav" previously produced literal traversal entries in the archive). - Size caps in UTF-8 bytes with fatal decoding (§ 10.3): text.length counted UTF-16 units, and invalid UTF-8 silently decoded to replacement characters here while native readers rejected the file. - Media entries MUST be STORED on read (§ 7.1): a compressed media entry defeats the size accounting and is refused. JSZip exposes the method only via _data; the unit suite pins that access so an upgrade fails loudly. - App/session identity is explicitly excluded from the container (§ 9). docs/format-fixtures/ holds the conformance-case manifest (currently executable-first in the unit lane; checked-in binary fixtures are the next #447 step). FORMAT_VERSION → 1.2. 68 unit / 75 e2e green.
Opening a non-conforming file over a dirty project asked "opening this file will REPLACE it — OK?" and THEN refused the file: the user consented to a replacement that never happened (found in live testing with a compressed-media container). The unzip/validation now runs first; the replace-confirmation only appears for a file that can actually be opened — the prepare → confirm → apply ordering #448 formalizes. Regression test drives the exact flow: dirty project + bad container → exactly one dialog (the refusal), project untouched.
Switching to a legacy Recents doc discards the project session exactly like Open Project replaces it — but only Open Project warned; an edit made after the last save was silently discarded on switch (reported in live testing). The switch now gets the same confirmation, cancelable at capture phase before the legacy loader swaps the DOM. The decision needs a SYNCHRONOUS dirty signal inside a click handler, which async OPFS-timestamp isDirty() cannot give — so this introduces sessionEdited, #448's markEdited in embryo: set on every capture trigger and on a fresh transcription (never-downloaded by definition), cleared on save-download, on open (the file IS the downloaded state), and on session end. E2e covers the warn-then-switch flow, the no-more-warnings-after-switch state, and the reset on Save Project.
All 13 native dialog sites in hyperaudio-save.js become one shared
daisyUI modal (#project-dialog): styled like the rest of the app,
labeled buttons instead of OK/Cancel prose ("Replace project",
"Discard and switch", "Save with link", "Choose media file", "Attach
anyway"), keyboard accessible (primary focused, Escape cancels), and
non-blocking.
The Recents-switch guard needed a new shape: the modal is async but
the click must be decided synchronously, so the guard now intercepts
the row click unconditionally, asks, and replays the click on
confirmation. That also made the Cancel path e2e-testable for the
first time — auto-accepted native confirms couldn't exercise it — so
the spec now covers: decline keeps the edit and stays put; confirm
replays and switches; no modal after the session ends or after a save;
and zero native dialogs anywhere in the flows.
Recents edits autosave on a 2s debounce; replacing the document inside that window silently dropped the last edits — the timer fired after the swap, against the replacing document (reported switching between two Recents docs, but transcribe-over and .hyperaudio-open-over lost the tail the same way, and this predates the format branch — it shipped with 0.9.0's autosave). flushRecentsAutosave() runs any pending save immediately and is called on all three replacement paths: the Recents row click, the hyperaudioInit auto-add handler, and (cross-module, guarded) hyperaudio-save's openFromFile before apply. Regression e2e: edit, switch within the debounce, verify the entry carries the edit and switching back shows it.
"Discard and switch" and "Replace project" — the two dialogs where unsaved work dies — get a danger variant: the confirm goes btn-error red (matching the Recents armed-delete convention) and default focus moves to Cancel, so Enter through a half-read dialog cannot destroy work. Neutral choices (save-with-link, reconciliation, large-media) stay primary. Spec asserts both the styling and the focus target.
tailwind-min.css predates any btn-error in markup, so the purge kept only the hover/active fills and resting text color; the resting background rule was gone, leaving the destructive confirm looking like a default button (caught in manual review — the earlier existence grep matched a fragment and I didn't check which rule survived). Explicit resting-state rule from the theme vars, scoped to the dialog; css cache-buster to 0.10.0.
Advising "you can save it first from FILE → Save Project" while making the user cancel, find the menu, save, and redo the action was the friction the button solves — the classic Save / Don't Save / Cancel triad, matching the native app's convention. The save button is the safe-and-constructive default (purple, holds focus — Enter saves and continues, and can never destroy work); the destructive confirm stays red; Cancel stays ghost. saveToFile now reports success (false on every abandoned path: link fallback declined, large-media cancelled, no media) and the continuation aborts unless the save actually completed — "Save and open" must never fall through to a replace after an abandoned save. Spec: focus/label assertions updated; new e2e drives Save-and-switch end to end (download observed, then the switch completes).
The unsaved-changes triads drop the explicit Cancel: the modal gains the app's standard top-right ✕ (matching the export/transcribe/captions modals) and ✕/Escape act as cancel, leaving the action row as the real choice — safe path (Save and open / Save and switch, purple, default focus) vs destructive path (Discard and open / Discard and switch, red). Neutral dialogs keep their named negative buttons; dismissing an OK-alert acknowledges it. Copy per review: "The current project has changes not yet saved as a .hyperaudio file. Opening a new project / Switching to a saved transcript will DISCARD them." — "not yet saved" over "never downloaded" now that the dialog contains the Save button, and DISCARD symmetry across both flows (the open confirm becomes "Discard and open" to match its own sentence).
…t padding) — the headingless layout let the first line crowd the close button
…ile snapshot (#448) - Edit tracking moves to document-level delegation: the caption-mode round trip REPLACES #hypertranscript, so the direct listeners died after one switch and the OPFS autosave silently stopped — the port study's lead finding, now regression-tested (edit after a caption round trip must reach the snapshot). Delegation covers the transcript, caption editor, summary/topics, the future title field, the strike-through toolbar (mutates styles without input), and end-of-edit via focusout. - Lifecycle guards: editGeneration bumps on every edit signal; identityGeneration bumps at every document commit (open, restore, new transcription, session end). Saves refuse to overlap (saveInFlight); a save marks the session clean ONLY if identity and edit generation are unchanged since gather — a download during which edits landed stays dirty. Autosaves serialize with a single follow-up write, and never adopt a completion for a superseded document. - The crash snapshot collapses to ONE file (snapshot.json: project json + transcript html + captions-or-null), making torn multi-file states impossible without staging/revision machinery; boot restore reads it with a fallback for pre-#448 work dirs, clears the boot hint on validation failure instead of retrying forever, and now carries the envelope so a save after restore preserves unknown fields (§8.1 held across the crash path too).
…ct row, menu simplification (#449) - The Save button adopts the native app's exact treatment (studied from the GliderMac injection it will retire): btn-primary leading the lifecycle cluster — Save · Export · NEW all primary, outline stays with the editing tools — inserted before the export button, with the primary-content dot ringed in primary while unsaved changes exist, tooltip "Save project (⌘S)". - The FILE menu sheds its Project section: the button covers saving, so no Save item; opening joins the Export/Import submenu as "Import Project (.hyperaudio)". The dialog copy's FILE→Save Project reference survives as the button. - The title field leaves the menu; the CURRENT PROJECT surfaces as a row at the top of the Recents panel instead — active styling, its own container so legacy re-renders can't wipe it, renamable in place (pencil → inline input; the rename edits the title Save uses and marks the doc dirty). The Recents row styles extend to it via :is(#file-picker, #project-current). The switch-guard ignores clicks on this row (it's the current doc, not a switch), and gather()'s #project-title preference stays as the hook for any future field. - beforeunload quit guard and ⌘/Ctrl-S unchanged from the first cut; the quit-guard e2e asserts the arming logic via a cancelable synthetic event (defaultPrevented is what the browser reads; headless prompt plumbing was unreliable) — armed when dirty, disarmed after save. - Bridge hook (window.hyperaudioProjectBridge.save) unchanged; e2e-covered including clean-marking through a bridge save.
…e it a phantom Recents entry The legacy picker queries, binds handlers, and the e2e asserts by .file-item; the project row wearing that class (and .active) collided with all three — caught by the auto-add spec asserting the first row's name. project-current-item now carries the same truncation and active styling via extended selectors, without the shared identity.
…dy shows it Per design review: a fresh transcription's auto-added Recents entry carries the active highlight, which is indication enough; the separate Project row duplicated it. Removed with its rename affordance, CSS scoping reverted, spec trimmed (Ctrl-S coverage kept, row-rename coverage gone with the row). The project title still round-trips via texts.title/session; renaming returns with the #451 panel redesign.
…riginal media and redactions as data; share rendered exports, not project files (flattened-export feature tracked in #455)
… without it (#450) The origin-global work/ slot gets one Web Lock. The first tab owns it and boots normally (restore, autosave, hint). A later tab keeps FULL editing — read-only would be hostile — but loses the slot: no boot restore (which would duplicate the owner's doc), no snapshot/media/ origin/app-state writes, no hint mutations (a guarded tab must not clear the owner's boot hint), and isDirty answers from its own sessionEdited instead of the owner's timestamps. A quiet banner says so; beforeunload still protects the guarded tab's unsaved work, and Save works (in-memory origin fallback — the no-OPFS path already covered it). The lock's queue gives promotion for free: when the owner closes or crashes (locks release with the tab), the waiting tab becomes the owner — banner drops, captures enable from that point; deliberately no boot-restore mid-session. Browsers without Web Locks (pre-15.4 Safari) assume single-tab ownership, the pre-#450 status quo. E2e drives two real tabs: banner in the second, its transcription leaving the owner's snapshot byte-identical, and promotion on owner close.
Deleted, not deprecated: js/hyperaudio-lite-editor-storage.js (stable-ID entries, migration, Recents autosave, star/rename/duplicate/delete, Restore), the Recents card markup, the storage/legacy specs (23 e2e + 13 unit), and ~6.7KB of dead CSS. The .hyperaudio model is the only project persistence: Save button / Import Project, OPFS working copy for crash recovery. With Recents gone, the whole coexistence seam goes too: the legacy-switch guard, the Save-and-switch dialog, the session-end listener and the autosave flush — none have anything left to guard. The danger-triad coverage moved to the open flow (Save-and-open e2e). Relocated before deletion: resetCaptionTrack into hyperaudio-save.js, exposed globally for editor-core's typeof-guarded caption-regenerate path (the track-reset spec caught the IIFE-scoping miss); getTopicsString died with its last caller. media-export names exports from the project title (HyperaudioSave.getProjectTitle) instead of the active Recents row. The tab-guard banner anchors to the new #side-notices slot where the card stood; #recents-pane survives as the mobile drawer shell. User data is NOT deleted: old localStorage/IndexedDB transcripts stay inert on the device, and a one-time dismissible notice — shown only when legacy data exists — points at the v0.9.1 release for retrieval. Kept: transcribe prefs, caption-warning dismissal, export options, the boot hint. No blanket localStorage.clear(). Closes #446 (the module it concerns no longer exists). 55 unit / 59 e2e green.
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #403.
Adds Save project / Open project: the whole working state — original media (byte for byte, never re-encoded), transcript with in-progress redactions, captions, options, and the machine transcription — travels in one portable
.hyperaudiofile (a renamed ZIP). The working copy lives in OPFS with autosave and is restored at boot; the user's filesystem is the project library.Format v1.0 is specified in full (with a worked example) in the first comment of #403;
js/hyperaudio-save.jscarries a ten-line summary in its header.Commits
Tests
__TEST__/unit/hyperaudio-save.test.mjs) on the pure format/container layers — all unit tests pass (22/22).__TEST__/e2e/project-save.spec.mjs): real download, open, manual-captions preservation, OPFS restore across reload.Remote media (format v1.1)
For projects whose media was loaded from a URL, Save first tries to download and embed it (the server's CORS policy decides — #370); when it can't, the project is saved with a declared
media.kind: "link". On open, an unreachable link URL (or an original-kind container missing its media entry) triggers a media-reconciliation offer per spec § 7.3: re-attach a local copy, verified heuristically (size/filename/duration), and the next save is self-contained again.Known v1 limits