Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3955,6 +3955,16 @@ def _arr_to_data(arr, name):


def setup(app, context):
"""Initialize the editor plugin, register its routes, and manage editing sessions.

Parameters:
app: FastAPI application to which the editor routes are registered.
context (dict): Host-provided configuration and service callbacks, including the
configuration directory and DLC directory resolver.

Returns:
None
"""
config_dir = context["config_dir"]
get_dlc_dir = context["get_dlc_dir"]
# Shared metadata cache (title/artist/…), populated by the core library
Expand Down Expand Up @@ -4083,6 +4093,16 @@ async def close_editor_session(data: dict):

@app.get("/api/plugins/editor/session/export")
async def export_editor_session(session_id: str):
"""
Export the saved feedpak associated with an active editor session.

Parameters:
session_id (str): Identifier of the active editor session.

Returns:
FileResponse: The saved feedpak file.
JSONResponse: An error response when the session or saved feedpak is unavailable or invalid.
"""
session = sessions.get(session_id)
if not session:
return JSONResponse({"error": "No active session"}, 404)
Expand Down Expand Up @@ -8752,10 +8772,19 @@ async def build_song_endpoint(data: dict):
{"error": f"invalid drum_parts[{_pi}].drum_tab: {_p_reason}"}, 400)

def _build_sloppak():
"""Build a `.sloppak` for the create-mode session.

Output filename is derived from title/artist for create-mode
sessions (no existing source filename to preserve).
"""
Builds a Sloppak package for the create-mode session.

The package is staged in the session directory when the destination is
``"session"``; otherwise, it is written to the configured DLC directory
using a title- and artist-derived filename.

Raises:
RuntimeError: If the master audio, an additional audio track, or the DLC
directory required for a library build is unavailable.

