/** * 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} */ #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 // PDF 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} */ 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} */ 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} */ 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 }