Skip to content

Save/open projects as a single .hyperaudio file (#403) - #404

Merged
maboa merged 23 commits into
mainfrom
403-hyperaudio-save
Aug 3, 2026
Merged

Save/open projects as a single .hyperaudio file (#403)#404
maboa merged 23 commits into
mainfrom
403-hyperaudio-save

Conversation

@scarsellifi

@scarsellifi scarsellifi commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

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 .hyperaudio file (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.js carries a ten-line summary in its header.

Commits

  • Preserve struck words in the HTML/JSON round-trip; expose gap-removal settings — standalone bugfix too: JSON export/import silently resurrected every redaction.
  • Add .hyperaudio project save/open — one module in five layers (format and container are pure and node-tested; OPFS working copy; editor bridge; UI in the file menu). JSZip 3.10.1 vendored (MIT branch of its dual license) and precached.

Tests

  • 15 unit tests (__TEST__/unit/hyperaudio-save.test.mjs) on the pure format/container layers — all unit tests pass (22/22).
  • 4 e2e (__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

  • Dirty-work guard on Open only; native confirm/alert dialogs.

… 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.
maboa added 2 commits July 31, 2026 12:21
…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.
@maboa

maboa commented Jul 31, 2026

Copy link
Copy Markdown
Member

Rebased onto current main (v0.9.1) as part of the #403 Phase A push — the branch now carries a merge of main plus repairs:

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.

maboa added 17 commits July 31, 2026 13:20
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Save and open projects as a single portable .hyperaudio file

2 participants