/** * Plugin providing a link to server-side methods */ /** * @typedef {object} ErrorResponse * @property {string} error */ /** * @import { ApplicationState } from '../state.js' * @import { PluginContext } from '../modules/plugin-context.js' * @import { AuthenticationData } from './authentication.js' */ import Plugin from '../modules/plugin-base.js'; import { ApiClientV1 } from '../modules/api-client-v1.js'; /** * Parent class for all API errors * @extends {Error} * @property {string} message - The error message * @property {number} statusCode - The HTTP status code associated with the error, defaults to 400 */ class ApiError extends Error { /** * @param {string} message - The error message * @param {number} statusCode - The HTTP status code associated with the error, defaults to 400 */ constructor(message, statusCode = 400) { super(message); this.name = "ApiError"; this.statusCode = statusCode; } } /** * Error indicating that a resource is locked * @extends {ApiError} */ class LockedError extends ApiError { /** * @param {string} message - The error message * @param {number} statusCode - The HTTP status code associated with the error, defaults to 423 */ constructor(message, statusCode = 423) { super(message); this.name = "LockedError"; this.statusCode = statusCode; } } /** * Error indicating server-side connection issues, such as timeouts or unreachable endpoints. * @extends {ApiError} */ class ConnectionError extends ApiError { /** * @param {string} message - The error message * @param {number} statusCode - The HTTP status code associated with the error, defaults to 504 */ constructor(message, statusCode = 504) { super(message); this.name = "ConnectError"; this.statusCode = statusCode; } } /** * Error indicating an unexpected server-side error. * @extends {Error} * @property {number} statusCode - The HTTP status code associated with the error, defaults to 500 * @property {string} name - The name of the error, defaults to "ApiError" * @property {string} message - The error message */ class ServerError extends Error { /** * @param {string} message - The error message * @param {number} statusCode - The HTTP status code associated with the error, defaults to 500 */ constructor(message, statusCode = 500) { super(message); this.name = "ServerError"; this.statusCode = statusCode; } } // Current sessionId stored locally for API requests (updated when state changes) /** @type {string|null} */ let sessionId = null; /** @type {number|null} */ let lastHttpStatus = null; const api_base_url = '/api/v1'; const upload_route = api_base_url + '/files/upload' // Create singleton API client instance using the callApi function const apiClient = new ApiClientV1(callApi); /** * A client for the FastAPI backend, providing 1:1 methods * for each of the API routes via its `getApi()` method, wich returns * methods generated from the OpenAPI data exposed by the FastAPI server. * TODO: remove handwritten methods and update consuming plugin's code to use generated methods */ class ClientPlugin extends Plugin { /** @param {PluginContext} context */ constructor(context) { super(context, { name: 'client', deps: [] }); } /** @param {ApplicationState} initialState */ async install(initialState) { await super.install(initialState); if (initialState.sessionId) { sessionId = initialState.sessionId; this.getDependency('logger').debug(`Initialized session id from initial state: ${sessionId}`); } } /** * Sync sessionId early (step 2 of updateState), before other plugins' onStateUpdate * handlers fire API requests that require a valid session header. * @param {ApplicationState} state */ updateInternalState(state) { if (state.sessionId !== sessionId) { sessionId = state.sessionId; this.getDependency('logger').debug(`Setting session id to ${sessionId}`); } } /** * Returns the module-level api object so getDependency('client') returns it * rather than the Plugin instance. */ getApi() { return api; } } export default ClientPlugin; /** @deprecated Use ClientPlugin class directly */ export const plugin = ClientPlugin; /** * A generic function to make API requests against the application backend. * Expects a JSON response which is an arbitrary value in case of success or a * `{errror: "Error message"}` object in case of an error handled by the API methods. * * @param {string} endpoint - The API endpoint to call. * @param {string} method - The HTTP method to use (e.g., 'GET', 'POST'). * @param {object|null} body - The request body (optional). Will be stringified to JSON. * @param {Number} [retryAttempts] - The number of retry attempts after a timeout * @returns {Promise} - A promise that resolves to the response data, * or rejects with an error message if the request fails. */ async function callApi(endpoint, method = 'GET', body = null, retryAttempts = 3) { // If endpoint already starts with /api, use it directly (skip api_base_url prefix) let url = endpoint.startsWith('/api') ? endpoint : `${api_base_url}${endpoint}`; /** @type {RequestInit} */ const options = { method, headers: { 'X-Session-ID': sessionId || '', } }; // Handle request body based on method and content type if (body !== null && body !== undefined) { if (method === 'GET') { // GET requests: convert body to query string const params = new URLSearchParams(); for (const [key, value] of Object.entries(body)) { if (value !== null && value !== undefined) { params.append(key, String(value)); } } const queryString = params.toString(); if (queryString) { url += (url.includes('?') ? '&' : '?') + queryString; } } else if (body instanceof FormData) { // FormData (file uploads) - don't stringify or set Content-Type options.body = body; // Don't set Content-Type header - browser will set it with boundary } else { // POST/PUT/PATCH/DELETE with JSON body options.headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); } } // else: no body (valid for GET without params, DELETE without body, etc.) // function to send the request which can be repeatedly called in case of a timeout const sendRequest = async () => { // send request const response = await fetch(url, options); // save the last HTTP status code for later use lastHttpStatus = response.status let result; try { result = await response.json() } catch (jsonError) { throw new ServerError("Failed to parse error response as JSON"); } // Check for all 2XX success status codes (200, 201, 204, etc.) if (response.ok) { // simple legacy error protocol if the server doesn't return a special status code // this is deprecated and should be removed if (result && typeof result === "object" && result.error) { throw new Error(result.error); } // success! return result } // handle error responses // FastAPI uses 'detail', Flask uses 'error' let message = result.detail || result.error // FastAPI validation errors (422) return detail as an array of error objects if (response.status === 422 && Array.isArray(message)) { // Format validation errors into readable messages const errorMessages = message.map(err => { const field = err.loc ? err.loc.slice(1).join('.') : 'unknown' return `${field}: ${err.msg}` }) message = `Validation failed: ${errorMessages.join(', ')}` } // handle app-specific error types switch (response.status) { case 423: throw new LockedError(message) case 504: // Timeout throw new ConnectionError(message) default: // other 4XX errors if (response.status && String(response.status)[0] === '4') { throw new ApiError(message, response.status); } throw new ServerError(message); } } let error; do { try { // return the non-error result return await sendRequest() } catch (e) { error = e if (error instanceof ConnectionError) { // retry in case of ConnectionError LoggerPlugin.getInstance().warn(`Connection error: ${String(error)}. ${retryAttempts} retries remainig..`) // wait one second await new Promise(resolve => setTimeout(resolve, 1000)) } else { // throw the error break; } } } while (retryAttempts-- > 0); throw error } /** * Logs in a user * @param {string} username * @param {string} passwd_hash * @returns {Promise} */ async function login(username, passwd_hash) { return await apiClient.authLogin({ username, passwd_hash }); } /** * Logs out the current user * @returns {Promise} */ async function logout() { return await apiClient.authLogout(); } /** * Checks the authentication status of the current user * @returns {Promise} */ async function status() { return await apiClient.authStatus(); } /** * Gets a list of pdf/tei files from the server, including their relative paths * * @import { DocumentItem } from '../modules/file-data-utils.js' * @param {string|null} variant - Optional variant filter to apply * @param {boolean} refresh - Whether to force refresh of server cache * @returns {Promise} - A promise that resolves to an array of document items */ async function getFileList(variant = null, refresh = false) { // Build query params object const params = {}; if (variant !== null) params.variant = variant; if (refresh) params.refresh = refresh; // Use generated client with query params (callApi handles GET query string conversion) return await apiClient.filesList(params); } /** * @typedef {object} ValidationError * @property {number} line * @property {number} column * @property {string} message */ /** * Lints a TEI XML string against the FastAPI validation endpoint. * * @param {string} xmlString - The TEI XML string to validate. * @returns {Promise} - A promise that resolves to an array of XML validation error messages, */ async function validateXml(xmlString) { const response = await apiClient.validate({ xml_string: xmlString }); return response.errors || []; } /** * Gets autocomplete data for the XML schema associated with the given XML string. * * @param {string} xmlString - The XML string containing schema information. * @param {boolean} [invalidateCache] - Whether to use cached data (false, default) or reload any required data (true) * @returns {Promise>} - A promise that resolves to the autocomplete data object, * which may be in a deduplicated format requiring resolution with resolveDeduplicated(). */ async function getAutocompleteData(xmlString, invalidateCache ) { const response = await apiClient.validateAutocompleteData({ xml_string: xmlString, invalidate_cache: invalidateCache }); return response.data; } /** * Saves the XML string to a file on the server, optionally as a new version * @param {string} xmlString * @param {string} fileId * @param {Boolean?} saveAsNewVersion Optional flag to save the file content as a new version * @returns {Promise} */ async function saveXml(xmlString, fileId, saveAsNewVersion) { return await apiClient.filesSave({ xml_string: xmlString, file_id: fileId, new_version: saveAsNewVersion }); } /** * Gets a list of available extraction engines * @returns {Promise} Array of extractor information objects */ async function getExtractorList() { return await apiClient.extractList(); } /** * Extract content from a source document using specified extractor * @param {string} file_id The file ID/hash of the source document to extract from * @param {any} options The options for the extraction, including extractor type and specific options * @returns {Promise} */ async function extract(file_id, options) { // Extract extractor ID from options for new API format const extractor = options.extractor || "llamore-gemini"; const extractionOptions = { ...options }; delete extractionOptions.extractor; // Remove extractor from options return await apiClient.extract({ file_id: file_id, extractor: extractor, options: extractionOptions }); } /** * Returns the current prompt extraction instruction data * @returns {Promise>} An array of {active,label,text} objects */ async function loadInstructions() { return await apiClient.configListInstructions(); } /** * Saves the prompt extraction instruction data * @param {Array} instructions An array of {active,label,text} objects * @returns {Promise} The result object */ async function saveInstructions(instructions) { if (!Array.isArray(instructions)) { throw new Error("Instructions must be an array"); } // Send the instructions to the server return await apiClient.configSaveInstructions({ instructions }); } /** * Deletes all extraction document versions with the given file IDs * @param {string[]} fileIds * @returns {Promise} The result object */ async function deleteFiles(fileIds) { if (!Array.isArray(fileIds)) { throw new Error("File IDs must be an array"); } return await apiClient.filesDelete({ files: fileIds }); } /** * Creates a new version of a file from an uploaded file. * @param {string} tempFilename * @param {string} fileId * @returns {Promise} */ async function createVersionFromUpload(tempFilename, fileId) { return await apiClient.filesCreateVersionFromUpload({ temp_filename: tempFilename, file_id: fileId }); } /** * Retrieves the server application state * @returns {Promise} */ async function state() { return await apiClient.configState(); } /** * Moves the given files to a new collection * @param {string} pdf - PDF file ID * @param {string} xml - XML file ID * @param {string} destinationCollection - Destination collection ID * @returns {Promise<{new_pdf_id: string, new_xml_id: string}>} */ async function moveFiles(pdf, xml, destinationCollection) { return await apiClient.filesMove({ pdf_id: pdf, xml_id: xml, destination_collection: destinationCollection }); } /** * Copies the given files to an additional collection * @param {string} pdf - PDF file ID * @param {string} xml - XML file ID * @param {string} destinationCollection - Destination collection ID * @returns {Promise<{new_pdf_id: string, new_xml_id: string}>} */ async function copyFiles(pdf, xml, destinationCollection) { return await apiClient.filesCopy({ pdf_id: pdf, xml_id: xml, destination_collection: destinationCollection }); } /** * Gets the list of collections accessible to the current user * @returns {Promise>} */ async function getCollections() { return await apiClient.listCollections(); } /** * Creates a new collection * @param {string} id - Collection ID (only letters, numbers, hyphens, underscores) * @param {string} [name] - Display name (defaults to id if not provided) * @param {string} [description] - Collection description * @returns {Promise<{success: boolean, message: string, collection: {id: string, name: string, description: string}}>} */ async function createCollection(id, name, description = "") { return await apiClient.createCollections({ id, name: name || id, description }); } /** * Returns all server-side configuration values for this application * @returns {Promise} */ async function getConfigData() { return await apiClient.configList(); } /** * Sets a configuration value on the server. * @param {string} key * @param {any} value */ async function setConfigValue(key, value) { if (typeof key !== "string" || key.length === 0) { throw new Error("Key must be a non-empty string"); } // The server is expected to return { result: "OK" } on success return await apiClient.configSet({ key, value }); } /** * Sends a heartbeat to the server to keep the file lock alive. * @param {string} fileId The file ID to send the heartbeat for * @returns {Promise<{status:string, cache_status:{dirty:boolean, last_modified:number|null, last_checked:number|null}}>} The response from the server * @throws {Error} If the file ID is not provided or if the heartbeat fails */ async function sendHeartbeat(fileId) { if (!fileId) { throw new Error("File ID is required for heartbeat"); } return await apiClient.filesHeartbeat({ file_id: fileId }); } /** * Checks if a file is locked by another user. * @param {string} fileId The file ID to check the lock for * @returns {Promise<{is_locked: boolean}>} The response from the server indicating if the file is locked * @throws {Error} If the file ID is not provided or if the lock check fails */ async function checkLock(fileId) { if (!fileId) { throw new Error("File ID is required to check lock"); } return await apiClient.filesCheckLock({ file_id: fileId }); } /** * Acquires a lock on a file * @param {string} fileId * @returns {Promise} */ async function acquireLock(fileId) { if (!fileId) { throw new Error("File ID is required to acquire lock"); } return await apiClient.filesAcquireLock({ file_id: fileId }); } /** * Releases a lock on a file * @param {string} fileId * @returns {Promise} */ async function releaseLock(fileId) { if (!fileId) { throw new Error("File ID is required to release lock"); } return await apiClient.filesReleaseLock({ file_id: fileId }); } /** * Retrieves an object mapping all currently locked files to the session ID that locked them. * @returns {Promise<{[key:string]:Number}>} An object mapping locked file paths to session IDs */ async function getAllLockedFileIds() { return await apiClient.filesLocks(); } /** * Uploads a file selected by the user to a specified URL using `fetch()`. * * @param {string} uploadUrl - The URL to which the file will be uploaded. * @param {object} [options={}] - Optional configuration options. * @param {string} [options.method='POST'] - The HTTP method to use for the upload. * @param {string} [options.fieldName='file'] - The name of the form field for the file. * @param {object} [options.headers={}] - Additional headers to include in the request. * @param {function} [options.onProgress] - A callback function to handle upload progress events. * The function receives a progress event object as an argument. * @param {string} [options.accept='.pdf, .xml'] - The accepted file types for the file input. * This is a string that will be set as the `accept` attribute of the file * @returns {Promise<{ type:string, filename:string, originalFilename:string } >} - A Promise that resolves with the json-deserialized result * from the `fetch()` call, which must be an object, or rejects with an error. * The object should contain the uploaded file's metadata, such as its path or ID. * It will always contain a key "originalFilename" with the original name of the file, * (as the server can change the filename). Ff the upload fails on the server side, * it will contain an "error" key with the error message. * @throws {Error} If the upload fails or if no file is selected. * @example * // Async/Await example (requires an async function context): * async function myUploadFunction() { * try { * const response = await uploadFile('https://example.com/upload', { * fieldName: 'my_file', * headers: { * 'X-Custom-Header': 'value' * }, * onProgress: (event) => { * if (event.lengthComputable) { * const percentComplete = (event.loaded / event.total) * 100; * logger.info(`Uploaded: ${percentComplete.toFixed(2)}%`); * } else { * logger.info("Total size is unknown"); * } * } * }); * * if (response.ok) { * const data = await response.json(); * logger.info('Upload successful:', data); * } else { * throw new Error(`Upload failed: ${response.status} ${response.statusText}`); * } * } catch (error) { * console.error('Error uploading file:', error); * } * } */ export async function uploadFile(uploadUrl = upload_route, options = {}) { return new Promise((resolve, reject) => { const { method = 'POST', fieldName = 'file', headers = {}, onProgress, accept = '.pdf, .xml' } = options; const input = document.createElement('input'); input.type = 'file'; input.accept = accept; input.addEventListener('change', async () => { const file = input.files && input.files[0]; if (!file) { reject(new Error('No file selected.')); return; } const formData = new FormData(); console.debug("Uploading file:", file.name, "to", uploadUrl); formData.append(fieldName, file); const fetchOptions = { method: method, body: formData, headers: { ...headers, 'X-Session-ID': sessionId || '' } }; try { const response = await fetch(uploadUrl, fetchOptions); if (!response.ok) { reject(new Error(`HTTP error! Status: ${response.status}`)); return; } let result = await response.json() if (typeof result !== 'object' || !result) { reject(new Error("Invalid response format, expected an object")); return; } // Ensure the result contains the original filename if (!result.originalFilename) { result.originalFilename = file.name; // Fallback to the input file name } if (result.error) { reject(result.error) } resolve(result); } catch (error) { reject(error); } }); // Programmatically trigger the file chooser dialog. Crucially, this must be initiated from a user action, // such as a button click, to work correctly in most browsers. Directly calling input.click() on page // load will generally be blocked. input.click(); }); } /** * Backend plugin endpoint definition * @typedef {Object} BackendPluginEndpoint * @property {string} name - Endpoint method name * @property {string} label - Display label for menu item * @property {string} [description] - Optional description shown as tooltip * @property {string[]} state_params - Required frontend state fields to pass as parameters */ /** * Get list of available backend plugins * @typedef {Object} BackendPlugin * @property {string} id - Plugin identifier * @property {string} name - Plugin display name * @property {string} description - Plugin description * @property {string} category - Plugin category * @property {string} version - Plugin version * @property {BackendPluginEndpoint[]} [endpoints] - Optional menu endpoint definitions * * @param {string|null} [category] - Optional category filter * @returns {Promise} - List of available plugins */ async function getBackendPlugins(category = null) { const params = category ? { category } : {}; const response = await callApi('/plugins', 'GET', params); return response.plugins; } /** * Execute a backend plugin endpoint * @param {string} pluginId - Plugin identifier * @param {string} endpoint - Endpoint name to execute * @param {object} params - Parameters to pass to endpoint * @returns {Promise} - Plugin execution result */ async function executeBackendPlugin(pluginId, endpoint, params) { const response = await callApi(`/plugins/${pluginId}/execute`, 'POST', { endpoint, params }); if (response.success) { return response.result; } throw new ApiError(`Plugin execution failed: ${response.error || 'Unknown error'}`); } const api = { get lastHttpStatus() { return lastHttpStatus; }, ApiError, LockedError, ConnectionError, ServerError, get apiClient() { return apiClient; }, callApi, getFileList, validateXml, getAutocompleteData, saveXml, extract, getExtractorList, loadInstructions, saveInstructions, deleteFiles, createVersionFromUpload, uploadFile, getConfigData, setConfigValue, moveFiles, copyFiles, getCollections, createCollection, state, sendHeartbeat, checkLock, acquireLock, releaseLock, getAllLockedFileIds, login, logout, status, getBackendPlugins, executeBackendPlugin };