Wie kann ich H5P-Inhaltstypen aktiv den Zustand speichern lassen?

Der H5P-Kern sorgt dafür, dass der Zustand der Inhalte automatisch gespeichert wird, wenn die Wiederaufnahmefunktion in den H5P-Integrationseinstellungen aktiviert ist. Der Zustand wird in regelmäßigen Abständen und auf der Grundlage einiger Inhaltstyp- und Browser-Ereignisse gespeichert.

Es kann jedoch einen guten Grund geben, warum du nicht darauf warten möchtest, dass der H5P-Kern die Daten von deinem Inhaltstyp abruft. Vielleicht möchtest du es dem Benutzer ermöglichen, aktiv auf einen Button „Speichern“ zu klicken. Die Funktion, nach der du suchst, heißt setUserData im H5P-Kern. Wir werden sie uns später noch genauer ansehen.

Wichtig ist, dass du dir darüber im Klaren sein solltest, dass jedes Speichern einen Speichervorgang auf dem Server auslöst. Multiplizieren das mit der Anzahl der Benutzer*innen, die den Inhaltstyp gleichzeitig verwenden … Du verstehst.

H5P verfügt über zwei Mechanismen, um zu verhindern, dass der Server mit Anfragen überschwemmt wird, aber diese sind nicht bombensicher. Der erste Mechanismus ist ein Timeout von 250 ms auf dem Client, aber er ist nur für die internen Aufrufe des H5P-Kerns gedacht, die durch bestimmte Ereignisse ausgelöst werden. Der Timeout tritt nicht in Kraft, wenn du setUserData direkt aufrufst. Die zweite Funktion ist ein „flacher“ Objektvergleich zwischen dem letzten gespeicherten Zustand und dem Zustand, der gespeichert werden soll. Dies geschieht ebenfalls auf dem Client. Er hilft, unnötige Speichervorgänge zu vermeiden, wenn sich nichts geändert hat. Aber wenn man beispielweise den Zustand eines Timers, der alle 300 ms abläuft, speichern wollte, oder ähnliches … Puh.

Wie weiß ich, ob ich den Zustand speichern kann?

Um unnötige Anfragen zu vermeiden, solltest du prüfen, ob die H5P-Integration die Speicherung von Zuständen überhaupt zulässt. Leider gibt es bis jetzt nur einen Weg, den man nicht gehen sollte. Der H5P-Kern enthält ein globales H5PIntegration-Objekt, das die Verbindung zwischen dem Server und dem Client darstellt. Es wird verwendet, um eine Reihe von Konfigurationswerten vom Server zum Client zu übertragen. Eine dieser Eigenschaften ist saveFreq, die undefined oder false sein kann, wenn der Server keine Zustände akzeptiert. Oder sie kann eine positive Zahl sein, die das regelmäßige Speicherintervall in Sekunden angibt. Du kannst also etwas wie

const canStoreState = !Number.isNaN(parseInt(H5PIntegration?.saveFreq));

verwenden, um festzustellen, ob der Zustand gespeichert werden kann. Das wird jedoch nicht empfohlen. Das H5PIntegration-Objekt sollte nicht direkt gelesen werden, da sich die Datenstruktur ändern könnte. Andererseits ist eine richtige Lösung noch nicht in Sicht. Irgendwann wird man das Problem wahrscheinlich lösen, indem man der H5P-Instanz im dritten Parameter ihrer Konstruktorfunktion ein Flag übergibt, so wie es für einige andere Werte gemacht wird, aber das ist noch Zukunftsmusik.

Was, wenn mein Inhaltstyp als Unterinhalt läuft?

Unser Inhaltstyp könnte als Hauptinstanz (die root-Instanz) oder als Unterinstanz irgendwo unten im Instanzbaum ausgeführt werden. Das ist relevant, weil der H5P-Kern erwartet, dass er mit der Hauptinstanz kommuniziert, mit der die Inhalts-ID verbunden ist. Er erwartet nicht, dass er den Status von einem Unterinhalt erhält. Wenn das der Fall wäre, würden die Benutzer*innen beim nächsten Start des Inhalts möglicherweise mit einem leeren H5P-Inhalt begrüßt werden – der Zustand, den die Hauptinstanz bei der Wiederaufnahme erhält, entspricht möglicherweise nicht dem Erwarteten. Das heißt, wenn deine Inhalt eine Hauptinstanz ist, kannst du den Rückgabewert deiner eigenen Funktion getCurrentState speichern. Andernfalls musst du sicherstellen, dass du die Funktion getCurrentState der Hauptinstanz aufrufst und deren Rückgabewert übergibst. Was kannst du also tun?

Zunächst einmal erbt jede H5P-Instanz eine Funktion namens isRoot, mit der du feststellen kannst, ob dein Inhalt die Hauptinstanz oder ein Unterinhalt ist. Wenn es sich um die Hauptinstanz handelt, kanst fu getCurrentState für deine Instanz oder für eine  entsprechende Referenz darauf aufrufen, die du an andere Klassen weitergibst. Wenn nicht, müssen wir die Hauptinstanz finden.

Der einfachste Weg ist es, H5P.instances[0] abzurufen. Das Array instances enthält Verweise auf alle H5P-Hauptinstanzen innerhalb des Kontexts des H5P-Objekts. Das wird immer nur eine sein, wenn der H5P-Inhalt einen iframe zum Rendern verwendet. Theoretisch können die Inhaltstypen jedoch auch in einem div gerendert werden. Das heißt, wenn es auf einer Seite mehrere Inhaltstypen gibt, die in einem div gerendert werden, kann die Verwendung von H5P.instances[0] zu einem Verweis auf eine H5P-Instanz führen, die wir nicht suchen. Da alle mir bekannten Inhaltstypen mit Unterinhalten einen iframe verwenden, ist die Wahrscheinlichkeit eines Fehlers hier jedoch sehr gering.

