Spaces:
Build error
Build error
| /** | |
| * File Data Management Plugin | |
| * | |
| * Handles file data operations including loading file lists, saving XML files, | |
| * and managing file-related state updates. | |
| */ | |
| /** | |
| * @import { ApplicationState } from '../state.js' | |
| * @import { StatusText } from '../modules/panels/widgets/status-text.js' | |
| * @import { PluginContext } from '../modules/plugin-context.js' | |
| * @import { DocumentItem } from '../modules/file-data-utils.js' | |
| */ | |
| import { Plugin } from '../modules/plugin-base.js' | |
| import ep from '../extension-points.js' | |
| import { createIdLookupIndex } from '../modules/file-data-utils.js' | |
| import { PanelUtils } from '../modules/panels/index.js' | |
| import { registerTemplate, createSingleFromTemplate } from '../modules/ui-system.js' | |
| import { userIsAdmin } from '../modules/acl-utils.js' | |
| import { notify } from '../modules/sl-utils.js' | |
| // Register templates | |
| await registerTemplate('gc-menu-item', 'gc-menu-item.html') | |
| /** | |
| * Thrown by {@link FiledataPlugin#saveXml} when the editor's XML is not well-formed. | |
| * Callers can use `error instanceof NoValidXmlError` to distinguish this from transport | |
| * or server-side errors and avoid presenting a generic "Save failed" message for what is | |
| * really a client-side validation problem. | |
| */ | |
| export class NoValidXmlError extends Error { | |
| /** @param {string} message */ | |
| constructor(message) { | |
| super(message); | |
| this.name = 'NoValidXmlError'; | |
| } | |
| } | |
| /** | |
| * File data management plugin | |
| */ | |
| class FiledataPlugin extends Plugin { | |
| /** | |
| * @param {PluginContext} context | |
| */ | |
| constructor(context) { | |
| super(context, { | |
| name: 'filedata', | |
| deps: ['logger', 'client', 'dialog', 'xmleditor', 'toolbar', 'tools'] | |
| }); | |
| /** @type {StatusText | null} */ | |
| this.savingStatusWidget = null; | |
| /** @private Flag to prevent concurrent reload operations */ | |
| this._reloadInProgress = false; | |
| } | |
| // Cached dependencies | |
| #logger; | |
| #client; | |
| #dialog; | |
| #xmlEditor; | |
| static extensionPoints = [ep.filedata.reload, ep.filedata.saveXml]; | |
| /** | |
| * Extension point handler for `ep.filedata.reload`. | |
| * Called to refresh the file list from the server. | |
| * Delegates to {@link FiledataPlugin#reload}. | |
| * @param {{refresh: boolean}} [options] | |
| * @returns {Promise<void>} | |
| */ | |
| [ep.filedata.reload](...args) { return this.reload(...args) } | |
| /** | |
| * Extension point handler for `ep.filedata.saveXml`. | |
| * Called to persist the current XML document to the server. | |
| * Delegates to {@link FiledataPlugin#saveXml}. | |
| * @returns {Promise<void>} | |
| */ | |
| [ep.filedata.saveXml](...args) { return this.saveXml(...args) } | |
| /** | |
| * @param {ApplicationState} state | |
| */ | |
| async install(state) { | |
| await super.install(state); | |
| this.#logger = this.getDependency('logger'); | |
| this.#client = this.getDependency('client'); | |
| this.#dialog = this.getDependency('dialog'); | |
| this.#xmlEditor = this.getDependency('xmleditor'); | |
| this.#logger.debug(`Installing plugin "${this.name}"`); | |
| // Initialize empty ID lookup index to prevent errors during plugin initialization | |
| this.#logger.debug('Initializing empty ID lookup index during installation'); | |
| createIdLookupIndex([]); | |
| // Create status widget for save operations | |
| this.savingStatusWidget = PanelUtils.createText({ | |
| text: '', | |
| icon: 'floppy', | |
| variant: 'primary', | |
| name: 'savingStatus' | |
| }); | |
| // Listen for SSE events about file data changes | |
| this.getDependency('sse').addEventListener('fileDataChanged', async (event) => { | |
| const data = JSON.parse(event.data); | |
| // Only reload for metadata changes, not lock status changes | |
| // Lock status is already updated through other mechanisms | |
| if (data.reason === 'lock_acquired') { | |
| return; | |
| } | |
| // Check if currently loaded document was deleted | |
| if (data.reason === 'files_deleted' && data.stable_ids) { | |
| const currentXml = this.state.xml; | |
| if (currentXml && data.stable_ids.includes(currentXml)) { | |
| this.#logger.warn(`Currently loaded document ${currentXml} was deleted by another user`); | |
| // Clear the editor | |
| this.#xmlEditor.clear(); | |
| // Update state to clear document | |
| await this.context.updateState({ | |
| xml: null, | |
| diff: null, | |
| editorReadOnly: false | |
| }); | |
| // Notify user | |
| notify( | |
| 'The document you were viewing was deleted by another user', | |
| 'warning', | |
| 'trash' | |
| ); | |
| } | |
| } | |
| this.#logger.debug(`File data changed (reason: ${data.reason}), reloading file data`); | |
| // Reload file data when changes occur from other sessions | |
| this.reload({ refresh: true }); | |
| }); | |
| } | |
| async start() { | |
| const gcElement = createSingleFromTemplate('gc-menu-item'); | |
| this._gcItem = gcElement; | |
| this.getDependency('tools').addMenuItems([gcElement], 'administration'); | |
| gcElement.querySelector('[name="gcMenuItem"]').addEventListener('click', () => { | |
| this.showGarbageCollectionDialog(); | |
| }); | |
| // Initially hide until admin status is known | |
| this._gcItem.style.display = 'none'; | |
| } | |
| /** | |
| * Shows a confirmation dialog and triggers garbage collection | |
| * @private | |
| */ | |
| async showGarbageCollectionDialog() { | |
| const result = await this.#dialog.confirm( | |
| 'This will permanently delete all files marked for deletion. Continue?', | |
| 'Confirm Garbage Collection' | |
| ); | |
| if (!result) { | |
| return; | |
| } | |
| try { | |
| // Use current time to collect all deleted files | |
| // The backend enforces 24-hour minimum for non-admin users | |
| const deletedBefore = new Date().toISOString(); | |
| this.#logger.info('Running garbage collection...'); | |
| const response = await this.#client.apiClient.filesGarbageCollect({ | |
| deleted_before: deletedBefore | |
| }); | |
| const { purged_count, files_deleted, storage_freed } = response; | |
| const storageMB = (storage_freed / (1024 * 1024)).toFixed(2); | |
| notify( | |
| `Garbage collection complete: ${purged_count} records purged, ${files_deleted} files deleted, ${storageMB} MB freed`, | |
| 'success', | |
| 'check-circle' | |
| ); | |
| this.#logger.info(`GC complete: ${purged_count} records, ${files_deleted} files, ${storageMB} MB freed`); | |
| // Reload file data to update the UI | |
| await this.reload({ refresh: true }); | |
| } catch (error) { | |
| this.#logger.error('Garbage collection failed: ' + String(error)); | |
| notify('Garbage collection failed: ' + String(error), 'danger', 'exclamation-octagon'); | |
| } | |
| } | |
| /** | |
| * @param {ApplicationState['user']} user | |
| */ | |
| onUserChange(user) { | |
| if (this._gcItem) this._gcItem.style.display = userIsAdmin(user) ? '' : 'none'; | |
| } | |
| /** | |
| * Reloads the file data from the server | |
| * @param {Object} options - Options for reloading | |
| * @param {boolean} [options.refresh] - Whether to force refresh of server cache | |
| * @returns {Promise<ApplicationState>} Updated state with new file data | |
| */ | |
| async reload(options = {}) { | |
| // Prevent concurrent reload operations | |
| if (this._reloadInProgress) { | |
| this.#logger.debug("Ignoring reload request - reload already in progress"); | |
| return this.state; | |
| } | |
| this._reloadInProgress = true; | |
| this.#logger.debug("Reloading file data" + (options.refresh ? " with cache refresh" : "")); | |
| try { | |
| // Get file list response - API returns {files: [...]} structure | |
| const response = await this.#client.getFileList(null, options.refresh); | |
| /** @type {DocumentItem[]} */ | |
| let data = response?.files || []; | |
| if (!data || data.length === 0) { | |
| data = []; // Ensure data is an empty array instead of null/undefined | |
| } | |
| // Create ID lookup index when fileData is loaded | |
| if (data && data.length > 0) { | |
| this.#logger.debug('Creating ID lookup index for file data'); | |
| createIdLookupIndex(data); | |
| } else { | |
| // Initialize empty ID lookup index to prevent errors | |
| this.#logger.debug('Initializing empty ID lookup index'); | |
| createIdLookupIndex([]); | |
| } | |
| // Load collections from server | |
| let collections = []; | |
| try { | |
| collections = await this.#client.getCollections(); | |
| this.#logger.debug(`Loaded ${collections.length} collections from server`); | |
| // Validate that all document collections exist in the collections list | |
| const collectionIds = new Set(collections.map(c => c.id)); | |
| data.forEach((doc) => { | |
| if (doc.collections && doc.collections.length > 0) { | |
| const invalidCollections = doc.collections.filter(colId => !collectionIds.has(colId)); | |
| if (invalidCollections.length > 0) { | |
| this.#logger.warn( | |
| `Document ${doc.doc_id} references non-existent collection(s): ${invalidCollections.join(', ')}` | |
| ); | |
| } | |
| } | |
| }); | |
| } catch (error) { | |
| this.#logger.error(`Failed to load collections: ${error}`); | |
| // Continue with empty collections list - file operations will still work | |
| } | |
| // Store fileData and collections in state and propagate them | |
| const newState = await this.dispatchStateChange({ | |
| fileData: data, | |
| collections | |
| }); | |
| return newState; | |
| } finally { | |
| this._reloadInProgress = false; | |
| } | |
| } | |
| /** | |
| * Saves the current XML content to a file. | |
| * | |
| * If the editor contains malformed XML this method throws a {@link NoValidXmlError} so | |
| * callers can distinguish it from a transport or server error. Callers that auto-save | |
| * (e.g. XmlEditorPlugin.#saveIfDirty) should pre-check `xmlEditor.getXmlTree()` and keep | |
| * the user's content in a local draft instead of attempting the save. | |
| * | |
| * @param {string} fileHash The hash identifying the XML file on the server | |
| * @param {Boolean?} saveAsNewVersion Optional flag to save the file content as a new version | |
| * @returns {Promise<{file_id:string, status:string}>} An object with file_id (stable file identifier) and status | |
| * @throws {NoValidXmlError} when the editor has no parseable XML tree | |
| * @throws {Error} on transport/server failures | |
| */ | |
| async saveXml(fileHash, saveAsNewVersion = false) { | |
| this.#logger.info(`Saving XML${saveAsNewVersion ? " as new version" : ""}...`); | |
| if (!this.#xmlEditor.getXmlTree()) { | |
| throw new NoValidXmlError( | |
| 'Cannot save: the XML in the editor is not well-formed. Fix XML errors and try again.' | |
| ); | |
| } | |
| try { | |
| // Show saving status in the headerbar (right-aligned). | |
| if (this.savingStatusWidget) { | |
| this.getDependency('xmleditor').addHeaderbarWidget(this.savingStatusWidget, 'right', 3); | |
| } | |
| const xmlContent = this.#xmlEditor.getXML() | |
| const result = await this.#client.saveXml(xmlContent, fileHash, saveAsNewVersion); | |
| return /** @type {{file_id: string, status: string}} */ (result); | |
| } finally { | |
| // clear status message after 1 second | |
| setTimeout(() => { | |
| if (this.savingStatusWidget) { | |
| this.getDependency('xmleditor').removeHeaderbarWidget(this.savingStatusWidget.id); | |
| } | |
| }, 1000); | |
| } | |
| } | |
| } | |
| export default FiledataPlugin; |