diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70462d6..1c04047 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Save is now project persistence; Export to Library is publishing.** Importing
+ files creates only an unsaved editing session. A new project's first Save
+ chooses a `.feedpak` name and location through the native picker, later saves
+ reuse it, and no library file is created until the explicit Export to Library
+ action. In browsers without the native picker, Save downloads a copy but
+ keeps the project marked unsaved because completion cannot be verified.
+ Exporting no longer clears unsaved project changes. The editor's own Back
+ button now uses the Save / Don't Save / Cancel guard; complete shell navigation
+ and desktop app-exit guarding still require a cancellable host navigation/close contract.
+
- **The Legacy (EOF) profile is now a faithful port of EOF's pro-guitar keyset,
pinned to the reference.** F1 opens help, Ctrl+1-9 / Ctrl+0 / Ctrl+` set frets
beside the plain digits, the numpad places and jumps bookmarks (Ctrl+Numpad
diff --git a/README.md b/README.md
index 0a78ad5..f74f65d 100644
--- a/README.md
+++ b/README.md
@@ -3,15 +3,15 @@
The **Song Editor** plugin for [feedBack](https://github.com/got-feedback/feedback)
(plugin id `editor`) — a DAW-style timeline editor that turns a recording into
a playable feedBack chart: import a tab or start from audio, line the grid up
-to the take, author every note, and build a `.feedpak` the whole ecosystem
-plays.
+to the take, author every note, save the editable `.feedpak` project, and
+export it to the targets where it will be played.

**New here?** The **[User Guide](docs/USER-GUIDE.md)** (also in-app under
Help ▸ User Guide) walks the whole journey — create a project from a
recording, a chart, or both; fit the tempo map; chart the notes; add tracks,
-stems, and drums; mix; and build the finished feedpak.
+stems, and drums; mix; save the project; and export it to the library.
It is the largest plugin in the ecosystem. If you are here to work on it,
read this top-to-bottom once — the architecture section saves real time.
@@ -56,8 +56,12 @@ read this top-to-bottom once — the architecture section saves real time.
(candidate positions from the suggest-position resolver, click to assign),
a drum kit / pad strip (GM-mapped, MIDI-monitor capable), and advisory
playability/limb lints that name physical questions without ever blocking.
-- **Build** — assembles the finished `.feedpak` through the host's core
- libraries, so output packages are compatible everywhere.
+- **Save and export are separate** — with the native file picker, Save preserves
+ the editable `.feedpak` project at the location you choose and reuses that
+ destination. Browser fallback downloads a copy but keeps the project marked
+ unsaved because download completion cannot be verified. **Export to Library**
+ assembles and publishes a playable library copy through the host's core
+ libraries; export never pretends that unsaved project edits were saved.
## Layout
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 6234730..8c8a8ac 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -1,8 +1,8 @@
# Song Editor — User Guide
The **Song Editor** turns a recording into a playable feedBack chart. You line a
-grid up to the music, place the notes, mark the tempo and sections, and build a
-`.feedpak` the rest of the app can practice against.
+grid up to the music, place the notes, mark the tempo and sections, save the
+editable `.feedpak` project, and export it where the rest of the app can use it.
This guide is the same content you get in-app from **Help ▸ User Guide**. It
covers the FeedBack shortcut profile (the default). Three other profiles remap
@@ -23,7 +23,7 @@ six steps, and this guide follows them in order:
3. **Chart the notes** (§4) — place, edit, and mark up every note and technique.
4. **Build out the tracks** (§5, §7) — more parts, keys with hands, drums, studio stems.
5. **Hear it** (§3) — guide claps, the metronome, loops, and the mixer console.
-6. **Save and build** (§9) — `Ctrl+S` while you work; **Build** writes the `.feedpak`.
+6. **Save and export** (§9) — `Ctrl+S` preserves the project; export publishes it.
---
@@ -81,8 +81,9 @@ A few sources arrive through their own doors, any time after creation:
- A **GoPlayAlong** sidecar brings bar→time sync points only (no notes) —
useful for lining a grid up fast.
-> **Autosave is not a thing.** Nothing touches your library until you **Build**
-> (§9). Save early with `Ctrl+S`.
+> **Autosave is not a thing.** Importing creates an unsaved project, not a
+> library item. Save early with `Ctrl+S`; nothing touches your library until
+> you choose **Export to Library** (§9).
---
@@ -409,18 +410,21 @@ These live in the **Add** menu (Markers) and show in the inspector when selected
---
-## 9. Save and build — the finish line
-
-- **`Ctrl+S`** saves your working session. For an exportable packed song, the
- first save can ask where to keep an external copy when your browser supports
- a file picker; otherwise it saves straight to the library. Later saves reuse
- the chosen location.
-- **Build feedpak** (the toolbar button, or File ▸ Build feedpak) assembles
- the finished **`.feedpak`** through the host's core libraries and writes it
- to your library — this is the only step that changes what the rest of the
- app sees. The pack carries everything you authored: every arrangement,
- the tempo map, sections and phrases, techniques, keys notation with its
- hand split, stems, tones, and art.
+## 9. Save and export — the finish line
+
+- **`Ctrl+S`** saves the editable `.feedpak` project. A new project asks for a
+ name and location through the native file picker; later saves reuse that
+ location. Opening an existing project and pressing Save updates it in place.
+ Saving never publishes a library copy and never asks you to review export
+ settings. In browsers without the native picker, Save downloads a copy but
+ keeps the project marked unsaved because download completion cannot be
+ verified; the desktop-native save contract is the durable path.
+- **Export to Library** (the toolbar button, or File ▸ Export to Library)
+ assembles the current project through the host's core libraries and publishes
+ a playable copy to your library. Exporting is separate from saving: it does
+ not clear unsaved project changes. The exported pack carries every arrangement,
+ the tempo map, sections and phrases, techniques, keys notation with its
+ hand split, stems, tones, and art.
**Ready to build?** A quick pre-flight:
@@ -429,8 +433,9 @@ These live in the **Add** menu (Markers) and show in the inspector when selected
3. The lint flags you care about are addressed (they *advise*, never block).
4. Song details are filled (title, artist, art) — they're the library card.
-Build again any time — rebuilding replaces the pack, and your working session
-stays editable.
+Export again any time to refresh the library copy. For quick edits that do not
+need a new export, Save and close—the project remains editable and the library
+copy simply stays at its previous version.
**Undo/redo is the safety net.** Every edit — including tempo moves, offset, and
imports — is undoable (`Ctrl+Z` / `Ctrl+Y`). If something looks wrong, undo it.
diff --git a/routes.py b/routes.py
index a3f0f65..9139ac1 100644
--- a/routes.py
+++ b/routes.py
@@ -4086,6 +4086,17 @@ async def export_editor_session(session_id: str):
session = sessions.get(session_id)
if not session:
return JSONResponse({"error": "No active session"}, 404)
+ saved_path = session.get("export_path")
+ if saved_path:
+ candidate = Path(saved_path).resolve()
+ if not candidate.is_file():
+ return JSONResponse(
+ {"error": "Saved feedpak is not a packed file"}, 409)
+ return FileResponse(
+ candidate,
+ media_type="application/zip",
+ filename=str(session.get("export_filename") or candidate.name),
+ )
filename = str(session.get("filename") or "")
dlc_dir = get_dlc_dir()
if not filename or not dlc_dir:
@@ -8652,10 +8663,9 @@ async def add_arrangement(data: dict):
async def build_song_endpoint(data: dict):
"""Build a song from the current create-mode session.
- Always writes a native `.sloppak`. (the native `.archive`/custom-song export
- has been removed — the sloppak container carries every arrangement,
- including extended-range 7/8-string guitar and 5/6-string bass that
- the old note-chart binary format couldn't.)
+ ``destination: "session"`` writes a temporary `.feedpak` for Save or
+ Save As. Omitting it (or sending ``"library"``) preserves the
+ historical Export-to-Library behavior for older clients.
"""
session_id = data.get("session_id", "")
session = sessions.get(session_id)
@@ -8663,6 +8673,11 @@ async def build_song_endpoint(data: dict):
return JSONResponse({"error": "No active create session"}, 400)
session["last_touched"] = time.time()
+ destination = data.get("destination", "library")
+ if destination not in ("library", "session"):
+ return JSONResponse(
+ {"error": "destination must be 'library' or 'session'"}, 400)
+
arrangements_data = data.get("arrangements", [])
beats = data.get("beats", [])
sections = data.get("sections", [])
@@ -8753,15 +8768,25 @@ def _build_sloppak():
raise RuntimeError(f"Audio track '{track['name']}' is unavailable")
extra_audio_tracks.append({**track, "path": str(track_path)})
- dlc_dir = get_dlc_dir()
- if not dlc_dir:
- raise RuntimeError("DLC folder not configured")
-
title = meta.get("title", "Untitled")
artist = meta.get("artistName") or meta.get("artist", "Unknown")
safe_t = re.sub(r'[<>:"/\\|?*]', '_', title)
safe_a = re.sub(r'[<>:"/\\|?*]', '_', artist)
- output = dlc_dir / f"{safe_t}_{safe_a}.feedpak"
+ suggested_name = (
+ f"{safe_t}_{safe_a}.feedpak" if safe_a
+ else f"{safe_t}.feedpak"
+ )
+ if destination == "session":
+ # One immutable package per save generation: /session/export
+ # may still be streaming the previous generation while a fast
+ # second Ctrl+S is assembled.
+ output = (Path(session["dir"])
+ / f"project-save-{time.time_ns()}.feedpak")
+ else:
+ dlc_dir = get_dlc_dir()
+ if not dlc_dir:
+ raise RuntimeError("DLC folder not configured")
+ output = dlc_dir / suggested_name
return _write_sloppak_pak(
audio_file=audio_file,
art_path=_safe_storage_asset(art_path),
@@ -8793,24 +8818,45 @@ def _build_sloppak():
traceback.print_exc()
return JSONResponse({"error": str(e)}, 500)
- # Record the built pack on the session so /session/export (the Save As
- # external-copy source) can serve it. Builds land at the DLC root, so
- # the bare name is the library-relative filename export resolves.
- # Nothing else keys off `filename` for a create-mode session: /save is
- # gated off above it, and save_as_sloppak only accepts archive
- # sessions.
- session["filename"] = Path(output_path).name
+ title = meta.get("title", "Untitled")
+ artist = meta.get("artistName") or meta.get("artist", "Unknown")
+ safe_t = re.sub(r'[<>:"/\\|?*]', '_', title)
+ safe_a = re.sub(r'[<>:"/\\|?*]', '_', artist)
+ suggested_name = (
+ f"{safe_t}_{safe_a}.feedpak" if safe_a
+ else f"{safe_t}.feedpak"
+ )
+ previous_export = session.get("export_path")
+ if destination == "session":
+ session["export_path"] = str(output_path)
+ session["export_filename"] = suggested_name
+ else:
+ session.pop("export_path", None)
+ session.pop("export_filename", None)
+ if previous_export:
+ previous = Path(previous_export)
+ if previous != Path(output_path):
+ try:
+ previous.unlink(missing_ok=True)
+ except OSError:
+ # A response may still hold the old file open (Windows).
+ # Session teardown removes the containing temp directory.
+ pass
+ if destination == "library":
+ session["filename"] = Path(output_path).name
- return {
+ response = {
"success": True,
- "path": output_path,
"format": "sloppak",
- # Library-relative filename (built paks land at the DLC root),
- # mirroring create_sloppak's response: the frontend keys per-song
- # editor prefs (e.g. C1 surface memory) by the same filename a
- # later load/loadCDLC will use.
- "filename": Path(output_path).name,
+ "destination": destination,
+ "filename": (Path(output_path).name
+ if destination == "library" else suggested_name),
}
+ # Preserve the legacy library-build response without leaking the
+ # private session temp path used by Save.
+ if destination == "library":
+ response["path"] = output_path
+ return response
# ── Helpers ──────────────────────────────────────────────────────────
diff --git a/screen.html b/screen.html
index 31ce408..29101e6 100644
--- a/screen.html
+++ b/screen.html
@@ -6,7 +6,7 @@
-
@@ -1087,7 +1087,7 @@
Turn a recording into a playable chart: line a grid up to the
- music, place the notes, mark the tempo and sections, and build a .feedpak.
+ music, place the notes, mark the tempo and sections, save the editable .feedpak, and export it when ready.
Keys below are the default FeedBack profile — press
? for the full, profile-aware shortcut list.
@@ -1104,7 +1104,7 @@
1 · Start a project
New… uses one track table for audio, Guitar Pro, MIDI, and XML. Pick one audio row as the Guide for tempo mapping.
-
Nothing touches your library until you Build (§9). Save early with Ctrl+S.
+
Nothing touches your library until you Export to Library (§9). Save early with Ctrl+S.
2 · The workspace
@@ -1167,8 +1167,8 @@
7 · Drums & 8 · Structure
Adding drums: import a Guitar Pro or MIDI drum track, then edit it on the piece-lane grid.
-
9 · Save & build
-
Ctrl+S saves your session. Build assembles the finished .feedpak into your library — the only step that changes what the rest of the app sees. Build once the grid fits, the notes are placed, and guide-claps-against-the-recording sounds right.
+
9 · Save & export
+
Ctrl+S saves the editable .feedpak project at the location you choose. Export to Library publishes the current project to the library without replacing Save. Export once the grid fits, the notes are placed, and guide-claps-against-the-recording sounds right.
Undo/redo is the safety net — every edit, including tempo moves, offset, and imports, is undoable (Ctrl+Z / Ctrl+Y). If something looks wrong, undo it.
More help: ? shortcut panel · Ctrl+K command palette. Full written copy: docs/USER-GUIDE.md in the plugin repo.
diff --git a/src/create.js b/src/create.js
index 7cb3265..9a367f2 100644
--- a/src/create.js
+++ b/src/create.js
@@ -30,7 +30,7 @@ import { host } from './host.js';
import { isKeysMode, updatePianoRange } from './keys.js';
import { arrKind } from './instrument.js';
import { _seedExtendedStringsFromTuning } from './lanes.js';
-import { S, markSessionDirty, markSessionSaved } from './state.js';
+import { S, markSessionDirty } from './state.js';
import { _marksSanitizePure } from './tempo-marks.js';
import { disposeBackendSession, stopSessionProcesses } from './session-lifecycle.js';
import { _ensureOnsetsShifted, _guideAnalysisReset, resetStemAudioCache, syncStemAudio } from './audio.js';
@@ -2472,7 +2472,7 @@ export async function editorApplyCreateResult(data) {
document.getElementById('editor-song-title').textContent =
`${S.artist} — ${S.title} (new)`;
- document.getElementById('editor-save-btn').classList.add('hidden');
+ document.getElementById('editor-save-btn').classList.remove('hidden');
document.getElementById('editor-build-btn').classList.remove('hidden');
document.getElementById('editor-play-btn').disabled = !data.audio_url;
document.getElementById('editor-sync-btn').classList.toggle('hidden', !data.audio_url);
@@ -2502,14 +2502,14 @@ export async function editorApplyCreateResult(data) {
(s, u) => s + Math.max(0, Number(u.count) || 0), 0);
if (_droppedCount > 0 && S.drumTab) {
setStatus(`Imported (${_droppedCount} unmapped percussion — see dialog) `
- + '— edit notes then click Build feedpak');
+ + '— edit notes, Save your project, then Export to Library when ready');
_showDrumImportUnmappedModal(_drumUnmapped);
} else if (_droppedCount > 0) {
setStatus(`Imported — ${_droppedCount} percussion note`
+ `${_droppedCount === 1 ? '' : 's'} outside the drum vocabulary were `
+ 'dropped (the import response did not include a writable drum track)');
} else {
- setStatus('Imported — edit notes then click Build feedpak');
+ setStatus('Imported — edit notes, Save your project, then Export to Library when ready');
}
}
@@ -2575,20 +2575,21 @@ async function _editorDoEofCreate() {
}
}
-// Resolves true only after the pack was durably written to the library —
-// saveCDLC's create-mode leg (Save/Ctrl+S, the close-guard's "Save", the
-// host saveSession hook) relies on this to report save success honestly.
-export async function editorBuild() {
+// Packages the current create session. The default is an explicit library export;
+// Save passes destination:"session" so the backend prepares a temporary package
+// for the user-selected file without publishing it.
+export async function editorBuild(options = {}) {
if (!S.sessionId || !S.createMode) return false;
+ const destination = options.destination === 'session' ? 'session' : 'library';
// PR3c: warn before building when authored tone slots have no
// matching gear definition — DLC Builder defaults them to stock
// clean in the output archive. Confirm prompt lets the user
// continue or bail back to the modal to pull definitions in.
- if (!_editorConfirmToneDefinitions()) {
- setStatus('Build cancelled');
+ if (destination === 'library' && !_editorConfirmToneDefinitions()) {
+ setStatus('Export cancelled');
return false;
}
- setStatus('Building custom song...');
+ setStatus(destination === 'library' ? 'Exporting to library...' : 'Preparing feedpak...');
// Reconstruct chords for ALL arrangements before sending. Each
// arrangement must be flattened first: reconstructChords() rebuilds
@@ -2688,6 +2689,7 @@ export async function editorBuild() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: S.sessionId,
+ destination,
arrangements: allArrangements,
beats: S.beats,
sections: S.sections,
@@ -2726,19 +2728,20 @@ export async function editorBuild() {
}),
});
const data = await resp.json();
- if (data.error) { setStatus('Build error: ' + data.error); return false; }
- host.kickLibraryRescan(); // refresh the library grid in the background
+ if (data.error) {
+ setStatus((destination === 'library' ? 'Export' : 'Save') + ' error: ' + data.error);
+ return false;
+ }
+ if (destination === 'library') host.kickLibraryRescan();
// C1: the surface shaped while arranging becomes the built file's
// memory, so re-opening it from the library lands on the same tools.
// (The create session itself has no filename — this is the handoff.)
- if (data.filename) surfacePersistFor(data.filename);
- // A build IS this session's durable save — clear the dirty flag so
- // the unsaved-changes guard doesn't re-prompt over persisted work.
- markSessionSaved();
- setStatus('Built - added to library!');
+ if (destination === 'library' && data.filename) surfacePersistFor(data.filename);
+ // Export never clears project dirty state; only Save's file write does.
+ setStatus(destination === 'library' ? 'Exported to library' : 'Feedpak ready to save');
return true;
} catch (e) {
- setStatus('Build failed: ' + e.message);
+ setStatus((destination === 'library' ? 'Export' : 'Save') + ' failed: ' + e.message);
return false;
} finally {
// Re-flatten current arrangement for continued editing
diff --git a/src/file-ops.js b/src/file-ops.js
index f457623..c6f2158 100644
--- a/src/file-ops.js
+++ b/src/file-ops.js
@@ -41,12 +41,22 @@ import { host } from './host.js';
// would otherwise clear a flag the second still needs held.
export let _editorLoadsInFlight = 0;
let externalSaveHandle = null;
+let externalSaveSessionId = null;
let packLoadController = null;
let packLoadGeneration = 0;
-async function _exportBlob() {
+export function _externalSaveHandleIsCurrentPure(handleSessionId, sessionId) {
+ return !!handleSessionId && handleSessionId === sessionId;
+}
+
+function _currentExternalSaveHandle() {
+ return _externalSaveHandleIsCurrentPure(externalSaveSessionId, S.sessionId)
+ ? externalSaveHandle : null;
+}
+
+async function _exportBlob(sessionId = S.sessionId) {
const resp = await fetch('/api/plugins/editor/session/export?session_id='
- + encodeURIComponent(S.sessionId || ''));
+ + encodeURIComponent(sessionId || ''));
if (!resp.ok) {
let detail = '';
try { detail = (await resp.json()).error || ''; } catch (_) {}
@@ -55,8 +65,8 @@ async function _exportBlob() {
return resp.blob();
}
-async function _writeExternalCopy(handle) {
- const blob = await _exportBlob();
+async function _writeExternalCopy(handle, sessionId = S.sessionId) {
+ const blob = await _exportBlob(sessionId);
if (handle) {
const writable = await handle.createWritable();
try {
@@ -77,9 +87,12 @@ async function _writeExternalCopy(handle) {
}
async function _mirrorExternalCopy() {
- if (!externalSaveHandle) return true;
+ const sessionId = S.sessionId;
+ const handle = _currentExternalSaveHandle();
+ if (!handle) return true;
try {
- await _writeExternalCopy(externalSaveHandle);
+ await _writeExternalCopy(handle, sessionId);
+ if (S.sessionId !== sessionId) return false;
return true;
} catch (e) {
markSessionDirty();
@@ -154,6 +167,7 @@ export async function loadCDLC(filename, options = {}) {
await disposeBackendSession(oldSessionId);
}
externalSaveHandle = null;
+ externalSaveSessionId = null;
// The outgoing decoded buffer is not part of the new job. In
// particular, an audio-less feedpak must not inherit AUDIO mode or
// accidentally make the old recording playable again.
@@ -772,19 +786,19 @@ export function _buildSaveBody(forceFullSnapshot) {
export async function saveCDLC(options = {}) {
if (!S.sessionId) return false;
- // A create-mode session has no library file to save over — /save rejects
- // it outright. Persisting one IS building: route every save (Ctrl+S, the
- // close-guard's "Save", the host saveSession hook) through editorBuild,
- // which writes "_.feedpak" into the library — the same
- // deterministic filename every time, so repeat saves overwrite in place.
- // Without this leg an hour of create-mode charting dead-ends in
- // "Save error: Only sloppak-format sessions can be saved" (or its
- // drum_tab variant) with no way to persist. editorBuild owns
- // markSessionSaved() and the status line; the external mirror rides
- // after it exactly as it does after a library save.
+ // A new document has no library file to overwrite. Package it inside the
+ // backend session, then write it only to the location selected by Save.
if (S.createMode) {
- if (!(await editorBuild())) return false;
- if (!options.skipExternal && !(await _mirrorExternalCopy())) return false;
+ if (!(await editorBuild({ destination: 'session' }))) return false;
+ // Save As writes its newly-picked handle after this preparation step.
+ if (options.skipExternal) return true;
+ if (!_currentExternalSaveHandle()) {
+ setStatus('Choose a location with Save or Save As first');
+ return false;
+ }
+ if (!(await _mirrorExternalCopy())) return false;
+ markSessionSaved();
+ setStatus('Saved successfully');
return true;
}
// archive can't carry >6-string guitar / >4-string bass. If the user
@@ -897,6 +911,7 @@ export const editorSaveAsSloppakConfirm = async () => {
export async function editorSaveAs() {
if (!S.sessionId) return false;
+ const sessionId = S.sessionId;
let handle = null;
if (typeof window.showSaveFilePicker === 'function') {
try {
@@ -913,21 +928,37 @@ export async function editorSaveAs() {
return false;
}
}
+ if (S.sessionId !== sessionId) {
+ await _removeEmptyPickedFile(handle);
+ return false;
+ }
- // A create-mode session rides the saveCDLC leg: its create-mode branch
- // builds the pack into the library (and /build records the filename on
- // the backend session, which is what lets /session/export serve the
- // external copy below).
- const saved = S.format === 'archive'
- ? await editorSaveAsSloppakConfirm()
- : await saveCDLC({ skipExternal: true });
+ // Create mode prepares a session-scoped package without publishing it.
+ const saved = S.createMode
+ ? await saveCDLC({ skipExternal: true })
+ : (S.format === 'archive'
+ ? await editorSaveAsSloppakConfirm()
+ : await saveCDLC({ skipExternal: true }));
if (!saved) {
await _removeEmptyPickedFile(handle); // don't strand a 0-byte husk
return false;
}
+ if (S.sessionId !== sessionId) {
+ await _removeEmptyPickedFile(handle);
+ return false;
+ }
try {
- await _writeExternalCopy(handle);
+ await _writeExternalCopy(handle, sessionId);
+ if (S.sessionId !== sessionId) {
+ await _removeEmptyPickedFile(handle);
+ return false;
+ }
+ if (!handle) {
+ setStatus('Downloaded a copy — this browser cannot confirm a durable project location');
+ return false;
+ }
externalSaveHandle = handle;
+ externalSaveSessionId = sessionId;
markSessionSaved();
setStatus(handle ? 'Saved to the selected file' : 'Saved — download started');
return true;
@@ -939,31 +970,14 @@ export async function editorSaveAs() {
}
}
-// The Save command (Ctrl+S / the toolbar Save button / File ▸ Save). The FIRST
-// save of a session pulls up the file explorer — same picker as Save As — so the
-// user chooses where the .feedpak lands; once a location is chosen this session,
-// later saves write straight to it (and mirror to that file). The "chosen this
-// session" signal is externalSaveHandle: it's null on load / a new session and
-// set by editorSaveAs after a picked file. Guard on the picker API so that
-// where it's unavailable (no showSaveFilePicker) we fall back to the plain
-// library save rather than re-triggering a download on every Ctrl+S. Programmatic
-// saves (highway handoff, host saveSession hook, build) keep calling saveCDLC()
-// directly and are unaffected — only the user's Save routes through here.
-// Route the Save command to the picker only when no location has been chosen
-// this session (hasHandle=false) AND the picker API exists (without it,
-// editorSaveAs can only trigger a download, so fall back to the library save)
-// AND the session can actually complete the Save As flow (sessionCanExport).
-// The last gate matters: a directory-form sloppak library-saves fine but
-// /session/export can't serve a packed file for it — routing it through the
-// picker would pop the explorer, have the user pick a destination, then fail
-// (and re-prompt on every subsequent Ctrl+S, with the session falsely
-// re-marked dirty after a library save that succeeded). A create-mode
-// session is also kept off the first-save picker, for a different reason:
-// its Save is a Build whose pack lands in the library under a
-// title/artist-derived name — there is no location to choose. An explicit
-// Save As still works there (build + export a copy to the picked file).
-export function _saveShouldPickPure(hasHandle, hasPickerApi, sessionCanExport) {
- return !hasHandle && !!hasPickerApi && !!sessionCanExport;
+// Save is document persistence, never a library export. An untitled create
+// session must choose its first destination (native picker where available,
+// download fallback elsewhere); later saves reuse that handle. Loaded projects
+// already have a source path and save in place. Bind the handle to sessionId so
+// starting another project can never overwrite the previous project's file.
+export function _saveShouldPickPure(hasHandle, _hasPickerApi, sessionNeedsLocation) {
+ // Without the native API, editorSaveAs falls back to a browser download.
+ return !hasHandle && !!sessionNeedsLocation;
}
export async function editorSave() {
if (!S.sessionId) return false;
@@ -974,7 +988,11 @@ export async function editorSave() {
// CONVERT it to a new .sloppak (editorSaveAsSloppakConfirm), and a plain
// Save must never perform a format conversion the user didn't ask for —
// that stays behind the explicit Save As command.
- const canExport = !S.createMode && S.format === 'sloppak' && S.sloppakForm !== 'dir';
- if (_saveShouldPickPure(!!externalSaveHandle, hasPicker, canExport)) return editorSaveAs();
+ // Loaded documents already have an in-place destination; only an untitled
+ // create session needs the first-save picker.
+ const needsLocation = S.createMode;
+ if (_saveShouldPickPure(!!_currentExternalSaveHandle(), hasPicker, needsLocation)) {
+ return editorSaveAs();
+ }
return saveCDLC();
}
diff --git a/src/main.js b/src/main.js
index 9865783..1e8a409 100644
--- a/src/main.js
+++ b/src/main.js
@@ -517,7 +517,7 @@ setHostHooks({
startPlayback,
stopPlayback,
cancelAudioLoad,
- saveSession: () => saveCDLC(),
+ saveSession: () => editorSave(),
finalizeRecording: () => {
if (_recState === 'recording') editorStopRecordMidi();
},
@@ -821,6 +821,16 @@ window.editorShowNewFormatPicker = async () => {
window.editorStagedRemove = editorStagedRemove;
window.editorYtUrlInput = editorYtUrlInput;
+// The editor-owned Back button is a destructive document transition, just
+// like New/Open. Keep the screen mounted when the user cancels or when Save
+// (including a cancelled first-save picker) does not complete durably.
+async function _editorLeaveToHome() {
+ if (!(await guardSessionTransition('returning to the library'))) return false;
+ if (typeof window.showScreen === 'function') window.showScreen('home');
+ return true;
+}
+window.editorLeaveToHome = _editorLeaveToHome;
+
// The window.* surface the modules can't own: a top-level `window.x =` throws
// when they are imported under node, which their unit tests do.
diff --git a/src/menu-bar.js b/src/menu-bar.js
index 7fe2da4..9344a1f 100644
--- a/src/menu-bar.js
+++ b/src/menu-bar.js
@@ -75,7 +75,7 @@ export const EDITOR_MENUS = Object.freeze([
{ sep: true },
{ label: 'Replace audio…', fn: 'editorShowReplaceAudioModal' },
{ cmd: 'manageStemTracks' },
- { label: 'Build feedpak', fn: 'editorBuild' },
+ { label: 'Export to Library', fn: 'editorBuild' },
] },
{ title: 'Edit', items: [
{ label: 'Undo', fn: 'editorUndo', key: 'Ctrl+Z' },
diff --git a/src/replace-audio.js b/src/replace-audio.js
index 32eaf09..a6333ae 100644
--- a/src/replace-audio.js
+++ b/src/replace-audio.js
@@ -134,7 +134,7 @@ export async function editorApplyReplaceAudio() {
const HINTS = {
none: 'Audio replaced',
save: 'Audio replaced (Save to persist to .sloppak)',
- build: 'Audio replaced (will persist on next Build feedpak)',
+ build: 'Audio replaced (Save the project; export again to update the library)',
rebuild: "Audio replaced (playback only — archive won't be repacked)",
};
editorHideReplaceAudioModal();
diff --git a/src/stem-tracks.js b/src/stem-tracks.js
index 4d0bf3b..1665259 100644
--- a/src/stem-tracks.js
+++ b/src/stem-tracks.js
@@ -233,7 +233,7 @@ function _onImportPicked(e) {
setStatus(`Imported ${n} track${n === 1 ? '' : 's'}`
+ (skipped ? ` (${skipped} skipped — not audio)` : '')
+ (data.next_step === 'save' ? ' — Save writes them into the pack.'
- : data.next_step === 'build' ? ' — Build Song packs them in.' : '.'));
+ : data.next_step === 'build' ? ' — Save the project, then export again when ready.' : '.'));
} catch (err) {
setStatus('Import failed: ' + err.message);
}
diff --git a/tests/create_save_routing.test.mjs b/tests/create_save_routing.test.mjs
index 8533bd4..ee40ba3 100644
--- a/tests/create_save_routing.test.mjs
+++ b/tests/create_save_routing.test.mjs
@@ -6,7 +6,8 @@
* saved", or the drum_tab variant when the session carries a drum chart),
* which used to dead-end an hour of from-scratch charting with no persist
* path from Save at all. saveCDLC now routes create-mode sessions through
- * editorBuild (POST /build), which IS their durable save.
+ * editorBuild (POST /build) with a session-only destination, then writes the
+ * resulting package to the user-selected file.
*
* Also covers the Save As husk cleanup (_removeEmptyPickedFile — the picker
* creates the file before the save runs, so a failed save used to strand a
@@ -22,11 +23,12 @@ import assert from 'node:assert';
globalThis.localStorage = globalThis.localStorage || {
getItem: () => null, setItem: () => {}, removeItem: () => {},
};
+globalThis.window = globalThis.window || globalThis;
globalThis.document = globalThis.document || { getElementById: () => null };
-const { saveCDLC, _removeEmptyPickedFile, _suggestedSaveNamePure } =
+const { editorSave, editorSaveAs, saveCDLC, _removeEmptyPickedFile, _suggestedSaveNamePure } =
await import('../src/file-ops.js');
-const { _drumBuildPayloadPure } = await import('../src/create.js');
+const { _drumBuildPayloadPure, editorBuild } = await import('../src/create.js');
const { S } = await import('../src/state.js');
let pass = 0, fail = 0;
@@ -55,21 +57,102 @@ function seedCreateSession() {
});
}
-await t('create-mode saveCDLC routes to /build (never /save) and reports durable success', async () => {
+await t('create-mode save prepares a session package without publishing or clearing dirty state', async () => {
seedCreateSession();
const calls = [];
globalThis.fetch = async (url, opts) => {
calls.push({ url, body: JSON.parse(opts.body) });
return { json: async () => ({ success: true, filename: 'White Wedding (Pt. 1)_Billy Idol.feedpak' }) };
};
- const ok = await saveCDLC();
+ const ok = await saveCDLC({ skipExternal: true });
assert.strictEqual(ok, true, 'a successful build resolves the save true');
assert.strictEqual(calls.length, 1);
assert.match(calls[0].url, /\/api\/plugins\/editor\/build$/,
'the create leg must hit /build — /save rejects create sessions');
assert.strictEqual(calls[0].body.session_id, 'sess-create-1');
- assert.strictEqual(S.sessionDirty, false,
- 'a build IS the durable save — the dirty flag clears so the close guard stays quiet');
+ assert.strictEqual(calls[0].body.destination, 'session',
+ 'Save prepares a session package instead of publishing to the library');
+ assert.strictEqual(S.sessionDirty, true,
+ 'the temporary package is not durable until the picked file is written');
+});
+
+await t('explicit Export to Library stays separate from Save', async () => {
+ seedCreateSession();
+ let body = null;
+ globalThis.fetch = async (url, opts) => {
+ body = JSON.parse(opts.body);
+ return { json: async () => ({ success: true, filename: 'export.feedpak' }) };
+ };
+ assert.strictEqual(await editorBuild(), true);
+ assert.strictEqual(body.destination, 'library');
+ assert.strictEqual(S.sessionDirty, true,
+ 'exporting does not claim unsaved project edits were saved');
+});
+
+await t('first Save picks a durable project file; later Save reuses it', async () => {
+ seedCreateSession();
+ let pickerCalls = 0;
+ const writes = [];
+ const handle = {
+ createWritable: async () => ({
+ write: async (blob) => { writes.push(blob); },
+ close: async () => {},
+ abort: async () => {},
+ }),
+ };
+ window.showSaveFilePicker = async () => { pickerCalls++; return handle; };
+ const buildBodies = [];
+ globalThis.fetch = async (url, opts) => {
+ if (String(url).includes('/session/export')) {
+ return { ok: true, blob: async () => ({ package: buildBodies.length }) };
+ }
+ buildBodies.push(JSON.parse(opts.body));
+ return { json: async () => ({ success: true, filename: 'project.feedpak' }) };
+ };
+
+ assert.strictEqual(await editorSave(), true);
+ assert.strictEqual(pickerCalls, 1);
+ assert.strictEqual(buildBodies[0].destination, 'session');
+ assert.strictEqual(writes.length, 1);
+ assert.strictEqual(S.sessionDirty, false, 'dirty clears only after the picked file closes');
+
+ S.sessionDirty = true;
+ assert.strictEqual(await editorSave(), true);
+ assert.strictEqual(pickerCalls, 1, 'the second Save reuses the chosen handle');
+ assert.strictEqual(writes.length, 2);
+ assert.strictEqual(S.sessionDirty, false);
+ delete window.showSaveFilePicker;
+});
+
+await t('archive-format create Save As stays session-only instead of publishing', async () => {
+ seedCreateSession();
+ S.format = 'archive';
+ const writes = [];
+ const handle = {
+ createWritable: async () => ({
+ write: async blob => { writes.push(blob); },
+ close: async () => {},
+ abort: async () => {},
+ }),
+ };
+ window.showSaveFilePicker = async () => handle;
+ const calls = [];
+ globalThis.fetch = async (url, opts) => {
+ calls.push({ url: String(url), body: opts?.body ? JSON.parse(opts.body) : null });
+ if (String(url).includes('/session/export')) {
+ return { ok: true, blob: async () => ({ package: 'session' }) };
+ }
+ return { json: async () => ({ success: true, filename: 'project.feedpak' }) };
+ };
+
+ assert.strictEqual(await editorSaveAs(), true);
+ const build = calls.find(call => call.url.endsWith('/api/plugins/editor/build'));
+ assert.ok(build, 'create-mode Save As prepares through /build');
+ assert.strictEqual(build.body.destination, 'session');
+ assert.strictEqual(calls.some(call => call.url.includes('/save_as_sloppak')), false,
+ 'archive-format create mode must not publish through the legacy conversion route');
+ assert.strictEqual(writes.length, 1, 'the private package reaches only the picked file');
+ delete window.showSaveFilePicker;
});
await t('create-mode build ships an authored arr.type (a re-typed track is not re-inferred from its name)', async () => {
@@ -84,7 +167,7 @@ await t('create-mode build ships an authored arr.type (a re-typed track is not r
body = JSON.parse(opts.body);
return { json: async () => ({ success: true, filename: 'x.feedpak' }) };
};
- await saveCDLC();
+ await saveCDLC({ skipExternal: true });
assert.ok(Array.isArray(body.arrangements), 'build ships the arrangement snapshot');
assert.strictEqual(body.arrangements[0].type, 'guitar',
'the authored type rides the build payload (else /build re-infers keys from the name)');
@@ -93,7 +176,7 @@ await t('create-mode build ships an authored arr.type (a re-typed track is not r
await t('a failed build reports save failure and keeps the session dirty', async () => {
seedCreateSession();
globalThis.fetch = async () => ({ json: async () => ({ error: 'DLC folder not configured' }) });
- const ok = await saveCDLC();
+ const ok = await saveCDLC({ skipExternal: true });
assert.strictEqual(ok, false, 'build error must not read as a successful save');
assert.strictEqual(S.sessionDirty, true, 'unsaved work stays flagged');
});
@@ -101,7 +184,7 @@ await t('a failed build reports save failure and keeps the session dirty', async
await t('a network-dead build reports save failure too', async () => {
seedCreateSession();
globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
- const ok = await saveCDLC();
+ const ok = await saveCDLC({ skipExternal: true });
assert.strictEqual(ok, false);
assert.strictEqual(S.sessionDirty, true);
});
@@ -224,7 +307,7 @@ await t('create-mode build ships the drum parts on the /build wire (real editorB
body = JSON.parse(opts.body);
return { json: async () => ({ success: true, filename: 'x.feedpak' }) };
};
- const ok = await saveCDLC();
+ const ok = await saveCDLC({ skipExternal: true });
assert.strictEqual(ok, true);
assert.deepStrictEqual(body.drum_tab, kit, 'the PRIMARY tab ships as drum_tab, not the active secondary');
assert.strictEqual(body.drum_tab_id, 'drums', 'the /build wire carries the primary id for round-trip');
diff --git a/tests/editor_back_guard.test.mjs b/tests/editor_back_guard.test.mjs
new file mode 100644
index 0000000..bae8531
--- /dev/null
+++ b/tests/editor_back_guard.test.mjs
@@ -0,0 +1,49 @@
+/* The editor's own Back button must not bypass the unsaved-document guard. */
+import assert from 'node:assert';
+import fs from 'node:fs';
+
+const mainSrc = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');
+const html = fs.readFileSync(new URL('../screen.html', import.meta.url), 'utf8');
+
+function extractAsyncFunction(name) {
+ const start = mainSrc.indexOf('async function ' + name);
+ assert.ok(start >= 0, name + ' must exist');
+ const open = mainSrc.indexOf('{', start);
+ let depth = 0;
+ for (let i = open; i < mainSrc.length; i++) {
+ if (mainSrc[i] === '{') depth++;
+ else if (mainSrc[i] === '}' && --depth === 0) return mainSrc.slice(start, i + 1);
+ }
+ throw new Error('unbalanced function');
+}
+
+function makeLeave(allowed) {
+ const calls = [];
+ const guard = async (label) => { calls.push(['guard', label]); return allowed; };
+ const fakeWindow = { showScreen: (id) => calls.push(['screen', id]) };
+ const body = extractAsyncFunction('_editorLeaveToHome');
+ const fn = new Function('guardSessionTransition', 'window',
+ body + '\nreturn _editorLeaveToHome;')(guard, fakeWindow);
+ return { fn, calls };
+}
+
+assert.match(html, /onclick="editorLeaveToHome\(\)"/, 'Back routes through the editor guard');
+assert.doesNotMatch(html, /onclick="showScreen\('home'\)"/, 'Back never navigates directly');
+
+{
+ const { fn, calls } = makeLeave(false);
+ assert.strictEqual(await fn(), false);
+ assert.deepStrictEqual(calls, [['guard', 'returning to the library']],
+ 'Cancel or failed Save keeps the editor active');
+}
+
+{
+ const { fn, calls } = makeLeave(true);
+ assert.strictEqual(await fn(), true);
+ assert.deepStrictEqual(calls, [
+ ['guard', 'returning to the library'],
+ ['screen', 'home'],
+ ], 'navigation happens only after the transition is approved');
+}
+
+console.log('2 passed, 0 failed');
diff --git a/tests/first_save_picker.test.mjs b/tests/first_save_picker.test.mjs
index ef7fac0..9465bfc 100644
--- a/tests/first_save_picker.test.mjs
+++ b/tests/first_save_picker.test.mjs
@@ -15,7 +15,8 @@ globalThis.localStorage = globalThis.localStorage || {
getItem: () => null, setItem: () => {}, removeItem: () => {},
};
-const { _saveShouldPickPure } = await import('../src/file-ops.js');
+const { _externalSaveHandleIsCurrentPure, _saveShouldPickPure } =
+ await import('../src/file-ops.js');
let pass = 0, fail = 0;
function t(name, fn) {
@@ -23,28 +24,32 @@ function t(name, fn) {
catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); }
}
-t('picks the file explorer only on the first save, and only when the API exists', () => {
- // hasHandle=false ⇒ nothing chosen yet this session (a new song, or one just
- // opened from the library — externalSaveHandle resets to null on load).
- assert.strictEqual(_saveShouldPickPure(false, true, true), true, 'first save + API → picker');
- assert.strictEqual(_saveShouldPickPure(true, true, true), false, 'location chosen → save straight to it');
- assert.strictEqual(_saveShouldPickPure(false, false, true), false, 'no picker API → library save, never a re-download');
+t('picks the file explorer for an untitled project, and only when the API exists', () => {
+ // hasHandle=false ⇒ this untitled project has no destination yet.
+ assert.strictEqual(_saveShouldPickPure(false, true, true), true, 'untitled project + API → picker');
+ assert.strictEqual(_saveShouldPickPure(true, true, true), false, 'chosen project path → save straight to it');
+ assert.strictEqual(_saveShouldPickPure(false, false, true), true,
+ 'no picker API → Save As download fallback, never a library build');
assert.strictEqual(_saveShouldPickPure(true, false, true), false);
});
-t('never routes a session that cannot complete Save As through the picker', () => {
- // sessionCanExport=false covers: create-mode sessions (/save rejects them
- // outright — the picker would pop, the user would pick a destination, then
- // nothing would be written to it) and directory-form sloppaks
- // (/session/export 409s — the library save succeeds but the external write
- // fails, falsely re-marking the session dirty and re-prompting forever).
+t('a chosen file handle belongs only to the session that picked it', () => {
+ assert.strictEqual(_externalSaveHandleIsCurrentPure('session-a', 'session-a'), true);
+ assert.strictEqual(_externalSaveHandleIsCurrentPure('session-a', 'session-b'), false,
+ 'a new project cannot inherit and overwrite the previous project file');
+ assert.strictEqual(_externalSaveHandleIsCurrentPure(null, 'session-b'), false);
+});
+
+t('only an untitled project is routed through first-save destination selection', () => {
assert.strictEqual(_saveShouldPickPure(false, true, false), false, 'non-exportable session → plain library save');
+ assert.strictEqual(_saveShouldPickPure(false, true, true), true,
+ 'a create session with temporary export support gets a first-save picker');
assert.strictEqual(_saveShouldPickPure(true, true, false), false);
});
t('coerces truthiness (defensive against non-boolean inputs)', () => {
assert.strictEqual(_saveShouldPickPure(null, {}, 1), true); // no handle, api present, exportable
- assert.strictEqual(_saveShouldPickPure(undefined, undefined, true), false);
+ assert.strictEqual(_saveShouldPickPure(undefined, undefined, true), true);
assert.strictEqual(_saveShouldPickPure('handle', 'fn', true), false); // truthy handle
assert.strictEqual(_saveShouldPickPure(false, true, undefined), false, 'unknown exportability → no picker');
});
diff --git a/tests/test_create_save_destination.py b/tests/test_create_save_destination.py
new file mode 100644
index 0000000..21c8295
--- /dev/null
+++ b/tests/test_create_save_destination.py
@@ -0,0 +1,152 @@
+"""Create-mode Save stays temporary while Export keeps library behavior."""
+
+from __future__ import annotations
+
+import asyncio
+import importlib
+import json
+import sys
+import types
+from pathlib import Path
+
+import pytest
+
+
+class _FakeApp:
+ def __init__(self):
+ self.routes = {}
+
+ def _register(self, path):
+ def decorator(fn):
+ self.routes[path] = fn
+ return fn
+ return decorator
+
+ def get(self, path, *args, **kwargs):
+ return self._register(path)
+
+ def post(self, path, *args, **kwargs):
+ return self._register(path)
+
+
+@pytest.fixture()
+def build_routes(tmp_path):
+ added = []
+ if "lib" not in sys.modules:
+ lib = types.ModuleType("lib")
+ lib.__path__ = []
+ sys.modules["lib"] = lib
+ added.append("lib")
+ lib = sys.modules["lib"]
+ if "lib.song" not in sys.modules:
+ song = types.ModuleType("lib.song")
+ song.load_song = lambda *args, **kwargs: None
+ song.phrase_to_wire = lambda *args, **kwargs: None
+ sys.modules["lib.song"] = song
+ lib.song = song
+ added.append("lib.song")
+ if "lib.sloppak" not in sys.modules:
+ sloppak = types.ModuleType("lib.sloppak")
+ sys.modules["lib.sloppak"] = sloppak
+ lib.sloppak = sloppak
+ added.append("lib.sloppak")
+
+ sys.modules.pop("routes", None)
+ routes = importlib.import_module("routes")
+ dlc = tmp_path / "dlc"
+ dlc.mkdir()
+ app = _FakeApp()
+ routes.setup(app, {
+ "config_dir": tmp_path,
+ "get_dlc_dir": lambda: dlc,
+ "meta_db": None,
+ "get_sloppak_cache_dir": lambda: str(tmp_path / "cache"),
+ })
+ session_dir = tmp_path / "session"
+ session_dir.mkdir()
+ storage = tmp_path / "editor_cache"
+ audio = storage / "source.ogg"
+ audio.write_bytes(b"OggS" + b"test-audio" * 20)
+ routes._sessions["create"] = {
+ "create_mode": True,
+ "dir": str(session_dir),
+ "metadata": {},
+ }
+ try:
+ yield routes, app, dlc, session_dir
+ finally:
+ sys.modules.pop("routes", None)
+ for name in added:
+ sys.modules.pop(name, None)
+
+
+def _payload(**extra):
+ data = {
+ "session_id": "create",
+ "audio_url": "/api/plugins/editor/cache/source.ogg",
+ "metadata": {"title": "My Song", "artist": "The Band"},
+ "arrangements": [],
+ "beats": [],
+ "sections": [],
+ }
+ data.update(extra)
+ return data
+
+
+def test_session_destination_builds_outside_library_and_is_downloadable(build_routes):
+ routes, app, dlc, session_dir = build_routes
+ result = asyncio.run(app.routes["/api/plugins/editor/build"](
+ _payload(destination="session")))
+
+ assert result["success"] is True
+ assert result["destination"] == "session"
+ assert result["filename"] == "My Song_The Band.feedpak"
+ assert "path" not in result, "private session temp paths stay server-side"
+ assert list(dlc.iterdir()) == []
+ assert "filename" not in routes._sessions["create"]
+
+ response = asyncio.run(app.routes["/api/plugins/editor/session/export"]("create"))
+ assert Path(response.path).parent == session_dir
+ assert Path(response.path).name.startswith("project-save-")
+ assert "My%20Song_The%20Band.feedpak" in response.headers["content-disposition"]
+
+
+def test_omitted_destination_preserves_library_build(build_routes):
+ routes, app, dlc, _session_dir = build_routes
+ result = asyncio.run(app.routes["/api/plugins/editor/build"](_payload()))
+
+ expected = dlc / "My Song_The Band.feedpak"
+ assert result["destination"] == "library"
+ assert Path(result["path"]) == expected
+ assert expected.is_file()
+ assert routes._sessions["create"]["filename"] == expected.name
+
+
+def test_library_build_removes_previous_private_save_generation(build_routes):
+ routes, app, dlc, session_dir = build_routes
+ first = asyncio.run(app.routes["/api/plugins/editor/build"](
+ _payload(destination="session")))
+ assert first["success"] is True
+ previous = Path(routes._sessions["create"]["export_path"])
+ assert previous.parent == session_dir
+ assert previous.is_file()
+
+ second = asyncio.run(app.routes["/api/plugins/editor/build"](
+ _payload(destination="library")))
+ assert second["success"] is True
+ assert list(dlc.iterdir()), "the explicit library export still publishes"
+ assert not previous.exists(), "the superseded private generation is reclaimed"
+ assert "export_path" not in routes._sessions["create"]
+ assert "export_filename" not in routes._sessions["create"]
+
+
+def test_unknown_destination_is_rejected_before_writing(build_routes):
+ _routes, app, dlc, session_dir = build_routes
+ response = asyncio.run(app.routes["/api/plugins/editor/build"](
+ _payload(destination="somewhere")))
+
+ assert response.status_code == 400
+ assert json.loads(bytes(response.body))["error"] == (
+ "destination must be 'library' or 'session'")
+ assert list(dlc.iterdir()) == []
+ assert list(session_dir.iterdir()) == []