From c0f9caa26244af253a1139ab2d42006f408c2b76 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 21 Jul 2026 23:49:41 -0500 Subject: [PATCH 1/2] Separate project saves from library exports --- CHANGELOG.md | 8 ++ README.md | 12 ++- docs/USER-GUIDE.md | 43 +++++---- routes.py | 92 +++++++++++++----- screen.html | 12 +-- src/create.js | 41 ++++---- src/file-ops.js | 116 ++++++++++++---------- src/main.js | 12 ++- src/menu-bar.js | 2 +- src/replace-audio.js | 2 +- src/stem-tracks.js | 2 +- tests/create_save_routing.test.mjs | 74 +++++++++++--- tests/editor_back_guard.test.mjs | 49 ++++++++++ tests/first_save_picker.test.mjs | 33 ++++--- tests/test_create_save_destination.py | 134 ++++++++++++++++++++++++++ 15 files changed, 481 insertions(+), 151 deletions(-) create mode 100644 tests/editor_back_guard.test.mjs create mode 100644 tests/test_create_save_destination.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 70462d63..9f552423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ 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, later saves reuse it, and no library + file is created until the explicit Export to Library action. 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 0a78ad54..a5913a8e 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. ![The Song Editor: the multitrack Tracks column at left, the waveform and beat grid up top, and a synced Guitar Pro chart laid out on the string lanes.](assets/guide/workspace.png) **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,10 @@ 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** — Save preserves the editable `.feedpak` + project at the location you choose. **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 6234730e..8c8a8acc 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 a3f0f651..df00be3d 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 destination == "session" and 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 31ce4087..29101e62 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

The Create New Arrangement dialog: add files, review every imported track, and select the Guide audio source.
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