pdf-tei-editor / app /src /plugins /document-actions.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
20.4 kB
/**
* Document Actions Plugin - Handles document operations (save, version, delete)
*/
/**
* @import { PluginContext } from '../modules/plugin-context.js'
* @import { ApplicationState } from '../state.js'
* @import { RespStmt, RevisionChange, Edition} from '../modules/tei-utils.js'
* @import { Artifact } from '../modules/file-data-utils.js'
* @import { documentActionButtonsPart } from '../templates/document-action-buttons.types.js'
* @import { saveDocumentDialogPart } from '../templates/save-document-dialog.types.js'
*/
import { Plugin } from '../modules/plugin-base.js'
import ep from '../extension-points.js'
import { testLog } from '../modules/test-log.js'
import { getFileDataById } from '../modules/file-data-utils.js'
import { registerTemplate, createSingleFromTemplate } from '../ui.js'
import { notify } from '../modules/sl-utils.js'
import * as tei_utils from '../modules/tei-utils.js'
import { prettyPrintXmlDom } from '../modules/xml-utils.js'
import { userHasRole, isGoldFile } from '../modules/acl-utils.js'
// Register templates
await registerTemplate('document-action-buttons', 'document-action-buttons.html')
await registerTemplate('save-document-dialog', 'save-document-dialog.html')
class DocumentActionsPlugin extends Plugin {
static extensionPoints = [ep.toolbar.contentItems];
/** @param {PluginContext} context */
constructor(context) {
super(context, { name: 'document-actions', deps: ['file-selection', 'authentication', 'access-control', 'config', 'logger', 'dialog'] })
}
/** @type {HTMLSpanElement & documentActionButtonsPart} */
#ui = null
/** @type {import('../ui.js').SlDialog & saveDocumentDialogPart} */
#dialogUi = null
get #client() { return this.getDependency('client') }
get #logger() { return this.getDependency('logger') }
get #filedata() { return this.getDependency('filedata') }
get #services() { return this.getDependency('services') }
get #xmlEditor() { return this.getDependency('xmleditor') }
/**
* Extension point handler for `ep.toolbar.contentItems`.
* Called by ToolbarPlugin during start() to collect this plugin's toolbar contribution.
* @returns {Array<{element: HTMLElement, priority: number, position: string}>}
*/
[ep.toolbar.contentItems]() {
return [{ element: this.#ui, priority: 8, position: 'center' }]
}
/**
* @param {ApplicationState} _state
*/
async install(_state) {
await super.install(_state)
this.#logger.debug(`Installing plugin "document-actions"`)
const span = createSingleFromTemplate('document-action-buttons')
this.#ui = this.createUi(span)
const dialog = createSingleFromTemplate('save-document-dialog', document.body)
this.#dialogUi = this.createUi(dialog)
const da = this.#ui.documentActions
da.saveRevision.addEventListener('click', () => this.saveRevision())
da.deleteCurrentVersion.addEventListener('click', () => this.deleteCurrentVersion())
da.deleteAllVersions.addEventListener('click', () => this.deleteAllVersions())
da.deleteAll.addEventListener('click', () => this.deleteAll())
}
/**
* Append a button to the document-actions button group.
* Use this instead of accessing `ui.toolbar.documentActions` directly.
* @param {HTMLElement} element
*/
addButton(element) {
this.#ui.documentActions.append(element)
}
/**
* @param {string[]} _changedKeys
*/
async onStateUpdate(_changedKeys) {
const state = this.state
const da = this.#ui.documentActions
da.childNodes.forEach(el => {
if (el instanceof HTMLElement && 'disabled' in el) {
// @ts-ignore
el.disabled = state.offline
}
})
if (state.offline) return
const isReviewer = userHasRole(state.user, ["admin", "reviewer"])
const isAnnotator = userHasRole(state.user, ["admin", "reviewer", "annotator"])
if (isAnnotator || isReviewer) {
da.deleteAll.disabled = !Boolean(state.pdf && state.xml) || !isReviewer
da.deleteAllVersions.disabled = !isReviewer || this.getDependency('file-selection').getOptionValues('xml').length < 2
da.deleteCurrentVersion.disabled = !state.xml || state.editorReadOnly || (isGoldFile(state.xml) && !isReviewer)
} else {
for (const btn of [da.deleteAll, da.deleteAllVersions, da.deleteCurrentVersion]) {
btn.disabled = true
}
}
da.deleteBtn.disabled =
da.deleteCurrentVersion.disabled &&
da.deleteAllVersions.disabled &&
da.deleteAll.disabled
da.saveRevision.disabled = isAnnotator ? !Boolean(state.xml) : true
}
/**
* Deletes the current version of the document
*/
async deleteCurrentVersion() {
const state = this.state
const xmlValue = state.xml || ''
const fileSelection = this.getDependency('file-selection')
const versionLabel = fileSelection.getOptionValues('xml').find(o => o.value === xmlValue)?.label
if (!xmlValue) {
this.getDependency('dialog').error("No file selected for deletion")
return
}
if (state.pdf && typeof xmlValue === 'string') {
const fileData = getFileDataById(xmlValue)
if (fileData && fileData.type === 'gold' && !userHasRole(state.user, ['admin', 'reviewer'])) {
this.getDependency('dialog').error("You cannot delete the gold version")
}
}
const filePathsToDelete = [xmlValue]
if (filePathsToDelete.length > 0) {
const versionName = versionLabel || 'current version'
const msg = `Are you sure you want to delete the current version "${versionName}"?`
if (!confirm(msg)) return
this.#services.removeMergeView()
await this.#client.deleteFiles(/** @type {string[]} */ (filePathsToDelete))
try {
await this.dispatchStateChange({ xml: null })
await this.getDependency('file-selection').reload()
const xml = this.getDependency('file-selection').getOptionValues('xml')[0]?.value
await this.#services.load({ xml })
notify(`Version "${versionName}" has been deleted.`)
this.context.invokePluginEndpoint(ep.sync.syncFiles, state)
.then(summary => summary && console.debug(summary))
.catch(e => console.error(e))
} catch (error) {
console.error(error)
this.getDependency('dialog').error(String(error))
}
}
}
/**
* Deletes all versions of the document, leaving only the gold standard version
*/
async deleteAllVersions() {
const state = this.state
if (!state?.fileData) throw new Error("No file data")
const currentSource = state.pdf
const selectedFile = state.fileData.find(file => file.source?.id === currentSource)
if (!selectedFile || !selectedFile.artifacts) return
let artifactsToDelete = selectedFile.artifacts.filter(/** @param {any} a */ a => !a.is_gold_standard)
const { variant } = state
if (variant === "none") {
artifactsToDelete = artifactsToDelete.filter(/** @param {any} artifact */ artifact => !artifact.variant)
} else if (variant && variant !== "") {
artifactsToDelete = artifactsToDelete.filter(/** @param {any} artifact */ artifact => artifact.variant === variant)
}
const filePathsToDelete = artifactsToDelete.map(/** @param {any} artifact */ artifact => artifact.id)
if (filePathsToDelete.length > 0) {
const variantText = variant === "none" ? "without variant" :
variant && variant !== "" ? `with variant "${variant}"` : ""
const msg = `Are you sure you want to delete ${filePathsToDelete.length} version(s) ${variantText}? This cannot be undone.`
if (!confirm(msg)) return
} else {
const variantText = variant === "none" ? "without variant" : `with variant "${variant}"`
notify(`No versions ${variantText} found to delete.`)
return
}
this.#services.removeMergeView()
try {
await this.#client.deleteFiles(filePathsToDelete)
} catch (err) {
notify(err.message || 'Failed to delete files.', 'danger', 'exclamation-octagon')
await this.getDependency('file-selection').reload()
return
}
try {
await this.dispatchStateChange({ xml: null })
await this.getDependency('file-selection').reload()
let goldToLoad = null
if (selectedFile.artifacts) {
const goldArtifacts = selectedFile.artifacts.filter(/** @param {any} a */ a => a.is_gold_standard)
if (variant === "none") {
goldToLoad = goldArtifacts.find(/** @param {any} gold */ gold => !gold.variant)
} else if (variant && variant !== "") {
goldToLoad = goldArtifacts.find(/** @param {any} gold */ gold => gold.variant === variant)
} else {
goldToLoad = goldArtifacts[0]
}
}
if (goldToLoad) {
await this.#services.load({ xml: goldToLoad.id })
}
const variantText = variant === "none" ? "without variant" :
variant && variant !== "" ? `with variant "${variant}"` : ""
notify(`All versions ${variantText} have been deleted`)
this.context.invokePluginEndpoint(ep.sync.syncFiles, state)
.then(summary => summary && console.debug(summary))
.catch(e => console.error(e))
} catch (error) {
console.error(error)
this.getDependency('dialog').error(String(error))
}
}
/**
* Deletes all versions of the document and the PDF file
*/
async deleteAll() {
const state = this.state
const fileSelection = this.getDependency('file-selection')
if (fileSelection.getOptionValues('pdf').length < 2) {
throw new Error("Cannot delete all files, at least one PDF must be present")
}
const filePathsToDelete = Array.from(new Set(
[state.pdf]
.concat(fileSelection.getOptionValues('xml').map(o => o.value))
.concat(fileSelection.getOptionValues('diff').map(o => o.value))
)).filter(Boolean)
if (filePathsToDelete.length > 0) {
const msg = `Are you sure you want to delete ${filePathsToDelete.length} files? This cannot be undone.`
if (!confirm(msg)) return
}
this.#services.removeMergeView()
this.#logger.debug("Deleting files:" + filePathsToDelete.join(", "))
try {
await this.#client.deleteFiles(/** @type {string[]} */ (filePathsToDelete))
notify(`${filePathsToDelete.length} files have been deleted.`)
this.context.invokePluginEndpoint(ep.sync.syncFiles, state)
.then(summary => summary && console.debug(summary))
.catch(e => console.error(e))
} catch (error) {
const errorMessage = String(error)
console.error(errorMessage)
notify(errorMessage, "warning")
} finally {
await this.getDependency('file-selection').reload({ refresh: true })
await this.dispatchStateChange({ xml: null, pdf: null })
}
}
/**
* Called when the "Save Revision" button is executed.
*/
async saveRevision() {
const state = this.state
const dlg = this.#dialogUi
const userData = /** @type {any} */ (this.getDependency('authentication').getUser())
try {
if (userData) {
const isOwnerBasedMode = this.getDependency('access-control').getMode() === 'owner-based'
const fileData = getFileDataById(state.xml, state.fileData)
const isOwner = fileData?.item?.created_by === userData.username
const forceCopy = isOwnerBasedMode && !isOwner
dlg.options.saveToNewCopySection.saveToNewCopy.checked = forceCopy
dlg.options.saveToNewCopySection.saveToNewCopy.disabled = forceCopy
const nonGoldCount = /** @type {Artifact[]} */ (fileData?.file?.artifacts ?? [])
.filter(a => !a.is_gold_standard && a.variant === state.variant).length
dlg.options.saveToNewCopySection.copyLabel.value = `v${nonGoldCount + 1} (${userData.username})`
const updateCopyLabelVisibility = () => {
dlg.options.saveToNewCopySection.copyLabel.style.display = dlg.options.saveToNewCopySection.saveToNewCopy.checked ? '' : 'none'
}
updateCopyLabelVisibility()
dlg.options.saveToNewCopySection.saveToNewCopy.addEventListener('sl-change', updateCopyLabelVisibility, { once: false })
const xmlDoc = this.#xmlEditor.getXmlTree()
let currentStatus = 'draft'
let lastChangeDesc = ''
if (xmlDoc) {
const lastChange = xmlDoc.querySelector('revisionDesc change:last-of-type')
if (lastChange) {
currentStatus = lastChange.getAttribute('status') || 'draft'
const descEl = lastChange.querySelector('desc')
if (descEl?.textContent?.trim()) {
lastChangeDesc = descEl.textContent.trim()
}
}
}
const config = this.getDependency('config')
const lifecycleOrder = await config.get('annotation.lifecycle.order')
const changeDescriptions = /** @type {string[]} */ (await config.get('annotation.lifecycle.change-descriptions', []))
/** @type {Object.<string, string>} */
const statusDescMap = Object.fromEntries(
lifecycleOrder.map((/** @type {string} */ s, /** @type {number} */ i) => [s, changeDescriptions[i] ?? ''])
)
if (currentStatus === 'extraction') {
const idx = lifecycleOrder.indexOf('extraction')
currentStatus = (idx >= 0 && idx < lifecycleOrder.length - 1)
? lifecycleOrder[idx + 1]
: 'unfinished'
}
const extractionDesc = changeDescriptions[0] ?? ''
const reuseLastDesc = lastChangeDesc && lastChangeDesc !== extractionDesc
const defaultChangeDesc = reuseLastDesc ? lastChangeDesc : (statusDescMap[currentStatus] || '')
const userRoles = userData.roles || []
let allowedStatuses = []
for (const role of userRoles) {
try {
const roleStatuses = await config.get(`annotation.lifecycle.role.${role}`)
if (roleStatuses) {
allowedStatuses = [...allowedStatuses, ...roleStatuses]
}
} catch (e) {
continue
}
}
allowedStatuses = [...new Set(allowedStatuses)]
if (!allowedStatuses.includes(currentStatus)) {
allowedStatuses.push(currentStatus)
}
dlg.status.innerHTML = ''
for (const status of lifecycleOrder) {
const option = document.createElement('sl-option')
option.value = status
option.textContent = status.charAt(0).toUpperCase() + status.slice(1)
option.disabled = !allowedStatuses.includes(status)
dlg.status.appendChild(option)
}
dlg.status.value = currentStatus
dlg.changeDesc.value = defaultChangeDesc
dlg._changeDescManuallyEdited = false
dlg.changeDesc.addEventListener('sl-input', () => { dlg._changeDescManuallyEdited = true }, { once: true })
const isReviewer = userHasRole(userData, ["admin", "reviewer"])
dlg.options.saveAsGoldSection.style.display = isReviewer ? '' : 'none'
const isCurrentlyGold = state.xml ? isGoldFile(state.xml) : false
dlg.options.saveAsGoldSection.saveAsGold.checked = isCurrentlyGold
}
const dialogShown = new Promise(resolve => dlg.addEventListener('sl-after-show', resolve, { once: true }))
dlg.show()
await dialogShown
await new Promise((resolve, reject) => {
dlg.submit.addEventListener('click', resolve, { once: true })
dlg.cancel.addEventListener('click', reject, { once: true })
const handleHide = (e) => {
if (e.target === dlg) reject()
}
dlg.addEventListener('sl-hide', handleHide, { once: true })
})
} catch (e) {
if (e instanceof Error) {
console.error("Error in saveRevision:", e)
throw e
}
this.#logger.info("User cancelled")
return
} finally {
dlg.hide()
delete dlg._changeDescManuallyEdited
}
dlg.hide()
const saveToNewCopy = dlg.options.saveToNewCopySection.saveToNewCopy.checked
/** @type {RespStmt} */
const respStmt = {
persId: userData.username,
persName: userData.fullname,
resp: "Annotator"
}
/** @type {RevisionChange} */
const revisionChange = {
status: dlg.status.value,
persId: userData.username,
desc: dlg.changeDesc.value,
label: saveToNewCopy ? (dlg.options.saveToNewCopySection.copyLabel.value.trim() || undefined) : undefined
}
const saveAsGold = dlg.options.saveAsGoldSection.saveAsGold.checked
this.#ui.documentActions.saveRevision.disabled = true
try {
if (saveToNewCopy) {
if (!state.xml) throw new Error('No XML file loaded')
const currentFileId = state.xml
const sourceFile = getFileDataById(currentFileId, state.fileData)
const sourceVariant = sourceFile?.variant
await this.addTeiHeaderInfo(respStmt, /** @type {Edition} */ ({ title: undefined, note: undefined }), revisionChange)
if (sourceVariant) {
const xmlDoc = this.#xmlEditor.getXmlTree()
if (xmlDoc) {
tei_utils.ensureExtractorVariant(xmlDoc, sourceVariant)
await this.#xmlEditor.updateEditorFromXmlTree()
}
}
this.#xmlEditor.markAsClean()
const filedata = this.#filedata
let { file_id: newFileId } = await filedata.saveXml(currentFileId, true)
testLog('NEW_VERSION_CREATED', { oldFileId: currentFileId, newFileId })
this.#xmlEditor.markAsClean()
await this.getDependency('file-selection').reload({ refresh: true })
await this.#services.load({ xml: newFileId })
testLog('NEW_VERSION_LOADED', { fileId: newFileId, editorReadOnly: this.state.editorReadOnly })
notify("Document was duplicated. You are now editing the copy.")
} else {
await this.addTeiHeaderInfo(respStmt, undefined, revisionChange)
if (!state.xml) throw new Error('No XML file loaded')
this.#xmlEditor.markAsClean()
const filedata = this.#filedata
await filedata.saveXml(state.xml)
testLog('REVISION_SAVED', {
changeDescription: dlg.changeDesc.value,
status: dlg.status.value
})
testLog('REVISION_IN_XML_VERIFIED', {
changeDescription: dlg.changeDesc.value,
xmlContainsRevision: this.#xmlEditor.getXML().includes(dlg.changeDesc.value)
})
if (saveAsGold) {
try {
await this.#client.apiClient.filesGoldStandard(state.xml)
testLog('GOLD_STANDARD_SET', { fileId: state.xml })
await this.getDependency('file-selection').reload({ refresh: true })
notify("Revision saved and marked as Gold version")
} catch (goldError) {
console.error("Failed to set gold standard:", goldError)
notify("Revision saved, but failed to set as Gold version", "warning")
}
} else {
notify("Revision saved successfully")
}
}
this.context.invokePluginEndpoint(ep.sync.syncFiles, state)
.then(summary => summary && console.debug(summary))
.catch(e => console.error(e))
this.#xmlEditor.markAsClean()
} catch (error) {
console.error(error)
notify(`Save failed: ${String(error)}`, 'danger', 'exclamation-octagon')
} finally {
this.#ui.documentActions.saveRevision.disabled = false
}
}
/**
* Add information on responsibility, edition or revisions to the document
* @param {RespStmt} [respStmt]
* @param {Edition} [_edition]
* @param {RevisionChange} [revisionChange]
* @throws {Error} If any of the operations to add teiHeader info fail
*/
async addTeiHeaderInfo(respStmt, _edition, revisionChange) {
const xmlDoc = this.#xmlEditor.getXmlTree()
if (!xmlDoc) throw new Error("No XML document loaded")
if (respStmt) {
if (!respStmt || tei_utils.getRespStmtById(xmlDoc, respStmt.persId)) {
console.warn("No persId or respStmt already exists for this persId")
} else {
tei_utils.addRespStmt(xmlDoc, respStmt)
}
}
if (revisionChange) {
tei_utils.addRevisionChange(xmlDoc, revisionChange)
}
prettyPrintXmlDom(xmlDoc, 'teiHeader')
await this.#xmlEditor.updateEditorFromXmlTree()
}
}
export default DocumentActionsPlugin
export const plugin = DocumentActionsPlugin