utilities_server_access.js

/**
* @module server_access
* @description Mostly mirrors functions found in the {@link Server} class,  but keeps the actual request logic
* out of that class.
*/

/**
 * Authenticated fetch wrapper. Injects the auth token header if present
 * and throws an Error with the server's error message on non-2xx status.
 * @param {string} baseUrl - base URL of the server (e.g. "http://localhost:5000")
 * @param {string} path - request path relative to baseUrl (e.g. "/api/projects")
 * @param {RequestInit} [options] - fetch options (method, headers, body, etc.)
 * @param {string} token - auth token to include in the X-Auth-Token header
 * @returns {Promise<Response>}
 */
/**
 * XHR-based request with upload progress support. Returns parsed JSON.
 * @param {string} url - Full URL to request
 * @param {string} method - HTTP method
 * @param {FormData} body - Request body
 * @param {string|null} token - Auth token
 * @param {function|null} onProgress - Called with fraction (0–1) as upload progresses
 * @param {AbortSignal} [signal] - Optional abort signal.
 * @returns {Promise<object>}
 */
function _xhrRequest(url, method, body, token, onProgress, signal) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url);
        xhr.withCredentials = true;
        if (token) xhr.setRequestHeader('X-Auth-Token', token);
        if (onProgress) {
            xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
        }
        xhr.onload = () => {
            if (xhr.status >= 200 && xhr.status < 300) {
                try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
            } else {
                let body = {};
                try { body = JSON.parse(xhr.responseText); } catch {}
                reject(new Error(body.error || `HTTP ${xhr.status}`));
            }
        };
        xhr.onerror = () => reject(new Error('Network error'));
        xhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'));
        if (signal) {
            signal.addEventListener('abort', () => xhr.abort(), { once: true });
        }
        xhr.send(body);
    });
}

/**
 * Authenticated fetch wrapper. Attaches the auth token header and throws on
 * non-2xx responses, parsing any JSON error body for the message.
 * @param {string} baseUrl - server origin (e.g. "http://localhost:5000")
 * @param {string} path - request path (e.g. "/api/projects")
 * @param {object} [options] - fetch init options (method, body, headers, etc.)
 * @param {string} [token] - optional auth token; added as X-Auth-Token header
 * @returns {Promise<Response>} resolved fetch Response
 */
export async function _fetch(baseUrl, path, options = {}, token) {
    const headers = { ...options.headers };

    if (token) {
        headers['X-Auth-Token'] = token;
    }

    const resp = await fetch(baseUrl + path, {
        credentials: 'include',   // send cookies alongside the token header
        ...options,
        headers,
    });

    if (!resp.ok) {
        // Try to parse a JSON error body; fall back to the HTTP status code
        const body = await resp.json().catch(() => ({}));
        throw new Error(body.error || `HTTP ${resp.status}`);
    }
    return resp;
}

// ----- USER ME ----- //

/**
 * Fetches the current user's profile from the backend.
 * Used to verify backend access after Firebase sign-in.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - Firebase ID token
 * @returns {Promise<object>} the user profile object
 */
export async function _getUserMe(baseUrl, token) {
    const resp = await _fetch(baseUrl, '/api/users/me', { method: 'GET' }, token);
    return await resp.json();
}

/**
* Fetch the list of all server-side projects.
* @param {string} baseUrl - the base url of the server
* @param {string} token - the access token of the server
* @returns {Promise<object[]>} array of project metadata objects
*/
export async function _getProjectList(baseUrl, token) {
    const projectList = await _fetch(baseUrl, '/api/projects', { method: 'GET' }, token);
    return await projectList.json();
}

/**
 * Fetch projects shared with the current user.
 * @param {string} baseUrl - Base URL of the server.
 * @param {string} token - Auth token for the current session.
 * @returns {Promise<object[]>} Array of shared project metadata objects.
 */
export async function _getSharedProjects(baseUrl, token) {
    const resp = await _fetch(baseUrl, '/api/projects/shared', { method: 'GET' }, token);
    return await resp.json();
}

/**
 * Fetch folders shared with the current user.
 * @param {string} baseUrl - Base URL of the server.
 * @param {string} token - Auth token for the current session.
 * @returns {Promise<object[]>} Array of shared folder metadata objects.
 */
export async function _getSharedFolders(baseUrl, token) {
    const resp = await _fetch(baseUrl, '/api/folders/shared', { method: 'GET' }, token);
    return await resp.json();
}

