/** * 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.} */ 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