Code owners
Assign users and groups as approvers for specific file changes. Learn more.
sanitization.service.ts 17.05 KiB
import { Injectable } from '@angular/core';
import { Editor } from '@tiptap/core';
import ToggleButtonExtension from
'common/models/elements/compound-elements/cloze/tiptap-editor-extensions/toggle-button';
import DropListExtension from 'common/models/elements/compound-elements/cloze/tiptap-editor-extensions/drop-list';
import TextFieldExtension from 'common/models/elements/compound-elements/cloze/tiptap-editor-extensions/text-field';
import { Unit } from 'common/models/unit';
import {
BasicStyles, DragNDropValueObject, ExtendedStyles,
InputElement, PlayerProperties,
PositionedUIElement, PositionProperties, TextImageLabel,
UIElement, UIElementValue
} from 'common/models/elements/element';
import { LikertElement } from 'common/models/elements/compound-elements/likert/likert';
import { ToggleButtonElement } from 'common/models/elements/compound-elements/cloze/cloze-child-elements/toggle-button';
import { LikertRowElement } from 'common/models/elements/compound-elements/likert/likert-row';
import { TextElement } from 'common/models/elements/text/text';
import {
ClozeDocument,
ClozeDocumentParagraph,
ClozeDocumentParagraphPart,
ClozeElement
} from 'common/models/elements/compound-elements/cloze/cloze';
import { DropListElement } from 'common/models/elements/input-elements/drop-list';
import { Page } from 'common/models/page';
import { Section } from 'common/models/section';
import { IDManager } from 'common/util/id-manager';
import { RadioButtonGroupComplexElement } from 'common/models/elements/input-elements/radio-button-group-complex';
import packageJSON from '../../../package.json';
@Injectable({
providedIn: 'root'
})
export class SanitizationService {
private static expectedUnitVersion: [number, number, number] =
packageJSON.config.unit_definition_version.split('.') as unknown as [number, number, number];
private static unitDefinitionVersion: [number, number, number] | undefined;
// TODO: isUnitDefinitionOutdated must not set the unitDefinitionVersion
static isUnitDefinitionOutdated(unitDefinition: Partial<Unit>): boolean {
SanitizationService.unitDefinitionVersion =
SanitizationService.readUnitDefinitionVersion(unitDefinition as unknown as Record<string, string>);
return SanitizationService.isVersionOlderThanCurrent(SanitizationService.unitDefinitionVersion);
}
sanitizeUnitDefinition(unitDefinition: Partial<Unit>): Partial<Unit> {
return {
...unitDefinition,
pages: unitDefinition.pages?.map((page: Page) => this.sanitizePage(page)) as Page[]
};
}
private static readUnitDefinitionVersion(unitDefinition: Record<string, string>): [number, number, number] {
return (
unitDefinition.version ||
(unitDefinition.unitDefinitionType && unitDefinition.unitDefinitionType.split('@')[1]) ||
(unitDefinition.veronaModuleVersion && unitDefinition.veronaModuleVersion.split('@')[1]))
.split('.') as unknown as [number, number, number];
}
private static isVersionOlderThanCurrent(version: [number, number, number]): boolean {
if (!version) return true;
if (version[0] < SanitizationService.expectedUnitVersion[0]) {
return true;
}
if (version[1] < SanitizationService.expectedUnitVersion[1]) {
return true;
}
return version[2] < SanitizationService.expectedUnitVersion[2];
}
private sanitizePage(page: Page): Partial<Page> {
return {
...page,
sections: page.sections.map((section: Section) => this.sanitizeSection(section))
};
}
private sanitizeSection(section: Section): Section {
return {
...section,
elements: section.elements.map((element: UIElement) => (
this.sanitizeElement(
element as Record<string, UIElementValue>,
section.dynamicPositioning
))) as PositionedUIElement[]
} as Section;
}
private sanitizeElement(element: Record<string, UIElementValue>,
sectionDynamicPositioning?: boolean): UIElement {
let newElement: Partial<UIElement> = {
...element,
position: SanitizationService.getPositionProps(element, sectionDynamicPositioning),
styling: SanitizationService.getStyleProps(element) as unknown as BasicStyles & ExtendedStyles,
player: SanitizationService.getPlayerProps(element)
};
if (newElement.type === 'text') {
newElement = SanitizationService.handleTextElement(newElement as Record<string, UIElementValue>);
}
if (['text-field', 'text-area', 'text-field-simple', 'spell-correct']
.includes(newElement.type as string)) {
newElement = SanitizationService.sanitizeTextInputElement(newElement as Record<string, UIElementValue>);
}
if (newElement.type === 'cloze') {
newElement = this.handleClozeElement(newElement as Record<string, UIElementValue>);
}
if (newElement.type === 'toggle-button') {
newElement = SanitizationService.handleToggleButtonElement(newElement as Record<string, UIElementValue>);
}
if (['drop-list', 'drop-list-simple'].includes(newElement.type as string)) {
newElement = this.handleDropListElement(newElement as Record<string, UIElementValue>);
}
if (['dropdown', 'radio', 'likert-row', 'radio-group-images', 'toggle-button']
.includes(newElement.type as string)) {
newElement = SanitizationService.handlePlusOne(newElement as InputElement);
}
if (['radio-group-images'].includes(newElement.type as string)) {
newElement = SanitizationService.fixImageLabel(newElement as RadioButtonGroupComplexElement);
}
if (['likert'].includes(newElement.type as string)) {
newElement = this.handleLikertElement(newElement as LikertElement);
}
if (['likert-row', 'likert_row'].includes(newElement.type as string)) {
newElement = SanitizationService.handleLikertRowElement(newElement as Record<string, UIElementValue>);
}
return newElement as unknown as UIElement;
}
private static getPositionProps(element: Record<string, any>,
sectionDynamicPositioning?: boolean): PositionProperties {
if (element.position && element.position.gridColumnEnd) {
return {
...element.position,
dynamicPositioning: sectionDynamicPositioning,
gridColumn: element.position.gridColumn !== undefined ?
element.position.gridColumn : element.position.gridColumnStart,
gridColumnRange: element.position.gridColumnEnd - element.position.gridColumnStart,
gridRow: element.position.gridRow !== undefined ?
element.position.gridRow : element.position.gridRowStart,
gridRowRange: element.position.gridRowEnd - element.position.gridRowStart
};
}
if (element.position) {
return {
...element.position,
dynamicPositioning: sectionDynamicPositioning
};
}
if (element.positionProps) {
return {
...element.positionProps,
dynamicPositioning: sectionDynamicPositioning,
gridColumn: element.positionProps.gridColumn !== undefined ?
element.positionProps.gridColumn : element.positionProps.gridColumnStart,
gridColumnRange: element.positionProps.gridColumnEnd - element.positionProps.gridColumnStart,
gridRow: element.positionProps.gridRow !== undefined ?
element.positionProps.gridRow : element.positionProps.gridRowStart,
gridRowRange: element.positionProps.gridRowEnd - element.positionProps.gridRowStart
};
}
return {
...element,
dynamicPositioning: sectionDynamicPositioning,
gridColumn: element.gridColumn !== undefined ?
element.gridColumn : element.gridColumnStart,
gridColumnRange: element.gridColumnEnd - element.gridColumnStart,
gridRow: element.gridRow !== undefined ?
element.gridRow : element.gridRowStart,
gridRowRange: element.gridRowEnd - element.gridRowStart
} as PositionProperties;
}
/* Style properties are expected to be in 'stylings'. If not they may be in fontProps and/or
* surfaceProps. Even older versions had them in the root of the object, which is uses as last resort.
* The styles object then has all other properties of the element, but that is not a problem
* since the factory methods only use the values they care for and all others are discarded. */
private static getStyleProps(element: Record<string, UIElementValue>): Record<string, UIElementValue> {
if (element.styling !== undefined) {
return element.styling as Record<string, UIElementValue>;
}
if (element.fontProps !== undefined) {
return {
...(element.fontProps as Record<string, any>),
// additional props that were neither fontProp nor surfaceProp before
backgroundColor: (element.surfaceProps as Record<string, any>)?.backgroundColor,
borderRadius: element.borderRadius as number | undefined,
itemBackgroundColor: element.itemBackgroundColor as string | undefined,
borderWidth: element.borderWidth as number | undefined,
borderColor: element.borderColor as string | undefined,
borderStyle: element.borderStyle as
'solid' | 'dotted' | 'dashed' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | undefined,
lineColoring: element.lineColoring as boolean | undefined,
lineColoringColor: element.lineColoringColor as string | undefined
};
}
return element;
}
private static getPlayerProps(element: Record<string, UIElementValue>): PlayerProperties {
if (element.playerProps !== undefined) {
return element.playerProps as PlayerProperties;
}
if (element.player !== undefined) {
return element.player as PlayerProperties;
}
return element as unknown as PlayerProperties;
}
private static handleTextElement(element: Record<string, UIElementValue>): TextElement {
const newElement = { ...element };
if (newElement.highlightable || newElement.interaction === 'highlightable') {
newElement.highlightableYellow = true;
newElement.highlightableTurquoise = true;
newElement.highlightableOrange = true;
}
if (newElement.interaction === 'underlinable') {
newElement.highlightableYellow = true;
}
return newElement as TextElement;
}
private static sanitizeTextInputElement(element: Record<string, UIElementValue>): InputElement {
const newElement = { ...element };
if (newElement.restrictedToInputAssistanceChars === undefined && newElement.inputAssistancePreset === 'french') {
newElement.restrictedToInputAssistanceChars = false;
}
if (newElement.inputAssistancePreset === 'none') {
newElement.inputAssistancePreset = null;
}
return newElement as InputElement;
}
/*
Replace raw text with backslash-markers with HTML tags.
The TipTap editor module can create JSOM from the HTML. It needs plugins though to be able
to create ui-elements.
Afterwards element models are added to the JSON.
*/
private handleClozeElement(element: Record<string, UIElementValue>): ClozeElement {
if (!element.document && (!element.parts || !element.text)) throw Error('Can\'t read Cloze Element');
let childElements: UIElement[];
let doc: ClozeDocument;
// TODO: create a sub method
if (element.document) {
doc = element.document as ClozeDocument;
childElements = ClozeElement.getDocumentChildElements(doc);
} else {
childElements = (element.parts as any[])
.map((el: any) => el
.filter((el2: { type: string; }) => [
'text-field', 'text-field-simple', 'drop-list', 'drop-list-simple', 'toggle-button'
].includes(el2.type)).value)
.flat();
doc = SanitizationService.createClozeDocument(element);
}
// TODO: and create a sub method
// repair child element types
childElements.forEach(childElement => {
childElement.type = childElement.type === 'text-field' ? 'text-field-simple' : childElement.type;
childElement.type = childElement.type === 'drop-list' ? 'drop-list-simple' : childElement.type;
});
return {
...element,
document: {
...doc,
content: doc.content
.map((paragraph: ClozeDocumentParagraph) => ({
...paragraph,
content: paragraph.content ? paragraph.content
.map((paraPart: ClozeDocumentParagraphPart) => (
['TextField', 'DropList', 'ToggleButton'].includes(paraPart.type) ?
{
...paraPart,
attrs: {
...paraPart.attrs,
model: this.sanitizeElement(childElements.shift() as Record<string, UIElementValue>)
}
} :
{
...paraPart
}
)) : undefined
}))
} as ClozeDocument
} as ClozeElement;
}
private static createClozeDocument(element: Record<string, UIElementValue>): ClozeDocument {
const replacedText = (element.text as string).replace(/\\i|\\z|\\r/g, (match: string) => {
switch (match) {
case '\\i':
return '<aspect-nodeview-text-field></aspect-nodeview-text-field>';
case '\\z':
return '<aspect-nodeview-drop-list></aspect-nodeview-drop-list>';
case '\\r':
return '<aspect-nodeview-toggle-button></aspect-nodeview-toggle-button>';
default:
throw Error('error in match');
}
});
const editor = new Editor({
extensions: [ToggleButtonExtension, DropListExtension, TextFieldExtension],
content: replacedText
});
return editor.getJSON() as ClozeDocument;
}
/* before: simple string[]; after: DragNDropValueObject with ID and value.
* Needs to be done to selectable options and the possibly set preset (value). */
private handleDropListElement(element: Record<string, UIElementValue>): DropListElement {
const newElement = { ...element };
if (newElement.options) {
console.warn('New dropList value IDs have been generated');
newElement.value = [];
(newElement.options as string[]).forEach(option => {
(newElement.value as DragNDropValueObject[]).push({
id: IDManager.getInstance().getNewID('value'),
text: option,
imgSrc: null,
imgPosition: 'above'
});
});
}
if (newElement.value &&
(newElement.value as []).length > 0 &&
!((newElement.value as DragNDropValueObject[])[0] instanceof Object)) {
const newValues: DragNDropValueObject[] = [];
(newElement.value as string[]).forEach(value => {
newValues.push({
id: IDManager.getInstance().getNewID('value'),
text: value,
imgSrc: null,
imgPosition: 'above'
});
});
// fix DragNDropValueObject stringValue -> text
// imgSrcValue -> imgSrc
(newElement as DropListElement).value.forEach((valueObject: any) => {
valueObject.text = valueObject.text || valueObject.stringValue;
valueObject.imgSrc = valueObject.text || valueObject.imgSrcValue;
valueObject.imgPosition = valueObject.imgPosition || valueObject.position;
});
newElement.value = newValues;
}
return newElement as DropListElement;
}
private handleLikertElement(element: LikertElement): LikertElement {
return new LikertElement({
...element,
options: element.options || element.columns,
rows: element.rows
.map((row: LikertRowElement) => this.sanitizeElement(row as Record<string, UIElementValue>) as LikertRowElement)
});
}
private static handleLikertRowElement(element: Record<string, UIElementValue>): Partial<LikertRowElement> {
return new LikertRowElement({
...element,
rowLabel: {
text: element.text,
imgSrc: element.imgSrc,
imgPosition: element.imgPosition || element.position || 'above'
} as TextImageLabel
});
}
// version 1.1.0 is the only version where there was a plus one for values, which was rolled back afterwards.
private static handlePlusOne(element: InputElement): InputElement {
return ((SanitizationService.unitDefinitionVersion?.toString() ===
[1, 1, 0].toString()) && (element.value && element.value > 0)) ?
{
...element,
value: (element.value as number) - 1
} as InputElement :
element;
}
private static handleToggleButtonElement(element: Record<string, UIElementValue>): ToggleButtonElement {
if (element.richTextOptions) {
return new ToggleButtonElement({
...element,
options: (element.richTextOptions as string[])
.map(richTextOption => ({ text: richTextOption }))
});
}
if (element.options && (element.options as unknown[]).length) {
if (typeof (element.options as unknown[])[0] === 'string') {
return new ToggleButtonElement({
...element,
options: (element.options as string[])
.map(options => ({ text: options }))
});
}
}
return element as ToggleButtonElement;
}
private static fixImageLabel(element: RadioButtonGroupComplexElement) {
element.options.forEach(option => {
option.imgPosition = option.imgPosition || (option as any).position || 'above';
});
return element;
}
}