pdf-tei-editor / app /src /plugins /tei-validation.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
10.1 kB
/**
* @import { Diagnostic } from '@codemirror/lint'
* @import { ApplicationState } from '../state.js'
* @import { ValidationError } from './client.js'
* @import { PluginContext } from '../modules/plugin-context.js'
*/
import { EditorView, ViewUpdate } from '@codemirror/view';
import { linter, lintGutter, forEachDiagnostic, setDiagnostics } from "@codemirror/lint";
import Plugin from '../modules/plugin-base.js';
import ep from '../extension-points.js';
class TeiValidationPlugin extends Plugin {
/** @param {PluginContext} context */
constructor(context) {
super(context, {
name: 'tei-validation',
deps: ['xmleditor', 'client']
});
}
// Cached dependencies
#logger;
#client;
#xmlEditor;
// Private state
#validatedVersion = null;
#isDisabled = false;
#validationInProgress = false;
/** @type {Promise<Diagnostic[]>|null} */
#validationPromise = null;
/** @type {Diagnostic[]} */
#lastDiagnostics = [];
#modeCache;
/** @param {ApplicationState} state */
async install(state) {
await super.install(state);
this.#logger = this.getDependency('logger');
this.#client = this.getDependency('client');
this.#xmlEditor = this.getDependency('xmleditor');
this.#logger.debug(`Installing plugin "${this.name}"`);
this.#xmlEditor.addLinter([
linter(this.#lintSource.bind(this), { autoPanel: true, delay: 2000, needsRefresh: () => false }),
lintGutter()
]);
this.#xmlEditor.on('editorUpdateDelayed', (update) => this.#removeDiagnosticsInChangedRanges(update));
}
/**
* @param {(keyof ApplicationState)[]} changedKeys
* @param {ApplicationState} state
*/
onStateUpdate(changedKeys, state) {
if (!changedKeys.some(k => ['offline', 'editorReadOnly', 'xml'].includes(k))) return;
if (state.offline || state.editorReadOnly || !state.xml) {
this.configure({ mode: 'off' });
} else {
this.configure({ mode: 'auto' });
}
}
static extensionPoints = [ep.validation.validate, ep.validation.inProgress];
/**
* Extension point handler for `ep.validation.validate`.
* Called by the plugin system to trigger XML validation.
* Delegates to {@link TeiValidationPlugin#validate}.
* @returns {Promise<import('@codemirror/lint').Diagnostic[]>}
*/
[ep.validation.validate](...args) { return this.validate(...args) }
/**
* Extension point handler for `ep.validation.inProgress`.
* Called when a validation cycle begins, passing the in-flight promise so this
* plugin can defer conflicting operations until validation completes.
* Delegates to {@link TeiValidationPlugin#inProgress}.
* @param {Promise<import('@codemirror/lint').Diagnostic[]>} promise
*/
[ep.validation.inProgress](...args) { return this.inProgress(...args) }
//
// Public API
//
/**
* Configures the validation mode.
* @param {{ mode: string }} param0
*/
configure({ mode = 'auto' }) {
switch (mode) {
case 'auto':
this.#isDisabled = false;
this.#logger.info('Validation is enabled');
break;
case 'off':
this.#isDisabled = true;
this.#logger.info('Validation is disabled');
break;
default:
throw new Error('Invalid mode parameter');
}
this.#modeCache = mode;
}
/**
* Triggers a validation and returns diagnostics.
* @returns {Promise<Diagnostic[]>}
*/
async validate() {
if (this.#isValidating()) {
this.#logger.debug('Validation is ongoing, waiting for it to finish');
return await this.#anyCurrentValidation();
}
this.#clearDiagnostics();
const prevDisabled = this.#isDisabled;
this.#isDisabled = false;
const diagnostics = await new Promise(resolve => {
this.#logger.debug('Waiting for validation to start...');
const check = () => {
if (this.#isValidating()) {
this.#anyCurrentValidation().then(resolve);
} else {
setTimeout(check, 100);
}
};
check();
});
this.#isDisabled = prevDisabled;
return diagnostics;
}
/**
* Invoked when a validation starts; disables re-entrant validation.
* @param {Promise<Diagnostic[]>} promise
*/
async inProgress(promise) {
this.#isDisabled = true;
await promise;
this.#isDisabled = false;
}
/** @returns {boolean} */
isDisabled() { return this.#isDisabled; }
/** @returns {boolean} */
isValidDocument() { return this.#lastDiagnostics.length === 0; }
getApi() { return this; }
//
// Private methods
//
/**
* Lint source function passed to CodeMirror's linter extension.
* @param {EditorView} view
* @returns {Promise<Diagnostic[]>}
*/
async #lintSource(view) {
const doc = view.state.doc;
const xml = doc.toString();
if (xml === '') {
this.#logger.debug('Nothing to validate.');
return [];
}
if (this.#isDisabled) {
this.#logger.debug('Ignoring validation request: Validation is disabled');
return this.#lastDiagnostics;
}
if (this.#validationInProgress) {
this.#logger.debug('Ignoring validation request: Validation is ongoing.');
return this.#lastDiagnostics;
}
this.#validationInProgress = true;
this.#validationPromise = new Promise(async (resolve) => {
/** @type {ValidationError[]} */
let validationErrors;
while (true) {
this.#validatedVersion = this.#xmlEditor.getDocumentVersion();
this.#logger.debug(`Requesting validation for document version ${this.#validatedVersion}...`);
this.context.invokePluginEndpoint(ep.validation.inProgress, [this.#validationPromise]);
try {
validationErrors = await this.#client.validateXml(xml);
} catch (error) {
this.#logger.warn(`Validation request failed: ${error.message}`);
return resolve([]);
}
console.log(`Received validation results for document version ${this.#validatedVersion}: ${validationErrors.length} errors.`);
if (this.#validatedVersion !== this.#xmlEditor.getDocumentVersion()) {
this.#logger.debug('Document has changed, restarting validation...');
} else {
const diagnostics = validationErrors.map(error => {
if (error.line === undefined || error.column === undefined) {
throw new Error('Invalid response from remote validation:' + JSON.stringify(error));
}
const lineNum = Math.max(1, Math.min(error.line, doc.lines));
let from, to;
try {
const line = doc.line(lineNum);
from = line.from;
to = line.to;
const columnOffset = Math.max(0, Math.min(error.column, line.length));
from = Math.max(0, Math.min(from + columnOffset, doc.length - 1));
to = Math.min(Math.max(from + 1, to), doc.length);
if (from >= to || from >= doc.length) {
from = Math.max(0, Math.min(line.from, doc.length - 1));
to = Math.min(from + 1, doc.length);
}
} catch (e) {
console.warn(`Invalid line/column in validation error:`, error, e);
from = 0;
to = Math.min(1, doc.length);
}
return { from, to, severity: 'error', message: error.message || String(error), column: error.column };
}).filter(Boolean);
// @ts-ignore
return resolve(diagnostics);
}
}
});
let diagnostics;
try {
diagnostics = await this.#validationPromise;
} catch (error) {
if (this.#client.lastHttpStatus && this.#client.lastHttpStatus >= 400) {
this.#logger.warn('Disabling validation because of server error ' + this.#client.lastHttpStatus);
this.configure({ mode: 'off' });
}
return this.#lastDiagnostics;
} finally {
this.#validationInProgress = false;
this.#validationPromise = null;
}
this.#lastDiagnostics = diagnostics;
this.context.invokePluginEndpoint(ep.validation.result, [diagnostics]);
return diagnostics;
}
#clearDiagnostics() {
this.#lastDiagnostics = [];
this.#xmlEditor.getView().dispatch(setDiagnostics(this.#xmlEditor.getView().state, []));
}
#isValidating() { return this.#validationInProgress; }
/** @returns {Promise<Diagnostic[]>} */
#anyCurrentValidation() {
return this.#validationInProgress && this.#validationPromise
? this.#validationPromise
: Promise.resolve([]);
}
/**
* @param {ViewUpdate} update
*/
#removeDiagnosticsInChangedRanges(update) {
const viewState = this.#xmlEditor.getView().state;
/** @type {Diagnostic[]} */
const diagnostics = [];
// @ts-ignore — changedRanges is not in the types but exists at runtime
const changedRangeValues = Object.values(update.changedRanges[0]);
const minRange = Math.min(...changedRangeValues);
const maxRange = Math.max(...changedRangeValues);
forEachDiagnostic(viewState, (d) => {
if (d.from > maxRange || d.to < minRange) {
const docLength = viewState.doc.length;
const validFrom = Math.max(0, Math.min(d.from, docLength - 1));
const validTo = Math.min(Math.max(validFrom + 1, d.to), docLength);
if (validFrom < validTo && validFrom < docLength) {
diagnostics.push({ column: null, from: validFrom, to: validTo, severity: d.severity, message: d.message });
}
} else {
this.#logger.debug('Removing diagnostic ' + JSON.stringify(d));
}
});
this.#lastDiagnostics = diagnostics;
try {
this.#xmlEditor.getView().dispatch(setDiagnostics(viewState, diagnostics));
} catch (error) {
this.#logger.warn('Error setting diagnostics after range change:' + String(error));
this.#xmlEditor.getView().dispatch(setDiagnostics(viewState, []));
this.#lastDiagnostics = [];
}
}
}
export default TeiValidationPlugin;
/** @deprecated Use TeiValidationPlugin class directly */
export const plugin = TeiValidationPlugin;