Skip to content
Snippets Groups Projects
sanitization.service.ts 17.7 KiB
Newer Older
  • Learn to ignore specific revisions
  • import { Injectable } from '@angular/core';
    import { Editor } from '@tiptap/core';
    
    rhenck's avatar
    rhenck committed
    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';
    
    rhenck's avatar
    rhenck committed
    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) => (
    
    rhenck's avatar
    rhenck committed
            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') {
    
    rhenck's avatar
    rhenck committed
          newElement = SanitizationService.handleTextElement(newElement as Record<string, UIElementValue>);
    
        if (['text-field', 'text-area', 'text-field-simple', 'spell-correct']
    
          .includes(newElement.type as string)) {
    
    rhenck's avatar
    rhenck committed
          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 = SanitizationService.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);
    
    rhenck's avatar
    rhenck committed
        if (['likert'].includes(newElement.type as string)) {
    
          newElement = this.handleLikertElement(newElement as LikertElement);
    
    rhenck's avatar
    rhenck committed
        }
        if (['likert-row', 'likert_row'].includes(newElement.type as string)) {
    
    rhenck's avatar
    rhenck committed
          newElement = SanitizationService.handleLikertRowElement(newElement as Record<string, UIElementValue>);
    
    rhenck's avatar
    rhenck committed
        }
    
        return newElement as unknown as UIElement;
    
      private static getPositionProps(element: Record<string, any>,
                                      sectionDynamicPositioning?: boolean): PositionProperties {
    
    rhenck's avatar
    rhenck committed
        if (element.position && element.position.gridColumnEnd) {
    
          return {
            ...element.position,
    
    rhenck's avatar
    rhenck committed
            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,
    
    rhenck's avatar
    rhenck committed
            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
          };
    
    rhenck's avatar
    rhenck committed
        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;
        }
    
      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;
        });
    
    
          ...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,
    
    rhenck's avatar
    rhenck committed
                          model: this.sanitizeElement(childElements.shift() as Record<string, UIElementValue>)
    
                  )) : 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 static 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({
    
    rhenck's avatar
    rhenck committed
              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({
    
    rhenck's avatar
    rhenck committed
              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;
        }
    
    
        if ((newElement.value as any)[0]?.stringValue) {
          type OldDragNDropValueObject = {
            id: string;
            stringValue?: string;
            imgSrcValue?: string;
          };
          newElement.value = (newElement.value as OldDragNDropValueObject[])
            .map((value: OldDragNDropValueObject) => ({
              text: value.stringValue,
              id: value.id,
              imgSrc: value.imgSrcValue,
              imgPosition: 'above'
            } as DragNDropValueObject));
        }
    
        return newElement as DropListElement;
      }
    
    
      private handleLikertElement(element: LikertElement): LikertElement {
    
        return new LikertElement({
    
    rhenck's avatar
    rhenck committed
          ...element,
    
          options: element.options || element.columns,
    
    rhenck's avatar
    rhenck committed
          rows: element.rows
            .map((row: LikertRowElement) => this.sanitizeElement(row as Record<string, UIElementValue>) as LikertRowElement)
    
    rhenck's avatar
    rhenck committed
      }
    
    
    rhenck's avatar
    rhenck committed
      private static handleLikertRowElement(element: Record<string, UIElementValue>): Partial<LikertRowElement> {
    
    rhenck's avatar
    rhenck committed
        return {
    
    rhenck's avatar
    rhenck committed
          rowLabel: {
    
    rhenck's avatar
    rhenck committed
            text: (element.rowLabel as TextImageLabel).text,
            imgSrc: (element.rowLabel as TextImageLabel).imgSrc,
            imgPosition: (element.rowLabel as TextImageLabel).imgPosition || element.position || 'above'
    
    rhenck's avatar
    rhenck committed
          } as TextImageLabel
    
    rhenck's avatar
    rhenck committed
        };
    
    rhenck's avatar
    rhenck committed
      }
    
    
      // 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: Partial<RadioButtonGroupComplexElement>) {
        return {
          ...element,
          options: element.options ?
            element.options :
            (element.columns as TextImageLabel[])
              .map((column: TextImageLabel) => ({
                ...column, imgPosition: column.imgPosition || (column as any).position || 'above'
              }))
        };