pdf-tei-editor / app /src /plugins /services.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
17.5 kB
/**
* 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
// 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<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
}