/**
 * Fetch a single project by id.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<object>} the project metadata object
 */
export async function _getProject(baseUrl, token, projectId)    {
    const project = await _fetch(baseUrl, `/api/projects/${projectId}`, { method: 'GET' }, token);
    return await project.json();
}

/**
 * Duplicate a project on the server and return the new project object.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @param {string} [folderPath] - destination folder path for the duplicate
 * @returns {Promise<object>} the newly created duplicate project object
 */
export async function _duplicateProject(baseUrl, token, projectId, folderPath) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/duplicate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ folder_path: folderPath ?? '' }),
    }, token);
    return await response.json();
}

/**
 * Permanently delete a project from the server.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<object>} server response object
 */
export async function _deleteProject(baseUrl, token, projectId) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}`, { method: 'DELETE' }, token);
    return await response.json();
}

/**
 * Fetch storage size info for a project before/after compression.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<object>} { can_compress, before_bytes?, after_bytes?, audio_format?, reason? }
 */
export async function _getCompressInfo(baseUrl, token, projectId) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/compress_info`, {}, token);
    return await response.json();
}

/**
 * Compress a project by deleting the original non-MP3 audio file.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<object>} { ok, audio_format }
 */
export async function _compressProject(baseUrl, token, projectId) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/compress`, { method: 'POST' }, token);
    return await response.json();
}

/**
 * Upload a brand-new project (first push from local).
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {object} metadata - general project metadata and speakers; no waveform details
 * @param {object|null} waveformData - { sampleRate, duration, filename, peaks }
 * @param {File} audioFile - the audio file to upload
 * @param {File|null} transcriptFile - the transcript CSV file, or null if none
 * @param {Object.<string, File>} sampleFiles - speaker sample files keyed by speaker id
 * @returns {Promise<object>} the newly created project object
 */
export async function _createProject(baseUrl, token, metadata,
        {waveformData = null, audioFile = null, transcriptFile = null, sampleFiles = null, onProgress = null, signal = null} = {}) {

    const data = new FormData();
    data.append('metadata', JSON.stringify(metadata));

    // Send as a file Blob so it goes to request.files, bypassing Werkzeug's
    // in-memory form-field size limit (default 500 kB) for large peaks arrays.
    if (waveformData) {
        data.append('waveform', new Blob([JSON.stringify(waveformData)], { type: 'application/json' }), 'waveform.json');
    }

    // append the audio file
    if (audioFile) {
        data.append('audio', audioFile);
    }

    // append the transcript if it exists
    if (transcriptFile) {
        data.append('transcript', transcriptFile);
    }

    if (sampleFiles) {
        // Append each speaker sample with a namespaced key the Flask API can parse
        for (const [sid, f] of Object.entries(sampleFiles || {})) {
            data.append(`samples[${sid}]`, f);
        }
    }

    return await _xhrRequest(baseUrl + '/api/projects', 'POST', data, token, onProgress, signal);
}

/**
 * Push updated data for an existing server project.
 * Audio is immutable once uploaded — only metadata, speakers, and transcript can be updated.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @param {string} id - the project id (included in metadata)
 * @param {object|null} metadata - { name, created, modified } (no speakers)
 * @param {object|null} speakersData - full speakers dict if speakers section is dirty
 * @param {File|null} transcriptFile - the updated transcript file, or null to leave unchanged
 * @param {Object.<string, File>} sampleFiles - updated speaker sample files keyed by speaker id
 * @returns {Promise<object>} the updated project object
 */
// TODO: (issue-11) The audio is currently immutable, but it should not be.  However, there should be some indication of potential transcript mismatch if so
export async function _updateProject(baseUrl, token, projectId, metadata,
        {speakersData = null, audioFile = null, transcriptFile = null, sampleFiles = null, onProgress = null, signal = null} = {}) {
    const data = new FormData();

    // append the metadata
    data.append('metadata', JSON.stringify(metadata));

    // if passed, append the speaker data
    if (speakersData) {
        data.append('speakers', JSON.stringify(speakersData));
    }

    // if passed, append the audio file
    if (audioFile) {
        data.append('audio', audioFile);
    }

    // if passed, append the transcript file
    if (transcriptFile) {
        data.append('transcript', transcriptFile);
    }

    // if passed, append the speaker samples
    if (sampleFiles) {
        for (const [sid, f] of Object.entries(sampleFiles || {})) {
            data.append(`samples[${sid}]`, f);
        }
    }

    return await _xhrRequest(baseUrl + `/api/projects/${projectId}`, 'PUT', data, token, onProgress, signal);
}

/**
 * Fetches the waveform metadata (sampleRate, duration, filename, peaks) for a project.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<object>} waveform metadata object
 */
export async function _getWaveform(baseUrl, token, projectId) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/waveform`, { method: 'GET'}, token);
    return await response.json();
}

