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