Newer
Older
import { Injectable } from '@angular/core';
import packageJSON from '../../../package.json';
import { Page, Section, Unit } from 'common/interfaces/unit';
ClozeElement, DragNDropValueObject, DropListElement,
ElementStyling,
InputElement, LikertElement, LikertRowElement, PlayerProperties,
PositionedElement, PositionProperties, RadioButtonGroupElement, TextElement,
ToggleButtonElement,
} from 'common/interfaces/elements';
import { ClozeDocument, ClozeDocumentParagraph, ClozeDocumentParagraphPart } from 'common/interfaces/cloze';
import { ClozeUtils } from 'common/util/cloze';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import ToggleButtonExtension from 'common/tiptap-editor-extensions/toggle-button';
import DropListExtension from 'common/tiptap-editor-extensions/drop-list';
import TextFieldExtension from 'common/tiptap-editor-extensions/text-field';
@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;
static isUnitDefinitionOutdated(unitDefinition: Unit): boolean {
SanitizationService.unitDefinitionVersion =
SanitizationService.readUnitDefinitionVersion(unitDefinition as unknown as Record<string, string>);
return SanitizationService.isVersionOlderThanCurrent(SanitizationService.unitDefinitionVersion);
sanitizeUnitDefinition(unitDefinition: Unit): Unit {
return {
...unitDefinition,
pages: unitDefinition.pages.map((page: Page) => this.sanitizePage(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[0] < SanitizationService.expectedUnitVersion[0]) {
if (version[1] < SanitizationService.expectedUnitVersion[1]) {
return version[2] < SanitizationService.expectedUnitVersion[2];
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, section.dynamicPositioning))) as PositionedElement[]
private sanitizeElement(element: Record<string, UIElementValue>,
sectionDynamicPositioning?: boolean): UIElement {
let newElement: Partial<UIElement> = {
...element,
position: SanitizationService.getPositionProps(element, sectionDynamicPositioning),
styling: SanitizationService.getStyleProps(element),
player: SanitizationService.getPlayerProps(element)
newElement = SanitizationService.handleTextElement(newElement);
if (['text-field', 'text-area', 'text-field-simple', 'spell-correct']
.includes(newElement.type as string)) {
newElement = SanitizationService.sanitizeTextInputElement(newElement);
newElement = this.handleClozeElement(newElement as Record<string, UIElementValue>);
newElement = SanitizationService.handleToggleButtonElement(newElement as ToggleButtonElement);
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'].includes(newElement.type as string)) {
newElement = SanitizationService.handleRadioButtonGroupElement(newElement as RadioButtonGroupElement);
newElement = this.handleLikertElement(newElement as LikertElement);
}
if (['likert-row', 'likert_row'].includes(newElement.type as string)) {
newElement = SanitizationService.handleLikertRowElement(newElement as LikertRowElement);
return newElement as unknown as UIElement;
private static getPositionProps(element: Record<string, any>,
sectionDynamicPositioning?: boolean): PositionProperties {
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,
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
};
...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>): ElementStyling {
if (element.styling !== undefined) {
return element.styling as ElementStyling;
}
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 as ElementStyling;
}
private static getPlayerProps(element: Record<string, UIElementValue>): PlayerProperties {
if (element.playerProps !== undefined) {
return element.playerProps as PlayerProperties;
} else 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
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');
// Version 2.0.0 needs to be sanatized as well because child elements were not sanatized before
if (SanitizationService.unitDefinitionVersion && SanitizationService.unitDefinitionVersion[0] >= 3) {
let childElements: UIElement[];
let doc: ClozeDocument;
if (element.document) {
childElements = ClozeUtils.getClozeChildElements((element as ClozeElement));
doc = element.document as ClozeDocument;
} else {
childElements = (element.parts as any[])
.map((el: any) => el
.filter((el2: { type: string; }) => ['text-field', 'text-field-simple', 'drop-list', 'drop-list', 'toggle-button']
.includes(el2.type)).value)
doc = SanitizationService.createClozeDocument(element);
// 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 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: [StarterKit, 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({
if (newElement.value && !((newElement.value as DragNDropValueObject[])[0] instanceof Object)) {
const newValues: DragNDropValueObject[] = [];
(newElement.value as string[]).forEach(value => {
newValues.push({
stringValue: value
});
});
newElement.value = newValues;
}
return newElement as DropListElement;
}
private handleLikertElement(element: LikertElement): LikertElement {
rows: element.rows.map((row: LikertRowElement) => this.sanitizeElement(row))
} as LikertElement;
}
private static handleLikertRowElement(element: LikertRowElement): LikertRowElement {
const newElement = element;
if (newElement.rowLabel) {
return newElement;
}
return {
...newElement,
rowLabel: {
text: newElement.text,
imgSrc: null,
position: 'above'
}
} as LikertRowElement;
}
// 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 === [1, 1, 0]) && (element.value && element.value > 0)) ?
{
...element,
value: (element.value as number) - 1
} :
element;
private static handleRadioButtonGroupElement(element: RadioButtonGroupElement): RadioButtonGroupElement {
if (element.richTextOptions) {
return element;
}
return {
...element,
richTextOptions: element.options as string[]
};
}
private static handleToggleButtonElement(element: ToggleButtonElement): ToggleButtonElement {
if (element.richTextOptions) {
return element;
}
return {
...element,
richTextOptions: element.options as string[]
};
}