pdf-tei-editor / app /src /plugins /client.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
24.9 kB
/**
* 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<any>} - 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<AuthenticationData>}
*/
async function login(username, passwd_hash) {
return await apiClient.authLogin({ username, passwd_hash });
}
/**
* Logs out the current user
* @returns {Promise<any>}
*/
async function logout() {
return await apiClient.authLogout();
}
/**
* Checks the authentication status of the current user
* @returns {Promise<AuthenticationData>}
*/
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<DocumentItem[]>} - 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<ValidationError[]>} - 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<Record<string, any>>} - 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<Object>}
*/
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<any[]>} 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<Object>}
*/
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<Array<Object>>} An array of {active,label,text} objects
*/
async function loadInstructions() {
return await apiClient.configListInstructions();
}
/**
* Saves the prompt extraction instruction data
* @param {Array<Object>} instructions An array of {active,label,text} objects
* @returns {Promise<Object>} 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<Object>} 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<Object>}
*/
async function createVersionFromUpload(tempFilename, fileId) {
return await apiClient.filesCreateVersionFromUpload({
temp_filename: tempFilename,
file_id: fileId
});
}
/**
* Retrieves the server application state
* @returns {Promise<ApplicationState>}
*/
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<Array<{id: string, name: string, description: string}>>}
*/
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<Object>}
*/
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<Object>}
*/
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<Object>}
*/
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<BackendPlugin[]>} - 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<any>} - 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
};