Spaces:
Build error
Build error
| /** | |
| * This component provides inter-plugin orchestration methods | |
| */ | |
| /** | |
| * @import { ApplicationState } from '../state.js' | |
| * @import { PluginContext } from '../modules/plugin-context.js' | |
| */ | |
| import { Plugin } from '../modules/plugin-base.js' | |
| import ep from '../extension-points.js' | |
| import ui from '../ui.js' | |
| import { SsePlugin } from '../plugin-registry.js' | |
| import { getFileDataById } from '../modules/file-data-utils.js' | |
| import { UrlHash } from '../modules/browser-utils.js' | |
| import { notify } from '../modules/sl-utils.js' | |
| import { resolveDeduplicated } from '../modules/codemirror/codemirror-utils.js' | |
| import { ApiError } from '../modules/utils.js' | |
| import { encodeXmlEntities } from '../modules/tei-utils.js' | |
| class ServicesPlugin extends Plugin { | |
| /** @type {import('./logger.js').default} */ | |
| #logger | |
| /** @type {ReturnType<import('./config.js').default['getApi']>} */ | |
| #config | |
| /** @type {import('./dialog.js').default} */ | |
| #dialog | |
| /** @type {import('./client.js').api} */ | |
| #client | |
| /** @type {import('./access-control.js').default} */ | |
| #accessControl | |
| /** @type {import('./xmleditor.js').default} */ | |
| #xmlEditor | |
| /** @type {import('./pdfviewer.js').default} */ | |
| #pdfViewer | |
| /** @type {import('./tei-validation.js').default} */ | |
| #validation | |
| /** @type {import('./file-selection.js').default} */ | |
| #fileSelection | |
| /** @param {PluginContext} context */ | |
| constructor(context) { | |
| super(context, { | |
| name: 'services', | |
| deps: ['file-selection', 'document-actions', 'logger', 'config', 'dialog', 'client', 'access-control', 'xmleditor', 'pdfviewer', 'tei-validation'] | |
| }) | |
| } | |
| /** @param {ApplicationState} state */ | |
| async install(state) { | |
| await super.install(state) | |
| this.#logger = this.getDependency('logger') | |
| this.#config = this.getDependency('config') | |
| this.#dialog = this.getDependency('dialog') | |
| this.#client = this.getDependency('client') | |
| this.#accessControl = this.getDependency('access-control') | |
| this.#xmlEditor = this.getDependency('xmleditor') | |
| this.#pdfViewer = this.getDependency('pdfviewer') | |
| this.#validation = this.getDependency('tei-validation') | |
| this.#fileSelection = this.getDependency('file-selection') | |
| // Listen for maintenance mode events | |
| SsePlugin.getInstance().addEventListener('maintenanceOn', async (event) => { | |
| const data = JSON.parse(event.data) | |
| await this.dispatchStateChange({ maintenanceMode: true }) | |
| ui.spinner.show(data.message || '') | |
| }) | |
| SsePlugin.getInstance().addEventListener('maintenanceOff', async (event) => { | |
| ui.spinner.hide() | |
| await this.dispatchStateChange({ maintenanceMode: false }) | |
| const data = JSON.parse(event.data) | |
| if (data.message) { | |
| this.#dialog.info(data.message) | |
| } | |
| }) | |
| SsePlugin.getInstance().addEventListener('maintenanceReload', () => { | |
| window.location.reload() | |
| }) | |
| // Listen for lock release events to attempt acquiring locks for read-only documents | |
| SsePlugin.getInstance().addEventListener('lockReleased', async (event) => { | |
| const data = JSON.parse(event.data) | |
| const releasedStableId = data.stable_id | |
| // Check if we're currently viewing this document in read-only mode | |
| if (this.state.xml === releasedStableId && this.state.editorReadOnly) { | |
| this.#logger.debug(`Lock released for current document ${releasedStableId}, attempting to acquire`) | |
| try { | |
| // Try to acquire the lock (first client wins) | |
| await this.#client.acquireLock(releasedStableId) | |
| this.#logger.info(`Successfully acquired lock for ${releasedStableId}`) | |
| notify('You can now edit this document', 'success', 'unlock') | |
| await this.load({ xml: releasedStableId }) | |
| await this.dispatchStateChange({ editorReadOnly: false }) | |
| } catch (error) { | |
| if (error instanceof this.#client.LockedError) { | |
| this.#logger.debug(`Lock already acquired by another client for ${releasedStableId}`) | |
| // Another client got the lock first, stay in read-only mode | |
| } else { | |
| this.#logger.error(`Failed to acquire lock for ${releasedStableId}: ${error}`) | |
| } | |
| } | |
| } | |
| }) | |
| } | |
| /** | |
| * Called when the application is shutting down (beforeunload) | |
| * Release any file locks held by this session | |
| */ | |
| async shutdown() { | |
| if (this.state?.xml && !this.state?.editorReadOnly) { | |
| try { | |
| await this.#client.releaseLock(this.state.xml) | |
| this.#logger.debug(`Released lock for file ${this.state.xml} during shutdown`) | |
| } catch (error) { | |
| // Don't throw during shutdown - just log the error | |
| console.warn('Failed to release lock during shutdown:', String(error)) | |
| } | |
| } | |
| } | |
| /** | |
| * Loads the given XML and/or PDF file(s) into the editor and viewer | |
| * @param {{xml?: string | null, pdf?: string | null}} files An Object with one or more of the keys "xml" and "pdf" | |
| */ | |
| async load({ xml, pdf }) { | |
| // Signal loading state to disable selectboxes | |
| await this.context.invokePluginEndpoint(ep.filedata.loading, true) | |
| try { | |
| // Capture current state at call time before any changes | |
| const currentState = this.state | |
| const stateChanges = {} | |
| const promises = [] | |
| let file_is_locked = false | |
| if (pdf) { | |
| await this.dispatchStateChange({ pdf: null, xml: null, diff: null }) | |
| this.#logger.info("Loading PDF: " + pdf) | |
| // Convert document identifier to static file URL | |
| const pdfUrl = `/api/files/${pdf}` // TODO unhardcode this! | |
| promises.push(this.#pdfViewer.load(pdfUrl)) | |
| } | |
| // XML | |
| if (xml) { | |
| // Always check for lock before loading, even if file is already in state | |
| // (e.g., when opening same URL in new tab with sessionStorage containing stale state) | |
| const isNewFile = currentState.xml !== xml; | |
| try { | |
| ui.spinner.show('Loading file, please wait...') | |
| // Release previous lock if we're switching files | |
| if (isNewFile && currentState.xml && !currentState.editorReadOnly) { | |
| await this.#client.releaseLock(currentState.xml) | |
| } | |
| // Check access control before attempting to acquire lock | |
| const canEdit = this.#accessControl.checkCanEditFile(xml) | |
| if (!canEdit) { | |
| this.#logger.debug(`User does not have edit permission for file ${xml}, loading in read-only mode`) | |
| file_is_locked = true | |
| } else { | |
| try { | |
| await this.#client.acquireLock(xml) | |
| this.#logger.debug(`Acquired lock for file ${xml}`) | |
| } catch (error) { | |
| if (error instanceof this.#client.LockedError) { | |
| this.#logger.debug(`File ${xml} is locked, loading in read-only mode`) | |
| file_is_locked = true | |
| notify( | |
| 'This document is being edited by another user', | |
| 'warning', | |
| 'exclamation-triangle' | |
| ) | |
| } else if (error instanceof this.#client.ApiError && error.statusCode === 403) { | |
| // Permission denied - load in read-only mode | |
| this.#logger.debug(`No edit permission for file ${xml}, loading in read-only mode`) | |
| file_is_locked = true | |
| // In owner-based mode the access-control plugin shows a more specific notification | |
| if (this.#accessControl.getMode() !== 'owner-based') { | |
| notify( | |
| 'You do not have permission to edit this document.', | |
| 'warning', | |
| 'lock' | |
| ) | |
| } | |
| } else { | |
| const errorMessage = String(error) | |
| this.#dialog.error(errorMessage) | |
| throw error | |
| } | |
| } | |
| } | |
| } finally { | |
| ui.spinner.hide() | |
| } | |
| // Always load XML content and update state | |
| await this.removeMergeView() | |
| await this.dispatchStateChange({ | |
| xml: null, | |
| diff: null, | |
| editorReadOnly: file_is_locked, | |
| xpath: null | |
| }) | |
| this.#logger.info(`Loading XML: ${xml} (read-only: ${file_is_locked})`) | |
| // Convert document identifier to static file URL | |
| const xmlUrl = `/api/files/${xml}` | |
| promises.push(this.#xmlEditor.loadXml(xmlUrl)) | |
| } | |
| // await promises in parallel | |
| try { | |
| await Promise.all(promises) | |
| } catch (error) { | |
| if (error instanceof ApiError) { | |
| // @ts-ignore | |
| if (error.status === 404) { | |
| this.#logger.warn(String(error)) | |
| await this.#fileSelection.reload() | |
| return | |
| } | |
| } | |
| throw error | |
| } | |
| if (pdf) { | |
| stateChanges.pdf = pdf | |
| } | |
| if (xml) { | |
| stateChanges.xml = xml | |
| // call asynchronously, don't block the editor | |
| this.#startAutocomplete().then(result => { | |
| result && this.#logger.info("Autocomplete is available") | |
| }) | |
| } | |
| // Set collection and variant based on loaded documents | |
| if (currentState.fileData && (pdf || xml)) { | |
| for (const file of currentState.fileData) { | |
| const fileData = /** @type {any} */ (file) | |
| let foundMatch = false | |
| // Check source id | |
| if (pdf && fileData.source && fileData.source.id === pdf) { | |
| if (!currentState.collection) { | |
| stateChanges.collection = fileData.collections[0] | |
| } | |
| foundMatch = true | |
| } | |
| // Check XML id in artifacts (don't skip this even if PDF was found) | |
| if (xml) { | |
| const matchingArtifact = fileData.artifacts && fileData.artifacts.find(/** @param {any} artifact */ artifact => artifact.id === xml) | |
| if (matchingArtifact) { | |
| if (!currentState.collection) { | |
| stateChanges.collection = fileData.collections[0] | |
| } | |
| // Always set variant from artifact (it's the source of truth for the loaded document) | |
| if (matchingArtifact.variant) { | |
| stateChanges.variant = matchingArtifact.variant | |
| } | |
| foundMatch = true | |
| } | |
| } | |
| // Only break if we found what we're looking for | |
| if (foundMatch) { | |
| break | |
| } | |
| } | |
| } | |
| // notify plugins | |
| await this.dispatchStateChange(stateChanges) | |
| } finally { | |
| // Clear loading state | |
| await this.context.invokePluginEndpoint(ep.filedata.loading, false) | |
| } | |
| } | |
| async #startAutocomplete() { | |
| // Load autocomplete data asynchronously after XML is loaded | |
| try { | |
| this.#logger.debug("Loading autocomplete data for XML document") | |
| const xmlContent = this.#xmlEditor.getEditorContent() | |
| if (xmlContent) { | |
| const hasXsdSchema = /schemaLocation="[^"]+"/.test(xmlContent) | |
| const hasRelaxNG = /<\?xml-model\s[^>]*schematypens="http:\/\/relaxng\.org\/ns\/structure\/1\.0"[^>]*\?>/.test(xmlContent) | |
| if (!hasXsdSchema && !hasRelaxNG) { | |
| this.#logger.info("No schema declaration found in XML document, skipping autocomplete") | |
| return true | |
| } | |
| try { | |
| const invalidateCache = this.state?.hasInternet | |
| const autocompleteData = await this.#client.getAutocompleteData(xmlContent, invalidateCache) | |
| // Resolve deduplicated references | |
| const resolvedData = resolveDeduplicated(autocompleteData) | |
| // Start autocomplete with the resolved data | |
| this.#xmlEditor.startAutocomplete(resolvedData) | |
| this.#logger.debug("Autocomplete data loaded and applied") | |
| } catch (error) { | |
| if (error instanceof ApiError) { | |
| this.#logger.info("No autocomplete data available: " + String(error)) | |
| } else { | |
| throw error | |
| } | |
| } | |
| } | |
| return true | |
| } catch (error) { | |
| const errorMessage = String(error) | |
| this.#logger.warn("Failed to load autocomplete data: " + errorMessage) | |
| return false | |
| } | |
| } | |
| /** | |
| * Validates the XML document by calling the validation service | |
| * @returns {Promise<object[]>} | |
| */ | |
| async validateXml() { | |
| this.#logger.info("Validating XML...") | |
| return await this.#validation.validate() // todo use endpoint instead | |
| } | |
| /** | |
| * Creates a diff between the current and the given document and shows a merge view | |
| * @param {string} diff The path to the xml document with which to compare the current xml doc | |
| */ | |
| async showMergeView(diff) { | |
| if (!diff || typeof diff != "string") { | |
| throw new TypeError("Invalid diff value") | |
| } | |
| this.#logger.info("Loading diff XML: " + diff) | |
| ui.spinner.show('Computing file differences, please wait...') | |
| try { | |
| // Convert document identifier to static file URL | |
| const diffUrl = `/api/files/${diff}` | |
| await this.#xmlEditor.showMergeView(diffUrl) | |
| if (!this.state || this.state.diff !== diff) { | |
| await this.dispatchStateChange({ diff: diff }) | |
| } | |
| // turn validation off as it creates too much visual noise | |
| this.#validation.configure({ mode: "off" }) | |
| } finally { | |
| ui.spinner.hide() | |
| } | |
| } | |
| /** | |
| * Removes all remaining diffs | |
| */ | |
| async removeMergeView() { | |
| this.#xmlEditor.hideMergeView() | |
| // re-enable validation | |
| this.#validation.configure({ mode: "auto" }) | |
| if (this.state && this.state.diff) { | |
| UrlHash.remove("diff") | |
| await this.dispatchStateChange({ diff: null }) | |
| } | |
| } | |
| /** | |
| * Downloads the current XML file | |
| * @param {ApplicationState} state | |
| */ | |
| async downloadXml(state) { | |
| if (!state.xml) { | |
| throw new TypeError("State does not contain an xml path") | |
| } | |
| let xml = this.#xmlEditor.getXML() | |
| if (await this.#config.get('xml.encode-entities.server')) { | |
| const encodeQuotes = await this.#config.get('xml.encode-quotes', false) | |
| xml = encodeXmlEntities(xml, { encodeQuotes }) | |
| } | |
| const blob = new Blob([xml], { type: 'application/xml' }) | |
| const url = URL.createObjectURL(blob) | |
| const a = document.createElement('a') | |
| a.href = url | |
| const fileData = getFileDataById(state.xml) | |
| let filename = fileData?.file?.doc_id || state.xml | |
| // Add variant name to filename if variant exists | |
| // The item could be a version or gold file which has variant | |
| const variant = fileData?.item?.variant | |
| if (variant) { | |
| // Extract the variant name from variant (e.g., "grobid.training.segmentation" -> "training.segmentation") | |
| const variantName = variant.replace(/^grobid\./, '') | |
| filename = `${filename}.${variantName}` | |
| } | |
| a.download = `${filename}.tei.xml` | |
| a.click() | |
| URL.revokeObjectURL(url) | |
| } | |
| /** | |
| * Uploads an XML file, creating a new version for the currently selected document | |
| * @param {ApplicationState} state | |
| */ | |
| async uploadXml(state) { | |
| const uploadResult = await this.#client.uploadFile(undefined, { accept: '.xml' }) | |
| const tempFilename = /** @type {any} */ (uploadResult).filename | |
| // @ts-ignore | |
| const { path } = await this.#client.createVersionFromUpload(tempFilename, state.xml) | |
| await this.#fileSelection.reload() | |
| await this.load({ xml: path }) | |
| notify("Document was uploaded. You are now editing the new version.") | |
| } | |
| /** | |
| * Given a Node in the XML, search and highlight its text content in the PDF Viewer | |
| * @param {Element} node | |
| */ | |
| async searchNodeContentsInPdf(node) { | |
| let searchTerms = getNodeText(node) | |
| // Split on whitespace only; keep hyphenated compounds intact since the | |
| // span-level scorer handles prefix/suffix matching for line-break hyphens | |
| .reduce((/**@type {string[]}*/acc, term) => acc.concat(term.split(/\s+/u)), []) | |
| .filter(term => term.length > 0) | |
| // make the list of search terms unique | |
| searchTerms = Array.from(new Set(searchTerms)) | |
| // add footnote number as required anchor term | |
| // Check the node and its ancestors for a source attribute (handles clicks on child elements) | |
| let anchorTerm = null | |
| let sourceNode = node | |
| while (sourceNode && sourceNode.nodeType === Node.ELEMENT_NODE) { | |
| if (sourceNode.hasAttribute("source")) { | |
| const source = sourceNode.getAttribute("source") | |
| if (source?.slice(0, 2) === "fn") { | |
| anchorTerm = source.slice(2) | |
| searchTerms.unshift(anchorTerm) | |
| break | |
| } | |
| } | |
| sourceNode = sourceNode.parentElement | |
| } | |
| // start search - if anchorTerm is set, clusters must contain it | |
| await this.#pdfViewer.search(searchTerms, { anchorTerm }) | |
| } | |
| } | |
| export default ServicesPlugin | |
| /** | |
| * Returns a list of non-empty text content from all text nodes contained in the given node | |
| * @param {Element} node | |
| * @returns {Array<string>} | |
| */ | |
| function getNodeText(node) { | |
| // @ts-ignore | |
| return getTextNodes(node).map(node => node.textContent?.trim()).filter(Boolean) | |
| } | |
| /** | |
| * Recursively extracts all text nodes contained in the given node into a flat list | |
| * @param {Node} node | |
| * @return {Array<Node>} | |
| */ | |
| function getTextNodes(node) { | |
| /** @type {Node[]} */ | |
| let textNodes = [] | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| textNodes.push(node) | |
| } else { | |
| for (let i = 0; i < node.childNodes.length; i++) { | |
| textNodes = textNodes.concat(getTextNodes(node.childNodes[i])) | |
| } | |
| } | |
| return textNodes | |
| } | |