Skip to content
Snippets Groups Projects
sanitization.service.ts 21.1 KiB
Newer Older
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 {
  InputElement, PositionedUIElement, 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,
  ClozeDocumentWrapperNode,
  ClozeDocumentContentNode,
  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 { RadioButtonGroupComplexElement } from 'common/models/elements/input-elements/radio-button-group-complex';
import { RadioButtonGroupElement } from 'common/models/elements/input-elements/radio-button-group';
import { MessageService } from 'editor/src/app/services/message.service';
rhenck's avatar
rhenck committed
import { IDService } from 'editor/src/app/services/id.service';
import {
  BasicStyles,
  PlayerProperties,
  PositionProperties
} from 'common/models/elements/property-group-interfaces';
import { DragNDropValueObject, TextImageLabel } from 'common/models/elements/label-interfaces';
rhenck's avatar
rhenck committed
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;
  repairLog: string[] = [];

  // 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[]
rhenck's avatar
rhenck committed
  checkAndRepairIDs(unitDefinition: Partial<Unit>, idManager: IDService, messageService: MessageService): Partial<Unit> {
    this.repairLog = [];
    unitDefinition.pages?.forEach(page => {
      page.sections.forEach(section => {
        section.elements.forEach(element => {
          this.checkIDList(element, idManager, messageService);
          if (element.type === 'likert') {
            (element as LikertElement).rows.forEach(row => this.checkIDList(row, idManager, messageService));
          }
          if (element.type === 'cloze') {
            ClozeElement.getDocumentChildElements((element as ClozeElement).document)
              .forEach(clozeChild => this.checkIDList(clozeChild, idManager, messageService));
          }
        });
      });
    });
    if (this.repairLog.length > 0) {
      messageService.showPrompt(
        `Doppelte IDs gefunden: \n${this.repairLog.join('\n')}\n\n Es wurden neue IDs generiert.`);
    }
rhenck's avatar
rhenck committed
    idManager.reset();
    return unitDefinition;
  }

  private checkIDList(element: UIElement | DragNDropValueObject,
rhenck's avatar
rhenck committed
                      idManager: IDService,
                      messageService: MessageService): void {
rhenck's avatar
rhenck committed
    if (!idManager.isIdAvailable(element.id)) {
      console.warn(`Id already in: ${element.id}! Generating a new one...`);
      this.repairLog.push(element.id);
      element.id = idManager.getAndRegisterNewID((element as UIElement).type || 'value');
rhenck's avatar
rhenck committed
    idManager.addID(element.id);

    if (['drop-list', 'drop-list-simple'].includes((element as UIElement).type as string)) {
      (element as DropListElement).value.forEach(value => {
        this.checkIDList(value, idManager, messageService);
      });
    }
  }

  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 (Number(version[0]) < Number(SanitizationService.expectedUnitVersion[0])) {
      return true;
    }
    if (Number(version[1]) < Number(SanitizationService.expectedUnitVersion[1])) {
      return true;
    }
    return Number(version[2]) < Number(SanitizationService.expectedUnitVersion[2]);
  private sanitizePage(page: Page): Partial<Page> {
    return {
      ...page,
      sections: page.sections.map((section: Section) => this.sanitizeSection(section))
  /* Transform grid sizes from string to array with value and unit. */
  private sanitizeSection(section: any): Section {
    return {
      ...section,
      gridColumnSizes: typeof section.gridColumnSizes === 'string' ?
        (section.gridColumnSizes as string)
          .split(' ')
          .map(size => ({ value: size.slice(0, -2), unit: size.slice(-2) })) :
        section.gridColumnSizes,
      gridRowSizes: typeof section.gridRowSizes === 'string' ?
        (section.gridRowSizes as string)
          .split(' ')
          .map(size => ({ value: size.slice(0, -2), unit: size.slice(-2) })) :
        section.gridRowSizes,
      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),
rhenck's avatar
rhenck committed
      styling: SanitizationService.getStyleProps(element) as unknown as BasicStyles,
      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 (['radio'].includes(newElement.type as string)) {
      newElement = SanitizationService.handleRadioGroup(newElement as RadioButtonGroupElement);
    }
    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,
      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;
    });

      ...element,
    //   document: {
    //     ...doc,
    //     content: doc.content
    //       .map((paragraph: ClozeDocumentWrapperNode) => ({
    //         ...paragraph,
    //         content: paragraph.content ? paragraph.content
    //           .map((paraPart: ClozeDocumentContentNode) => (
    //             ['TextField', 'DropList', 'ToggleButton', 'Button'].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 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, index) => {
        (newElement.value as DragNDropValueObject[]).push({
rhenck's avatar
rhenck committed
          id: 'id_placeholder',
          text: option,
          imgSrc: null,
          audioSrc: null,
          imgPosition: 'above',
          originListID: newElement.id as string,
          originListIndex: index
    if (newElement.value &&
        (newElement.value as []).length > 0 &&
        !((newElement.value as DragNDropValueObject[])[0] instanceof Object)) {
      const newValues: DragNDropValueObject[] = [];
      (newElement.value as string[]).forEach((value, index) => {
        newValues.push({
rhenck's avatar
rhenck committed
          id: 'id_placeholder',
          text: value,
          imgSrc: null,
          audioSrc: null,
          imgPosition: 'above',
          originListID: newElement.id as string,
          originListIndex: index
      // 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 && (newElement.value as any)[0]?.stringValue !== undefined) {
      type OldDragNDropValueObject = {
        id: string;
        stringValue?: string;
        imgSrcValue?: string;
      };
      newElement.value = (newElement.value as OldDragNDropValueObject[])
        .map((value: OldDragNDropValueObject, index) => ({
          text: value.stringValue,
          id: value.id,
          imgSrc: value.imgSrcValue,
          imgPosition: 'above'
        } as DragNDropValueObject));
    }
    // originListID and originListIndex are mandatory and need to be added to all values
    if (newElement.value &&
        (newElement.value as DragNDropValueObject[]).length &&
        !(newElement.value as DragNDropValueObject[])[0].originListID) {
      newElement.value = (newElement.value as DragNDropValueObject[])
        .map((value: DragNDropValueObject, index) => ({
          ...value,
          originListID: newElement.id as string,
          originListIndex: index
        } as DragNDropValueObject));
    }
    return newElement as DropListElement;
  }

  private handleLikertElement(element: Partial<LikertElement>): LikertElement {
    element.options = element.options || element.columns as any;
    return {
rhenck's avatar
rhenck committed
      ...element,
      options: element.options?.map(option => ({
        text: option.text,
        imgSrc: option.imgSrc,
        imgPosition: option.imgPosition || (option as any).position
      })),
rhenck's avatar
rhenck committed
      rows: element.rows
        ?.map((row: LikertRowElement) => (
          this.sanitizeElement(row as Record<string, UIElementValue>) as LikertRowElement))
    } as LikertElement;
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
  }

  private static handleToggleButtonElement(element: Record<string, UIElementValue>): ToggleButtonElement {
rhenck's avatar
rhenck committed
    // 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>): RadioButtonGroupComplexElement {
    return {
      ...element,
      options: element.options ?
        element.options :
        (element.columns as TextImageLabel[])
          .map((column: TextImageLabel) => ({
            ...column, imgPosition: column.imgPosition || (column as any).position || 'above'
          }))
    } as RadioButtonGroupComplexElement;
  }

  private static handleRadioGroup(element: Partial<RadioButtonGroupElement>): RadioButtonGroupElement {
    return {
      ...element,
      options: element.options ?
        element.options :
        (element.richTextOptions as string[])
          .map((option: string) => ({
            text: option
          }))
    } as RadioButtonGroupElement;