Wenn du wirklich auf Nummer sicher gehen willst, dass das nicht fehlschlägt, dann würdest du die Eigenschaft contentId abrufen, die H5P freundlicherweise in deine r Instanz setzt, und alle Elemente von H5P.instances auf dieselbe contentId hin überprüfen. Man könnte möglicherweise auch die parent-Eigenschaft verwenden, die mit dem dritten Konstruktorargument übergeben wird – aber das würde voraussetzen, dass alle Inhaltstypen mit Unterinhalten diese Eigenschaft setzen, und das tun nicht alle. Es könnte also sein, dass du auf diese Weise nicht an die „Wurzel“ gelangst.

Was ist jetzt diese setUserData function?

Die Funktion setUserData ist Teil einer Reihe von Funktionen (zusammen mit getUserData und deleteUserData), die der H5P-Kern verwendet, um Daten zu verwalten, die mit einem Benutzer verbunden sind, beispielsweise zur Speicherung des Zustands. Die JSDoc-Kommentare sollten die einen Einblick geben, wofür sie verwendet wird. Die Elemente, die für den Zustand relevant sind, sind fett hervorgehoben.

/**
 * @static
 * @param {number} contentId Content id.
 * @param {string} dataId Identifier for context, here 'state'.
 * @param {object} data Data to be stored, here for state.
 * @param {object} [extras={}] Additional parameters.
 * @param {string} [extras.subcontentId] Connect data to particular subcontent.
 * @param {boolean} [extras.preloaded=true] If true, will be loaded with content.
 * @param {boolean} [extras.deleteOnChange=false] It true, delete data if content changes.
 * @param {function} [extras.errorCallback] Error callback with error as parameter.
 * @param {boolean} [extras.async=true] If true, server request is asynchronous.
 */
H5P.setUserData(contentId, dataId, data, extras = {})

Wir können diese Funktion verwenden, um den Zustand so zu speichern, wie H5P ihn erwartet:

  • Wir müssen die aktuelle contentId übergeben, damit H5P weiß, mit welchem Inhalt der Status verknüpft werden soll.
  • Wir müssen dem H5P-Kern eine dataId als Bezeichner für den Zweck der zu speichernden Daten mitteilen. In diesem Fall ist state der richtige Wert.
  • Natürlich müssen wir einige Daten (data) übergeben.
  • Wir könnten einige zusätzliche Optionen übergeben, und extras.deleteOnChange kann nützlich sein. Wenn dieser Wert auf true gesetzt wird, wird der Zustand zurückgesetzt, wenn Autor*innen etwas am Inhalt ändern. Dieses Verhalten scheint auf der Annahme zu beruhen, dass sich eine Frage geändert haben könnte und die Antworten nicht mehr gültig sind – oder dass sich die Datenstruktur des Zustands geändert haben könnte. Der H5P-Kern verwendet dieses Verhalten standardmäßig. Wenn es auf false gesetzt ist, werden Änderungen durch Autor*innen nicht dazu führen, dass der Zustand zurückgesetzt wird, und Benutzer*innen behalten ihre vorherigen Antworten – aber du musst möglicherweise sicherstellen, dass der vorherige Zustand noch valide ist.

Wie könnte eine Lösung aussehen?

Eine Lösung für das aktive Speichern des Status könnte wie das folgende Beispiel aussehen. Es sollte für den produktiven Einsatz geeignet sein, aber du musst es natürlich an deine Bedürfnisse anpassen.

/*
 * Assuming this is part of your main instance class! You would
 * need to adjust references to 'this' if you put the storing
 * function inside another class and work with parameters if you
 * put it into a static service class, for instance.
 */

/**
 * @class
 * @param {object} params Parameters passed by the editor.
 * @param {number} contentId Content's id.
 * @param {object} [extras={}] Saved state, metadata, etc.
 */
constructor(params, contentId, extras = {}) {
  this.canStoreState = !Number.isNaN(parseInt(H5PIntegration?.saveFreq));
  this.stateProvider = this.retrieveStateProvider(); 

  /*
   * Here put whatever you need to do when constructing, in particular
   * check 'extras.previousState' for a previous state that you want
   * to re-create.
   */
}

/**
 * Retrieve state provider to get current state from.
 * @returns {H5P.ContentType|null} Instance to get current state from.
 */
retrieveStateProvider() {
  let stateProvider = this.isRoot() ? this : null;
  if (stateProvider) {
    return stateProvider;
  }
  // Find root instance for our subcontent instance
  const rootInstance = H5P.instances
    .find((instance) => instance.contentId === this.contentId);

  // Check root instance for having support for resume
  if (typeof rootInstance?.getCurrentState === 'function') {
    stateProvider = rootInstance;
  }

  return stateProvider;
}

/**
 * Store current state.
 *
 * Could be amended if required, of course, e.g. add an options
 * argument to control the 'deleteOnChange' value, add a callback
 * function to be passed to H5P.setUserData, etc.
 */
storeH5PState() {
  if (!this.canStoreState) {
    return; // Server does not store state.
  }

  if (!this.stateProvider) {
    return; // No state provider available.
  }

  H5P.setUserData(
    this.contentId, // Set automatically by H5P core
    'state',
    this.stateProvider.getCurrentState(),
    { deleteOnChange: true } // Use default behavior of H5P core
  );    
}

/**
 * Answer call from H5P core for current state.
 *
 * @returns {object} Current state.
 */
getCurrentState() {
  return {}; // Add the state as you need it
}