Returns:
Path: The path to the created package.
"""
resolved = _resolve_storage_url(audio_url) if audio_url else None
audio_file = str(resolved) if resolved else ""
Expand Down
16 changes: 13 additions & 3 deletions src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -2368,7 +2368,10 @@ async function _editorDoBlankCreate() {

// Apply a create-mode import result (from convert-gp OR import-xml-project) to
// the editor and open it. The two import sources return the same shape
// (_song_to_dict + session_id + create_mode), so this is shared.
/**
* Applies an imported creation result to the editor as a new session.
* @param {Object} data - Backend result containing session metadata, arrangements, timing data, and optional audio, drum, and track-session data.
*/
export async function editorApplyCreateResult(data) {
// Persist the audio URL the backend actually used (e.g. GP8-extracted or the
// project's uploaded mix) so editorBuild() reuses it on later builds.
Expand Down Expand Up @@ -2538,7 +2541,9 @@ export function editorEofFilesSelected(input) {

// EOF import path for editorDoCreate: upload the (optional) audio, POST the
// arrangement XML(s) + audio URL + metadata, then open the result. Album art and
// the preview clip are baked later at Build (editorBuild), from their own inputs.
/**
* Imports EOF arrangement files into a new editor session, optionally including staged audio and metadata.
*/
async function _editorDoEofCreate() {
const status = document.getElementById('editor-create-status');
const btn = document.getElementById('editor-create-go');
Expand Down Expand Up @@ -2577,7 +2582,12 @@ async function _editorDoEofCreate() {

// 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.
/**
* Builds the current create-mode project for library export or session saving.
* @param {Object} [options={}] - Build options.
* @param {string} [options.destination] - Set to `session` to prepare a package for saving; other values export to the library.
* @return {boolean} `true` if the build succeeds, `false` otherwise.
*/
export async function editorBuild(options = {}) {
if (!S.sessionId || !S.createMode) return false;
const destination = options.destination === 'session' ? 'session' : 'library';
Expand Down
55 changes: 54 additions & 1 deletion src/file-ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,31 @@ let externalSaveSessionId = null;
let packLoadController = null;
let packLoadGeneration = 0;

/**
* Determines whether an external save handle belongs to the specified session.
* @param {*} handleSessionId - The session identifier associated with the handle.
* @param {*} sessionId - The current session identifier.
* @return {boolean} `true` if the handle session identifier is present and matches the current session, `false` otherwise.
*/
export function _externalSaveHandleIsCurrentPure(handleSessionId, sessionId) {
return !!handleSessionId && handleSessionId === sessionId;
}

/**
* Gets the external save handle associated with the active session.
* @return {FileSystemFileHandle|null} The current session's external save handle, or `null` when none is associated with the session.
*/
function _currentExternalSaveHandle() {
return _externalSaveHandleIsCurrentPure(externalSaveSessionId, S.sessionId)
? externalSaveHandle : null;
}

/**
* Retrieves the exported feedpak for a session.
* @param {string} [sessionId=S.sessionId] - The session identifier to export.
* @returns {Promise<Blob>} The exported feedpak data.
* @throws {Error} If the export request fails.
*/
async function _exportBlob(sessionId = S.sessionId) {
const resp = await fetch('/api/plugins/editor/session/export?session_id='
+ encodeURIComponent(sessionId || ''));
Expand All @@ -65,6 +81,11 @@ async function _exportBlob(sessionId = S.sessionId) {
return resp.blob();
}

/**
* Writes the current session export to a selected file or starts a download.
* @param {FileSystemFileHandle|null} handle - Destination file handle, or `null` to download the export.
* @param {string} sessionId - Session identifier to export.
*/
async function _writeExternalCopy(handle, sessionId = S.sessionId) {
const blob = await _exportBlob(sessionId);
if (handle) {
Expand All @@ -86,6 +107,10 @@ async function _writeExternalCopy(handle, sessionId = S.sessionId) {
setTimeout(() => URL.revokeObjectURL(url), 0);
}

/**
* Mirrors the current session to its external save destination.
* @return {boolean} `true` if the copy succeeds or no destination is configured, `false` if the session changes or the copy fails.
*/
async function _mirrorExternalCopy() {
const sessionId = S.sessionId;
const handle = _currentExternalSaveHandle();
Expand Down Expand Up @@ -137,6 +162,13 @@ export function _suggestedSaveNamePure(filename, title, artist) {
return `${t || 'Untitled'}_${a || 'Unknown'}.feedpak`;
}

/**
* Loads a feedpak into the editor and resets session-specific state.
* @param {string} filename - The feedpak filename to load.
* @param {Object} [options] - Load options.
* @param {boolean} [options.skipGuard=false] - Whether to skip the session-transition guard.
* @return {Promise<boolean>} `true` when the feedpak is loaded successfully, `false` if loading fails, is canceled, or is superseded.
*/
export async function loadCDLC(filename, options = {}) {
if (!options.skipGuard && !(await guardSessionTransition('opening another feedpak'))) return false;
const oldSessionId = S.sessionId;
Expand Down Expand Up @@ -784,6 +816,12 @@ export function _buildSaveBody(forceFullSnapshot) {
return _stripBeatsFromSaveBody(body);
}

/**
* Saves the current editor session and optionally mirrors it to an external location.
* @param {Object} [options={}] - Save options.
* @param {boolean} [options.skipExternal=false] - Whether to skip writing an external copy.
* @return {boolean} `true` if the session is saved successfully, `false` otherwise.
*/
export async function saveCDLC(options = {}) {
if (!S.sessionId) return false;
// A new document has no library file to overwrite. Package it inside the
Expand Down Expand Up @@ -909,6 +947,11 @@ export const editorSaveAsSloppakConfirm = async () => {
}
};

/**
* Saves the current session to a user-selected file or downloads a copy.
*
* @returns {boolean} `true` if the session is saved successfully to a selected file, `false` otherwise.
*/
export async function editorSaveAs() {
if (!S.sessionId) return false;
const sessionId = S.sessionId;
Expand Down Expand Up @@ -972,11 +1015,21 @@ export async function editorSaveAs() {
// 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.
/**
* Determines whether saving requires selecting an external destination.
* @param {boolean} hasHandle - Whether the current session has an external save handle.
* @param {boolean} _hasPickerApi - Whether the file picker API is available.
* @param {boolean} sessionNeedsLocation - Whether the session requires a save location.
* @returns {boolean} `true` if no external handle exists and the session requires a save location, `false` otherwise.
*/
export function _saveShouldPickPure(hasHandle, _hasPickerApi, sessionNeedsLocation) {
// Without the native API, editorSaveAs falls back to a browser download.
return !hasHandle && !!sessionNeedsLocation;
}
/**
* Saves the current editor session, prompting for an external destination when a new document requires one.
* @returns {boolean} `true` if the session is saved successfully, `false` otherwise.
*/
export async function editorSave() {
if (!S.sessionId) return false;
const hasPicker = typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function';
Expand Down
5 changes: 4 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,10 @@ 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.
/**
* Navigates from the editor to the home screen after confirming the session transition.
* @return {boolean} `true` if navigation is allowed, `false` if the transition is denied.
*/
async function _editorLeaveToHome() {
if (!(await guardSessionTransition('returning to the library'))) return false;
if (typeof window.showScreen === 'function') window.showScreen('home');
Expand Down
4 changes: 4 additions & 0 deletions src/replace-audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ async function _uploadReplaceAudio() {
});
}

/**
* Replaces the session audio with a user-selected file or YouTube URL.
* @returns {Promise<void>}
*/
export async function editorApplyReplaceAudio() {
if (!S.sessionId) return;
const sessionId = S.sessionId;
Expand Down
3 changes: 3 additions & 0 deletions src/stem-tracks.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ function _onPairChange(e) {
})();
}

/**
* Imports selected audio files as stem tracks and updates the session with the resulting stems and pairings.
*/
function _onImportPicked(e) {
const input = e.target;
if (!input || !input.files || !input.files.length || !S.sessionId) return;
Expand Down
27 changes: 27 additions & 0 deletions tests/test_create_save_destination.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ def __init__(self):
self.routes = {}

def _register(self, path):
"""
Create a decorator that registers a route handler for the specified path.

Parameters:
path: The route path associated with the handler.

Returns:
A decorator that stores a handler in the app's route registry and returns it unchanged.
"""
def decorator(fn):
self.routes[path] = fn
return fn
Expand All @@ -31,6 +40,15 @@ def post(self, path, *args, **kwargs):

@pytest.fixture()
def build_routes(tmp_path):
"""
Create an isolated test environment with registered editor routes and a session workspace.

Parameters:
tmp_path (Path): Temporary directory used for test files and configuration.

Yields:
tuple: The imported routes module, fake application, DLC directory, and session directory.
"""
added = []
if "lib" not in sys.modules:
lib = types.ModuleType("lib")
Expand Down Expand Up @@ -81,6 +99,15 @@ def build_routes(tmp_path):


def _payload(**extra):
"""
Build the default JSON payload used by editor build route tests.

Parameters:
extra (dict): Additional fields that override or extend the default payload.

Returns:
dict: The resulting request payload.
"""
data = {
"session_id": "create",
"audio_url": "/api/plugins/editor/cache/source.ogg",
Expand Down