/**
 * Fetches the speakers dictionary for a project.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<object>} dictionary of speaker objects keyed by speaker id
 */
export async function _getSpeakers(baseUrl, token, projectId) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/speakers`, { method: 'GET' }, token);
    return await response.json();
}

/**
 * Fetches a speaker's voice sample as an ArrayBuffer.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @param {string} speakerId - the speaker's unique identifier
 * @returns {Promise<ArrayBuffer>} raw audio data for the speaker's voice sample
 */
export async function _getSpeakerSample(baseUrl, token, projectId, speakerId) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/samples/${speakerId}`, { method: 'GET' }, token);
    return await response.arrayBuffer();
}

/**
 * Fetches the transcript CSV text for a project.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<{contentType: string, text: string}>} the content type and raw body text
 */
export async function _getTranscript(baseUrl, token, projectId) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/transcript`, { method: 'GET' }, token);
    const contentType = response.headers.get('Content-Type') || '';
    const text = await response.text();
    return { contentType, text };
}

// ----- FOLDER API ----- //

/**
 * Encode a folder path for use in a URL, preserving slashes between segments.
 * @param {string} folderPath - relative folder path ('' for root)
 * @returns {string} URL-encoded path with slashes preserved.
 */
function _encodeFolderPath(folderPath) {
    return folderPath.split('/').map(s => encodeURIComponent(s)).join('/');
}

/**
 * Lists the immediate contents of a server folder.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} folderPath - relative folder path ('' for root)
 * @returns {Promise<{folders: object[], projects: object[]}>}
 */
export async function _listDirectory(baseUrl, token, folderPath) {
    const qs = folderPath ? `?path=${encodeURIComponent(folderPath)}` : '';
    const response = await _fetch(baseUrl, `/api/folders${qs}`, { method: 'GET' }, token);
    return await response.json();
}

/**
 * Creates a new folder on the server.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} parentPath - path of the parent folder ('' for root)
 * @param {string} name - name of the new folder
 * @returns {Promise<{name: string, path: string}>}
 */
export async function _createFolder(baseUrl, token, parentPath, name) {
    const response = await _fetch(baseUrl, '/api/folders', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ parent_path: parentPath, name }),
    }, token);
    return await response.json();
}

/**
 * Renames a folder on the server.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} folderPath - current path of the folder to rename
 * @param {string} newName - new name for the folder
 * @returns {Promise<{name: string, path: string}>}
 */
export async function _renameFolder(baseUrl, token, folderPath, newName) {
    const response = await _fetch(baseUrl, `/api/folders/${_encodeFolderPath(folderPath)}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: newName }),
    }, token);
    return await response.json();
}

/**
 * Deletes a folder on the server.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} folderPath - path of the folder to delete
 * @param {boolean} merge - if true, move contents to parent before deleting
 * @returns {Promise<{ok: boolean}>}
 */
export async function _deleteFolder(baseUrl, token, folderPath, merge) {
    const qs = merge ? '?merge=true' : '';
    const response = await _fetch(baseUrl, `/api/folders/${_encodeFolderPath(folderPath)}${qs}`, {
        method: 'DELETE',
    }, token);
    return await response.json();
}

/**
 * Moves a project to a different folder on the server.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @param {string} targetFolderPath - destination folder path ('' for root)
 * @returns {Promise<object>} updated project summary
 */
export async function _moveProjectToFolder(baseUrl, token, projectId, targetFolderPath) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/folder`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ folder_path: targetFolderPath }),
    }, token);
    return await response.json();
}

/**
 * Gets the count of projects and subfolders within a folder.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} folderPath - path of the folder to count
 * @returns {Promise<{projects: number, folders: number}>}
 */
export async function _getFolderCount(baseUrl, token, folderPath) {
    const response = await _fetch(baseUrl, `/api/folders/${_encodeFolderPath(folderPath)}/count`, {
        method: 'GET',
    }, token);
    return await response.json();
}

// ----- TRANSCRIPTION API ----- //

/**
 * Checks whether the audio.mp3 for a project is ready for transcription.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<boolean>} true if audio.mp3 exists and is ready
 */
export async function _checkAudioReady(baseUrl, token, projectId) {
    const response = await _fetch(baseUrl, `/api/projects/${projectId}/audio-ready`, { method: 'GET' }, token);
    const data = await response.json();
    return data.ready === true;
}

/**
 * Deletes the transcript for a project.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<void>}
 */
export async function _deleteTranscript(baseUrl, token, projectId) {
    await _fetch(baseUrl, `/api/projects/${projectId}/transcript`, { method: 'DELETE' }, token);
}

/**
 * Deletes the audio file for a project.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<void>}
 */
export async function _deleteAudio(baseUrl, token, projectId) {
    await _fetch(baseUrl, `/api/projects/${projectId}/audio`, { method: 'DELETE' }, token);
}

/**
 * Saves waveform metadata (peaks, duration, sampleRate) back to the server.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @param {{peaks: number[], duration: number, sampleRate: number}} waveformData - Waveform peaks and audio metadata.
 * @returns {Promise<void>}
 */
export async function _saveWaveform(baseUrl, token, projectId, waveformData) {
    const form = new FormData();
    form.append('waveform', new Blob([JSON.stringify(waveformData)], { type: 'application/json' }), 'waveform.json');
    await _fetch(baseUrl, `/api/projects/${projectId}/waveform`, {
        method: 'PUT',
        body: form,
    }, token);
}

/**
 * Fetch the annotations (hyperlinks, etc.) for a project.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @returns {Promise<object>} annotations object with a 'hyperlinks' key
 */
export async function _getAnnotations(baseUrl, token, projectId) {
    const resp = await _fetch(baseUrl, `/api/projects/${projectId}/annotations`, { method: 'GET' }, token);
    return await resp.json();
}

/**
 * Save annotations (hyperlinks, etc.) for a project.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @param {object} data - annotations object with a 'hyperlinks' key
 * @returns {Promise<void>}
 */
export async function _saveAnnotations(baseUrl, token, projectId, data) {
    await _fetch(baseUrl, `/api/projects/${projectId}/annotations`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
    }, token);
}

/**
 * Starts transcription and streams SSE progress events via a callback.
 * Resolves when the stream closes (after a 'result' or 'error' event).
 * Each event object has a 'type' field: 'status', 'progress', 'result', or 'error'.
 * @param {string} baseUrl - base URL of the server
 * @param {string} token - auth token for the current session
 * @param {string} projectId - the project's unique identifier
 * @param {object} options - transcription options
 * @param {string} [options.modelSize] - whisper model size (default 'medium')
 * @param {number|null} [options.speakerCount] - number of speakers, or null for auto-detect
 * @param {function} onEvent - called with each parsed SSE event object
 * @param {AbortSignal} [signal] - optional abort signal
 * @returns {Promise<void>}
 */
export async function _transcribeProject(baseUrl, token, projectId, options, onEvent, signal) {
    const resp = await _fetch(baseUrl, `/api/projects/${projectId}/transcribe`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            model_size:           options.modelSize     ?? 'medium',
            pyannote_model:       options.pyannoteModel ?? 'pyannote/speaker-diarization-3.1',
            speaker_count:        options.speakerCount  ?? null,
            include_voice_samples: options.includeVoiceSamples ?? false,
        }),
        signal,
    }, token);

    const reader = resp.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop(); // keep any incomplete trailing line
        for (const line of lines) {
            if (line.startsWith('data: ')) {
                try {
                    onEvent(JSON.parse(line.slice(6)));
                } catch { /* ignore malformed lines */ }
            }
        }
    }
}

/**
 * Retranscribes specific time-range segments and returns updated word-level timestamps.
 * @param {string} baseUrl - Base URL of the server
 * @param {string} token - Auth token
 * @param {string} projectId - The project ID
 * @param {Array<{start: number, end: number, idx: number}>} segments - Segments to retranscribe
 * @param {string} [modelSize] - Whisper model size to use
 * @returns {Promise<{results: Array<{idx: number, words: Array}>}>}
 */
export async function _retranscribeSegments(baseUrl, token, projectId, segments, modelSize = 'medium') {
    const resp = await _fetch(baseUrl, `/api/projects/${projectId}/retranscribe_segments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ segments, model_size: modelSize }),
    }, token);
    return await resp.json();
}