var NchanMessenger = (() => {
  var __defProp = Object.defineProperty;
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
  var __getOwnPropNames = Object.getOwnPropertyNames;
  var __hasOwnProp = Object.prototype.hasOwnProperty;
  var __export = (target, all) => {
    for (var name in all)
      __defProp(target, name, { get: all[name], enumerable: true });
  };
  var __copyProps = (to, from, except, desc) => {
    if (from && typeof from === "object" || typeof from === "function") {
      for (let key of __getOwnPropNames(from))
        if (!__hasOwnProp.call(to, key) && key !== except)
          __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
    }
    return to;
  };
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

  // node_modules/nchan-messages/js/index.js
  var index_exports = {};
  __export(index_exports, {
    AbortError: () => AbortError,
    ControllerUrlFactory: () => ControllerUrlFactory,
    IncrementalWait: () => IncrementalWait,
    MessageType: () => MessageType,
    Messenger: () => Messenger,
    Request: () => Request,
    Response: () => Response,
    TimeoutError: () => TimeoutError,
    UrlFactory: () => UrlFactory,
    WebSocketError: () => WebSocketError
  });

  // node_modules/nchan-messages/js/async/promise.js
  var TimeoutError = class extends Error {
    get name() {
      return "TimeoutError";
    }
  };
  var AbortError = class extends Error {
    get name() {
      return "AbortError";
    }
  };
  var PromiseStatus = Object.freeze({
    RESOLVED: "resolved",
    REJECTED: "rejected",
    PENDING: "pending"
  });
  var unsettledSymbol = Symbol("unsettled");
  var createPromise = (callback, { signal, timeout } = {}) => new Promise((res, rej) => {
    const tearDown = (reason) => {
      tearDownSignal.abort(reason);
      clearTimeout(timerId);
    };
    const resolve = (value) => {
      tearDown("resolved");
      res(value);
    };
    const reject = (error, reason) => {
      tearDown(reason || "rejected");
      rej(error);
    };
    const tearDownSignal = new AbortController();
    const timerId = timeout | 0 > 0 && setTimeout(() => reject(new TimeoutError(), "timeout"), timeout | 0);
    if (signal instanceof AbortSignal) {
      if (signal.aborted) {
        reject(new AbortError(signal.reason), "abort");
        return;
      } else {
        signal.addEventListener(
          "abort",
          (evt) => reject(new AbortError(evt.target.reason), "abort"),
          { signal: tearDownSignal.signal }
        );
      }
    }
    callback(resolve, reject, tearDownSignal.signal);
  });
  var PromiseManager = class extends Map {
    /**
     * The number of pending promises.
     */
    get pending() {
      return this.size;
    }
    /**
     * Creates a promise with a given signature unless one already exists
     * @param {*} signature 
     * @return {Promise}
     */
    create(signature) {
      const promiseData = this.get(signature);
      if (!promiseData) {
        const descriptor = {};
        descriptor.promise = new Promise((res, rej) => {
          descriptor.resolve = res;
          descriptor.reject = rej;
        });
        this.set(signature, descriptor);
        return descriptor.promise;
      }
      return promiseData.promise;
    }
    /**
     * Resolves and removes a promise with a matching signature
     * @param {*} signature
     * @param {*} value         The value with which the promises are resolved
     * @return {boolean}       True if a promise has been found
     */
    resolve(signature, value) {
      const promise = this.get(signature);
      if (promise) {
        this.delete(signature);
        promise.resolve(value);
        return true;
      }
      return false;
    }
    /**
     * Rejects and removes a promise with a matching signature
     * @param {*} signature
     * @param {*} error         The value with which the promises are rejected
     * @return {boolean}       True if a promise has been found
     */
    reject(signature, error) {
      const promise = this.get(signature);
      if (promise) {
        this.delete(signature);
        promise.reject(error);
      }
      return !!promise;
    }
    /**
     * Rejects all promises and clears the list
     * @param {*} error         The value with which the promises are rejected
     * @return {void}
     */
    rejectAll(error) {
      for (const p of this.values())
        p.reject(error);
      this.clear();
    }
  };

  // node_modules/nchan-messages/js/medium/urlfactory.js
  var flattenChannels = (channels, name) => {
    if (Array.isArray(channels))
      return channels.join(",");
    else if (typeof channels === "string")
      return channels;
    else if (!channels)
      return "";
    else
      throw TypeError(`${name} must be a string or an array of strings`);
  };
  var assertNonEmptyString = (value, name) => {
    if (typeof value !== "string")
      throw TypeError(`${name} must be a string`);
    else if (!value)
      throw TypeError(`${name} cannot be empty`);
  };
  var UrlFactory = class {
    constructor(baseUrl, group, channels = {}) {
      assertNonEmptyString(group, "group");
      const base = new URL(baseUrl || ".", location.href);
      if (!base.pathname.endsWith("/")) {
        base.pathname += "/";
      }
      this.generateBaseUrls(base, group, channels);
    }
    /**
     * 
     * @param {URL} base 
     * @param {string} group 
     * @param {string[]|string} param2.pub
     * @param {string[]|string} param2.sub
     */
    generateBaseUrls(base, group, { pub, sub }) {
      pub = flattenChannels(pub);
      sub = flattenChannels(sub);
      this.restBase = base.href + "rest/";
      this.pubBase = base.href + group + "/pub/";
      base.protocol = "ws" + base.protocol.substring(4);
      this.msgBase = base.href + group + `/room/pub${pub}/sub${sub}/`;
    }
    /**
     * Returns the base URL for the messenger REST service
     * 
     * @returns {string}
     */
    restEndpoint(path) {
      return this.restBase + path;
    }
    /**
     * Returns the publisher endpoint for the given channels
     * 
     * @param {string|string[]} channels 
     * @returns {string}
     */
    publisherEndpoint(channels) {
      channels = flattenChannels(channels, "channels");
      if (!channels) throw TypeError("at least one channel must be provided");
      return this.pubBase + channels;
    }
    /**
     * Returns the messenger endpoint to the given token
     * @param {string} token
     * @returns {string}
     */
    messengerEndpoint(token = "") {
      return this.msgBase + token;
    }
  };
  var ControllerUrlFactory = class extends UrlFactory {
    generateBaseUrls(base, group, { sub }) {
      sub = flattenChannels(sub);
      this.restBase = base.href + "rest/";
      this.pubBase = base.href + group + "/pub/";
      base.protocol = "ws" + base.protocol.substring(4);
      this.msgBase = base.href + group + `/controller/sub${sub}/`;
    }
  };

  // node_modules/nchan-messages/js/async/events.js
  var ChangeEvent = class extends CustomEvent {
    /**
     * Create an event
     * @param {string} type         default: 'change'
     * @param {*} param1            generic event options
     * @param {*} param1.previous   previous value; stored in detail.previous
     * @param {*} param1.current    current value; stored in detail.current
     */
    constructor(type, { previous, current, ...rest }) {
      super(type || "change", { ...rest, detail: { previous, current } });
    }
    /**
     * Returns the current value
     * @return {*}
     */
    get currentValue() {
      return this.detail.current;
    }
    /**
     * Returns the previous value
     * @return {*}
     */
    get previousValue() {
      return this.detail.previous;
    }
  };
  var eventHandlers = /* @__PURE__ */ new WeakMap();
  function defineEvents(classConstructor, ...eventNames) {
    if (!(classConstructor.prototype instanceof EventTarget))
      throw new TypeError("events can be defined only for classes derived from EventTarget");
    Object.defineProperties(classConstructor.prototype, Object.fromEntries(
      eventNames.map((name) => [
        "on" + name.toLowerCase(),
        {
          set(handler) {
            if (typeof handler !== "function" && typeof handler !== "undefined" && handler !== null) {
              return;
            }
            let handlers = eventHandlers.get(this);
            if (!handlers) {
              handlers = /* @__PURE__ */ new Map();
              eventHandlers.set(this, handlers);
            } else if (handlers.has(name)) {
              this.removeEventListener(name, handlers.get(name));
            }
            if (handler) {
              this.addEventListener(name, handler);
              handlers.set(name, handler);
            } else {
              handlers.delete(name);
            }
          },
          get() {
            const handlers = eventHandlers.get(this);
            return handlers && handlers.get(name);
          }
        }
      ])
    ));
  }

  // node_modules/nchan-messages/js/medium/protocol.js
  var MessageType = {
    MESSAGE: "message",
    PROGRESS: "progress",
    REQUEST: "request",
    RESPONSE: "response",
    CONTROL: "ctl"
  };
  var Message = class {
    /**
     * @param {*} payload - the content of the message
     * @param {MessageMetaData} meta - an object with meta data of the message
     */
    constructor(payload, meta) {
      this._meta = meta;
      this._payload = payload;
    }
    /** The source channel of the message */
    get channel() {
      return this._meta.channel;
    }
    /** The name field of the message */
    get name() {
      return this._meta.name;
    }
    /** The publish timestamp of the message */
    get timestamp() {
      return this._meta.timestamp;
    }
    /** The identifier of the message */
    get id() {
      return this._meta.id;
    }
    /** The content of the message */
    get payload() {
      return this._payload;
    }
  };
  var Request = class extends Message {
    /**
     * 
     * @param {Messenger} messenger  - the messenger object that controls the communication
     * @param {*} payload  - the content of the request
     * @param {RequestMetaData} meta - an object with meta data of the request    
     */
    constructor(payload, meta, messenger) {
      super(payload, meta);
      this._messenger = messenger;
      this._done = false;
      this.ondone = null;
    }
    /** The channel on which the requester waits for a response */
    get requester() {
      return this._meta.reply;
    }
    /** The identifier of the request */
    get requestId() {
      return this._meta.reqId;
    }
    /**
     * Sends a progress notification to the requester
     * @param {*} payload - the content of the message
     * @returns {Promise<void>}
     * @throws when a response has been already sent
     */
    async notify(payload) {
      if (this._done) {
        throw new Error("the request is already finilized");
      }
      return this._messenger.sendRaw({
        type: MessageType.PROGRESS,
        reqId: this.requestId,
        payload
      }, [this.requester]);
    }
    /**
     * Sends a response and closes the request
     * @param {*} payload - the content of the response
     * @returns {Promise<void>}
     * @throws when a response has been already sent
     */
    async respond(payload) {
      if (this._done) {
        throw new Error("the request is already finilized");
      }
      this._done = true;
      if (typeof this.ondone === "function") this.ondone();
      return this._messenger.sendRaw({
        type: MessageType.RESPONSE,
        reqId: this.requestId,
        payload
      }, [this.requester]);
    }
  };
  var Response = class extends EventTarget {
    /**
     * @param {Promise<any>} payloadPromise   a promise of the response
     */
    constructor(payloadPromise) {
      super();
      this._closed = false;
      this._payload = payloadPromise;
    }
    /**
     * Resolves to the response content once one is received.
     */
    async payload() {
      return await this._payload;
    }
    /**
     * Returns true if a reponse has been already received
     */
    get closed() {
      return this._closed;
    }
  };
  defineEvents(Response, "progress");
  var ResponseController = class {
    constructor({ signal, timeout }, cleanUpCallback) {
      this._response = new Response(
        createPromise((resolve, reject) => {
          this._resolve = resolve;
          this._reject = reject;
        }, { signal, timeout }).finally(cleanUpCallback)
      );
    }
    get response() {
      return this._response;
    }
    notify(content) {
      this.response.closed || this._response.dispatchEvent(new MessageEvent("progress", { data: content }));
    }
    close(content) {
      if (!this.response.closed) {
        this.response._closed = true;
        this._resolve(content);
      }
      ;
    }
  };

  // node_modules/nchan-messages/js/medium/nchan.js
  var parseMessageId = (msgId) => {
    const [timestamp, counters] = msgId.split(":", 2);
    const result = { timestamp: parseInt(timestamp) };
    if (counters) {
      const parts = counters.split(",");
      if (parts.length === 1) {
        result.channel = 0;
      } else {
        const channelNr = parts.findIndex((ch) => ch.charAt(0) === "[");
        if (channelNr >= 0) result.channel = channelNr;
      }
    }
    return result;
  };
  var parseWebSocketMessage = (msg) => {
    const [rawHeader, payload] = msg.split("\n\n", 2);
    const headers = Object.fromEntries(
      rawHeader.split("\n").map((line) => line.split(": ", 2))
    );
    return {
      headers,
      ...parseMessageId(headers.id),
      payload
    };
  };
  var publish = async (url, payload, { headers = {}, eventName, signal, authorization } = {}) => {
    const options = {
      method: "POST",
      body: payload,
      credentials: "include",
      headers: {
        "Content-Type": "text/json",
        // content type can be overriden
        ...headers,
        "Accept": "text/json"
        // the response must be JSON
      }
    };
    if (signal instanceof AbortSignal) options.signal = signal;
    if (authorization) options.headers["Authorization"] = authorization;
    if (eventName) options.headers["X-EventSource-Event"] = eventName;
    const response = await fetch(url, options);
    if (response.status !== 201 && response.status !== 202) {
      throw Error(`HTTP ${response.status}: access rejected`);
    }
    return await response.json();
  };

  // node_modules/nchan-messages/js/async/queue.js
  var ProcessingQueue = class {
    /**
     * Creates a processing queue
     * @param {QueueItemProcessor} action   A callback function that processes data from the queue.
     *                                      The callback must return true if the item has to be
     *                                      processed again.
     */
    constructor(action) {
      this._paused = false;
      this._processing = false;
      this._promises = new PromiseManager();
      this._items = [];
      this.action = action;
    }
    /**
     * Returns the pause status of the queue
     * @returns {boolean}
     */
    get paused() {
      return this._paused;
    }
    /**
     * Returns true if the queue is processing messages
     * @returns {boolean}
     */
    get active() {
      return this._processing;
    }
    /**
     * Pauses processing the queue
     */
    pause() {
      this._paused = true;
    }
    /**
     * Resumes processing the queue
     */
    resume() {
      this._paused = false;
      this._process();
    }
    /**
     * Returns the number of unprocessed items
     * @returns {number}
     */
    get size() {
      return this._items.length;
    }
    /**
     * Returns true if the queue has no unprocessed items.
     * @returns {boolean}
     */
    get empty() {
      return !this._items.length;
    }
    /**
     * Returns a promise that resolves when the queue has no unprocessed items.
     * @returns {Promise<void>}}
     */
    waitForEmpty() {
      return this._items.length ? this._promises.create("empty") : Promise.resolve();
    }
    /**
     * Returns a promise that resolves when the queue stops processing items.
     * @returns {Promise<void>}}
     */
    async processed() {
      this._processing && await this._promises.create("done");
    }
    /**
     * Puts data on the queue and starts processing unless already processing or paused.
     * @param {*} data 
     */
    put(data) {
      this._items.push(data);
      this._paused || this._process();
    }
    /**
     * Ensures processing messages from the internal queue.
     * 
     * This method creates a task that processes messages asynchronously,
     * allowing other tasks to be process in parallel. Nothing happens
     * if processing is already active.
     * @internal
     */
    async _process() {
      if (this._processing) return;
      this._processing = true;
      let processed = 0, unprocessed = 0;
      while (true) {
        if (processed >= this._items.length) {
          this._items.length = unprocessed;
          break;
        } else if (this._paused || !this._processing) {
          this._items.copyWithin(unprocessed, processed);
          this._items.length -= processed - unprocessed;
          break;
        } else {
          const item = this._items[processed];
          const success = await new Promise(
            (res) => res(this.action(item, this))
          ).catch(() => false);
          if (!success) this._items[unprocessed++] = item;
          processed++;
        }
      }
      this._processing = false;
      this._promises.resolve("done", unprocessed);
      if (this._items.length === 0) this._promises.resolve("empty");
    }
    /**
     * Removes all messages from the internal queue and resets the counter
     * of sent messages.
     */
    clear() {
      this._items.length = 0;
      if (!this._processing) {
        this._promises.resolve("done", 0);
        this._promises.resolve("empty");
      }
    }
  };

  // node_modules/nchan-messages/js/ext/class.js
  var defineConstants = (classConstructor, constants) => Object.defineProperties(
    classConstructor,
    Object.fromEntries(Object.entries(constants).map(
      ([name, value]) => [name, { value, enumerable: true, writable: false }]
    ))
  );

  // node_modules/nchan-messages/js/messenger.js
  var ConnectionState = {
    WAITING: 0,
    CONNECTING: 1,
    CONFIGURING: 2,
    CONNECTED: 3,
    CLOSING: 4,
    CLOSED: 5
  };
  var WebSocketError = class extends Error {
    constructor(message, code) {
      super(message);
      this.code = code;
    }
  };
  var Pinger = class {
    constructor(interval = 30) {
      this.interval = interval;
      this.connections = /* @__PURE__ */ new Set();
      this._timerId = 0;
      this._last = 0;
    }
    get last() {
      return this._last;
    }
    get active() {
      return !!this._timerId;
    }
    get message() {
      return {
        type: "ctl",
        name: "ping",
        timestamp: Date.now()
      };
    }
    start() {
      clearInterval(this._timerId);
      this.interval = Math.max(0, this.interval | 0);
      this._timerId = this.interval && setInterval(() => {
        const msg = JSON.stringify(this.message);
        for (const socket of this.connections) {
          if (socket.readyState === WebSocket.OPEN)
            socket.send(msg);
          else if (socket.readyState !== WebSocket.CONNECTING)
            this.sockets.delete(socket);
        }
      }, this.interval * 1e3);
    }
    stop() {
      clearInterval(this._timerId);
      this._timerId = 0;
    }
  };
  var IncrementalWait = class {
    constructor(options) {
      const {
        max = 3e5,
        stepFactor = 1.5,
        minStep = 1e3,
        maxStep = 3e3
      } = { ...options };
      this.currentInterval = 0;
      this.options = {
        max,
        factor: stepFactor,
        min: minStep,
        delta: maxStep - minStep
      };
      this.timer = 0;
    }
    computeNextInterval() {
      return Math.min(
        this.options.max,
        this.currentInterval * this.options.factor + this.options.min + this.options.delta * Math.random()
      );
    }
    reset() {
      this.tries = 0;
      this.currentInterval = 0;
    }
    async wait() {
      const waitPromise = new Promise((res) => this.timer = setTimeout(res, this.currentInterval));
      this.currentInterval = this.computeNextInterval();
      await waitPromise;
    }
    cancel() {
      clearInterval(this.timer);
    }
  };
  var equalSets = (list1, list2) => {
    const s1 = new Set(list1);
    const s2 = new Set(list2);
    return s1.size === s2.size && s1.isSubsetOf(s2);
  };
  var nullLogger = {
    log() {
    },
    info() {
    },
    warning() {
    },
    error() {
    }
  };
  var OwnChannels = Symbol("all channels");
  var implementsUrlFactory = (obj) => ["restEndpoint", "publisherEndpoint", "messengerEndpoint"].every(
    (prop) => typeof obj[prop] === "function"
  );
  var Messenger = class extends EventTarget {
    /**
     * @param {UrlFactory} urlFactory
     * @param {*} options 
     */
    constructor(urlFactory, options) {
      if (!implementsUrlFactory(urlFactory)) {
        throw new TypeError("the first agument must implement a UrlFactory interface");
      }
      super();
      const self = this;
      this.options = {
        waitOnClose: 500,
        autoReconnect: true,
        pingInterval: 0,
        logger: nullLogger,
        ...options
      };
      this.urlFactory = urlFactory;
      this.tokens = /* @__PURE__ */ (function(self2) {
        const collection = /* @__PURE__ */ new Set();
        let merged = null;
        return {
          add(...tokens) {
            merged = null;
            tokens.forEach((token) => collection.add(token));
          },
          clear() {
            merged = null;
            collection.clear();
          },
          delete(...tokens) {
            merged = null;
            tokens.forEach((token) => collection.delete(token));
          },
          has(token) {
            return collection.has(token);
          },
          get size() {
            return collection.size;
          },
          async merged({ signal } = {}) {
            if (!merged) {
              if (this.size < 2) {
                merged = collection.values().next().value;
              } else {
                const url = self2.urlFactory.restEndpoint("token/merge");
                const response = await fetch(url, {
                  method: "POST",
                  signal,
                  body: JSON.stringify({ tokens: [...collection] })
                }).then((data) => data.json());
                if (response.success) {
                  merged = response.data;
                } else {
                  throw Error(`Failed to generate an access token: ${response.reason}`);
                }
              }
            }
            return merged;
          }
        };
      })(this);
      this._channels = {
        sub: null,
        pub: null
      };
      this.channels = {
        get pub() {
          return self._channels.pub || [];
        },
        get sub() {
          return self._channels.sub || [];
        }
      };
      this._clientId = null;
      this._backend = null;
      this._promises = new PromiseManager();
      this._queue = new ProcessingQueue((msg) => {
        this._backend.send(msg);
        return true;
      });
      this._queue.pause();
      this.lastExitCode = 0;
      this._state = ConnectionState.CLOSED;
      this.reconnectController = this.options.autoReconnect ? new IncrementalWait() : null;
      this.pingSender = new Pinger(this.options.pingInterval | 0);
      this._subscribers = /* @__PURE__ */ new Map();
      this._subscribers.set(OwnChannels, /* @__PURE__ */ new Set());
      this._requests = /* @__PURE__ */ new Map();
      this._received = [];
      this.lastMessageTimestamp = 0;
      this.lastRequestId = 0;
    }
    get logger() {
      return this.options.logger;
    }
    get clientId() {
      return this._clientId;
    }
    get ownSubChannel() {
      return this._channels.sub[2] || this._channels.sub[0];
    }
    get state() {
      return this._state;
    }
    set state(value) {
      if (this._state === value) return;
      const previous = this._state;
      this._state = value;
      this._promises.resolve("state", value);
      this.dispatchEvent(new ChangeEvent("statechange", { previous, current: value }));
    }
    get desiredState() {
      return this._state < ConnectionState.CLOSING ? ConnectionState.CONNECTED : ConnectionState.CLOSED;
    }
    get isReady() {
      return this._state === ConnectionState.CONNECTED;
    }
    get reconnectionTime() {
      return this.reconnectController ? this.reconnectController.currentInterval : 0;
    }
    async waitForConnectionState(desiredState, options) {
      if (desiredState === this._state) return;
      await createPromise((res, rej, tearDown) => this.addEventListener(
        "statechange",
        () => this._state === desiredState && res(),
        { signal: tearDown }
      ), options);
    }
    async connected(options) {
      await Promise.race([
        this.waitForConnectionState(ConnectionState.CONFIGURING, options),
        this.waitForConnectionState(ConnectionState.CONNECTED, options)
      ]);
    }
    async opened(options) {
      await this.waitForConnectionState(ConnectionState.CONNECTED, options);
    }
    async closed(options) {
      await this.waitForConnectionState(ConnectionState.CLOSED, options);
    }
    async open() {
      if (this.state === ConnectionState.CONNECTED) return;
      const openedPromise = this._promises.create("opened");
      switch (this.state) {
        case ConnectionState.CLOSING:
          this._backend.onerror = null;
          this._backend.onclose = null;
          this._promises.reject("closed", new AbortError("Requested to reconnect"));
        // fallthrough
        case ConnectionState.WAITING:
          this.reconnectController && this.reconnectController.cancel();
        // fallthrough
        case ConnectionState.CLOSED:
          this.state = ConnectionState.CONNECTING;
          const abortController = new AbortController();
          openedPromise.then(
            () => abortController.abort(),
            () => abortController.abort()
          );
          try {
            const token = await this.tokens.merged({ signal: abortController.signal });
            const url = this.urlFactory.messengerEndpoint(token);
            this._backend = new WebSocket(url, "ws+meta.nchan");
            this._backend.onopen = this.handleSocketOpen.bind(this);
            this._backend.onclose = this.handleSocketClose.bind(this);
            this._backend.onerror = this.handleSocketError.bind(this);
            this._backend.onmessage = this.handleSocketMessage.bind(this);
          } catch (error) {
            if (!abortController.signal.aborted) {
              throw error;
            }
          }
      }
      await openedPromise;
    }
    async close(discardUnsent = false) {
      switch (this.state) {
        case ConnectionState.CONNECTING:
        // connecting - closing triggers an error, so be careful!
        case ConnectionState.CONFIGURING:
          if (!this._backend) {
            this._promises.reject("opened", new AbortError("Requested to disconnect"));
            this.state = ConnectionState.CLOSED;
            this.lastExitCode = 1e3;
          } else {
            this._backend.onerror = null;
            this._backend.onopen = null;
            this._promises.reject("opened", new AbortError("Requested to disconnect"));
            this.state = ConnectionState.CLOSING;
            this._backend.close(1e3);
            await this._promises.create("closed");
          }
          break;
        case ConnectionState.CONNECTED:
          this.state = ConnectionState.CLOSING;
          if ((this._backend.bufferedAmount || !this._queue.empty) && !discardUnsent) {
            this.logger.warning("waiting for queued data to be sent");
            setTimeout(this.close.bind(this, false), this.options.waitOnClose);
          } else {
            this.logger.info("closing connection");
            this._backend.close(1e3);
          }
        // fallthrough
        case ConnectionState.CLOSING:
          await this._promises.create("closed");
          break;
        case ConnectionState.WAITING:
          this.reconnectController && this.reconnectController.cancel();
          this._promises.reject("opened", new AbortError("Requested to disconnect"));
          this.state = ConnectionState.CLOSED;
          break;
      }
      return this.lastExitCode;
    }
    /**
     * Initiates the web socket connection and processes the message queue.
     * 
     * It updates the connection state to CONNECTED, adds the socket to
     * a ping sender if any, resolves the promise labeled by CONNECTED
     * and fires the connect event. The message queue is processed after.
     * 
     * @param {Event} evt
     */
    handleSocketOpen(evt) {
      this.logger.info(`[WebSocket]: onconnected(${this._backend.url})`);
      this.state = ConnectionState.CONFIGURING;
      this.reconnectController && this.reconnectController.reset();
      this.pingSender.connections.add(this._backend);
      this._promises.create("opened").then(
        () => this.dispatchEvent(new evt.constructor(evt.type, evt)),
        () => false
      );
      this._queue.resume();
      this.pingSender.start();
    }
    /**
     * Updates the connection state, removes the socket from a ping sender
     * if any, resolves or rejects the last promise and fires the disconnect
     * event.
     * 
     * The method is invoked in the following situations:
     *   CONNECTING:    the attempt to connect the web socket has failed
     *   CONNECTED:     the connection as been closed due to an error
     *   DISCONNECTING: the connection has been closed by calling disconnect()
     * 
     * @param {CloseEvent} evt    The event object passed to the handler of
     *                            the onclose event
     */
    async handleSocketClose(evt) {
      this.logger.info(`[WebSocket]: onclose(${this._backend.url}):`, evt.code);
      this._queue.pause();
      this.pingSender.stop();
      this.pingSender.connections.delete(this._backend);
      this.lastExitCode = evt.code;
      const previousState = this.state;
      const desiredState = this.desiredState;
      this.state = ConnectionState.CLOSED;
      switch (previousState) {
        case ConnectionState.CONNECTING:
          this._promises.reject("opened", new WebSocketError("Failed to open connection", evt.code));
          break;
        case ConnectionState.CLOSING:
          this._promises.resolve("closed", evt.code);
          break;
      }
      if (desiredState === ConnectionState.CONNECTED && this.reconnectController && ![1e3, 1001, 1005, 1010].includes(evt.code)) {
        try {
          this.dispatchEvent(new evt.constructor(evt.type, evt));
          this.state = ConnectionState.WAITING;
          await this.reconnectController.wait();
          this.open().catch(() => {
          });
        } catch (error) {
          this.logger.error("while reconnecting", error);
          this._promises.reject("open", error);
          this.state = ConnectionState.CLOSED;
        }
      } else {
        this.dispatchEvent(new evt.constructor(evt.type, evt));
      }
    }
    /**
     * A handler for the onerror event.
     * 
     * @param {ErrorEvent} evt    The event object passed to the handler
     *                            of the onerror event
     */
    handleSocketError(evt) {
      this.logger.error(evt.message, evt);
      this.dispatchEvent(new evt.constructor(evt.type, evt));
    }
    getSubscribers(channel) {
      const direct = this._subscribers.get(channel) || /* @__PURE__ */ new Set();
      const all = this._subscribers.get(OwnChannels) || /* @__PURE__ */ new Set();
      return /* @__PURE__ */ new Set([...direct, ...all]);
    }
    /**
     * A handler for the onmessage event.
     * @param {MessageEvent} evt     The event object passed to the handler
     *                               of the onmessage event.
     */
    handleSocketMessage(evt) {
      this.logger.info(`[${this.clientId}] message
`, evt.data);
      try {
        const parsed = parseWebSocketMessage(evt.data);
        parsed.payload = JSON.parse(parsed.payload);
        if (parsed.timestamp < this.lastMessageTimestamp) {
          return;
        } else if (parsed.payload.type === "ctl") {
          this.processControllMessage(parsed);
        } else if (this._channels.sub) {
          this.processMessage(parsed);
        } else {
          this._received.push(parsed);
        }
        this.lastMessageTimestamp = parsed.timestamp;
      } catch (err) {
        this.logger.error(`[${this.clientId}] message error`, err);
      }
    }
    processControllMessage(message) {
      const { name, payload } = message.payload;
      switch (name) {
        case "hello":
          this._clientId = payload.clientId;
          this._channels.pub = payload.pub || [];
          this._channels.sub = payload.sub || [];
          this.logger.debug(`[${this.clientId}] connected to group ${payload.group}.
Listens to ${this._channels.sub.join(",")}.
Publishes to ${this._channels.pub.join(",")}.`);
          if (this.state !== ConnectionState.CONNECTED) {
            this.logger.info(`[WebSocket]: onopen(${this._backend.url})`);
            this.state = ConnectionState.CONNECTED;
            this._promises.resolve("opened");
          }
          const received = [...this._received];
          this._received.length = 0;
          received.forEach(this.processMessage.bind(this));
          break;
      }
    }
    processMessage({ payload, ...message }) {
      const { type, name, reqId, reply, payload: content } = payload;
      message.channel = this._channels.sub[message.channel];
      switch (type) {
        case "message":
          const msgSubscribers = this.getSubscribers(message.channel);
          if (msgSubscribers.size) {
            Object.assign(message, { name });
            const msg = new Message(content, message);
            for (const subscriber of msgSubscribers) {
              if (typeof subscriber.handleMessage === "function")
                subscriber.handleMessage(msg);
            }
          }
          break;
        case "request":
          const reqSubscribers = this.getSubscribers(message.channel);
          if (!this._requests.has(reqId) && reqSubscribers.size) {
            Object.assign(message, { name, reqId, reply });
            const request = new Request(content, message, this);
            request.ondone = () => this._requests.delete(reqId);
            this._requests.set(reqId, request);
            for (const subscriber of reqSubscribers) {
              if (typeof subscriber.handleRequest === "function")
                subscriber.handleRequest(request);
            }
          }
          break;
        case "progress":
          const ctl = this._requests.get(reqId);
          this.logger.info(`[${this.clientId}] progress: `, ctl ? "requested" : "not requested");
          ctl && ctl.notify(content);
          break;
        case "response":
          const req = this._requests.get(reqId);
          this.logger.info(`[${this.clientId}] response: `, req ? "requested" : "not requested");
          req && req.close(content);
          break;
      }
      this.lastMessageTimestamp = message.timestamp;
    }
    subscribe(subscriber, channels) {
      if (channels) {
        for (const channel of channels) {
          this._subscribers.has(channel) || this._subscribers.set(channel, /* @__PURE__ */ new Set());
          this._subscribers.get(channel).add(subscriber);
        }
      } else {
        this._subscribers.get(OwnChannels).add(subscriber);
      }
    }
    unsubscribe(subscriber, channels) {
      if (channels) {
        channels.forEach((channel) => {
          const subscribers = this._subscribers.get(channel);
          subscribers && subscribers.delete(subscriber);
        });
      } else {
        this._subscribers.get(OwnChannels).delete(subscriber);
      }
    }
    /**
     * Sends the message when connected and puts it on an internal queue otherwise.
     * @param {string|object} msg   The message to be sent
     */
    async sendRaw(msg, channels = null) {
      msg = JSON.stringify(msg);
      if (!channels || equalSets(channels, this._channels.pub)) {
        this._queue.put(msg);
      } else {
        const url = this.urlFactory.publisherEndpoint(channels);
        const token = await this.tokens.merged();
        await publish(url, msg, { authorization: token }).catch(
          (err) => {
            this.logger.error(`Failed to publish a message on ${channels} with token ${token}`, err);
            throw err;
          }
        );
      }
    }
    async publish(name, payload, { channels = null } = {}) {
      await this.sendRaw({ type: "message", name, payload }, channels);
    }
    async request(name, payload, { channels = null, signal, timeout } = {}) {
      const reqId = `${this.clientId}:${++this.lastRequestId}`;
      const reply = this.ownSubChannel;
      if (!reply) {
        throw new Error("at least one subscription channel is required to receive a response");
      }
      const ctl = new ResponseController({
        signal,
        timeout
      }, () => this._requests.delete(reqId));
      this._requests.set(reqId, ctl);
      await this.sendRaw({ type: "request", reqId, reply, name, payload }, channels);
      return ctl.response;
    }
    async drain(options) {
      await this._queue.waitForEmpty(options);
    }
  };
  defineConstants(Messenger, ConnectionState);
  defineEvents(Messenger, "open", "close", "statechange");
  return __toCommonJS(index_exports);
})();

/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global EventEmitter */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     *
     * @type {{makePayload: Function, onMixin: Function}}
     *
     */
    n.EventEmitter = {
        makePayload: function (target, data, additionalArgs) {
            return [$.extend({},
                typeof additionalArgs === "object" ? additionalArgs : null,
                {
                    target: target,
                    data: data
                }
            )];
        },
        onMixin: function (event, func) {
            this.eventEmitter.addListener(event, func);
        }
    };

})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq.Element Namespace
 *
 * @namespace QfqNS.Element
 */
QfqNS.Element = QfqNS.Element || {};

(function (n) {
    'use strict';

    /**
     * Form Group represents a `<input>/<select>` element including the label and help block.
     *
     * It is not meant to be used directly. Use the specialized objects instead.
     *
     * The parameter `elementSelector` seems to be redundant, but `$enclosedElement` can be any element enclosed in
     * the form group, thus we need a way to identify the actual form element(s).
     *
     * @param $enclosedElement {jQuery} a jQuery object contained in the Form Group. It used to find the enclosing
     * HTML element having the `.form-group` class assigned.
     *
     * @param elementSelector {string} jQuery selector selecting the form element. It is used to find all elements
     * contained in the form group identified by $enclosedElement.
     *
     * @constructor
     * @name QfqNS.Element.FormGroup
     */
    n.FormGroup = function ($enclosedElement, elementSelector) {
        elementSelector = elementSelector || 'input:not([type="hidden"])';
        if (!$enclosedElement || $enclosedElement.length === 0) {
            throw new Error("No enclosed element");
        }

        this.$formGroup = this.$findFormGroup($enclosedElement);
        this.$element = this.$formGroup.find(elementSelector);
        this.$label = this.$formGroup.find('.control-label');
        this.$helpBlock = this.$formGroup.find(".help-block");
    };

    /**
     * Test if the Form Group is of the given type
     *
     * @param {string} type type name
     * @returns {boolean} true if the Form Group is of the given type. False otherwise
     * @protected
     */
    n.FormGroup.prototype.isType = function (type) {
        var lowerCaseType = type.toLowerCase();
        var isOfType = false;
        this.$element.each(function () {
            if (this.hasAttribute('type')) {
                if (this.getAttribute('type') === lowerCaseType) {
                    isOfType = true;
                    return true;
                } else {
                    isOfType = false;
                    return false;
                }
            } else {
                // <select> is not an attribute value, obviously, so check for nodename
                if (this.nodeName.toLowerCase() === lowerCaseType) {
                    isOfType = true;
                    return true;
                } else if (lowerCaseType === 'text') {
                    isOfType = true;
                    return true;
                } else {
                    isOfType = false;
                    return false;
                }
            }
        });

        return isOfType;
    };

    /**
     *
     * @param $enclosedElement
     * @returns {*}
     *
     * @private
     */
    n.FormGroup.prototype.$findFormGroup = function ($enclosedElement) {
        var idArray = $enclosedElement.attr('id').split("-");
        var searchString = "#";
        for(var i = 0; i < 8 && i < idArray.length; i++) {
            // Handle form-group for chat input element
            if (idArray[i] === 'chat') {
                continue;
            }
            searchString += idArray[i] + "-";
        }

        var $formGroup = $(searchString + 'i');

        if (!$formGroup || $formGroup.length === 0) {
            $formGroup = $(searchString.replace(/-0-?$/, '-') + 'i')
            if (!$formGroup) {
                console.log("Unable to find Form Group for", $enclosedElement);
                console.log("trying with: " + '#' + $enclosedElement.attr('id') + '-i');
                throw new Error("Unable to find Form Group for " + $enclosedElement.attr('id') + '-i');
            }
        }

        if ($formGroup.length > 1) {
            $formGroup = $(searchString + 'i');
            console.log("Enclosed Element Id: " + $enclosedElement.attr('id'));
            if ($formGroup.length !== 1) {
                throw new Error("enclosed element yields ambiguous form group");
            }
        }
        return $formGroup;
    };

    /**
     * @public
     * @returns {boolean}
     */
    n.FormGroup.prototype.hasLabel = function () {
        return this.$label.length > 0;
    };

    /**
     * @public
     * @returns {boolean}
     */
    n.FormGroup.prototype.hasHelpBlock = function () {
        return this.$helpBlock.length > 0;
    };

    /**
     * @deprecated
     *
     * Read-only is mapped onto setEnabled(). We do not distinguish between those two.
     *
     * @param readonly
     * @public
     */
    n.FormGroup.prototype.setReadOnly = function (readonly) {
        this.setEnabled(!readonly);
    };

    /**
     * @public
     * @param enabled
     */
    n.FormGroup.prototype.setEnabled = function (enabled) {
        this.$element.prop('disabled', !enabled);

        if (enabled) {
            //this.$formGroup.removeClass("text-muted");
            //this.$label.removeClass("disabled");
            this.$element.parents("div.radio").removeClass("disabled");
        } else {
            //this.$formGroup.addClass("text-muted");
            //this.$label.addClass("disabled");
            this.$element.parents("div.radio").addClass("disabled");
        }
    };

    /**
     * @public
     * @param hidden
     */
    n.FormGroup.prototype.setHidden = function (hidden) {
        if (hidden) {
            this.$formGroup.addClass("hidden");
        } else {
            this.$formGroup.removeClass("hidden");
        }
    };

    /**
     * @public
     * @param required
     */
    n.FormGroup.prototype.setRequired = function (required) {
        if(this.$element.is('div')) {
            if (!!this.$element.find('input')) {
                console.log("children found");
                this.$element.find('input').prop('required', required);
            }
        } else {
            this.$element.prop('required', required);
        }
    };

    /**
     * @public
     * @param isError
     */
    n.FormGroup.prototype.setError = function (isError) {
        if (isError) {
            this.$formGroup.addClass("has-error has-danger");
        } else {
            this.$formGroup.removeClass("has-error has-danger");
        }
    };

    n.FormGroup.prototype.setHelp = function (help) {
        if (!this.hasHelpBlock()) {
            return;
        }

        this.$helpBlock.text(help);
    };

    n.FormGroup.prototype.clearHelp = function () {
        if (!this.hasHelpBlock()) {
            return;
        }

        this.$helpBlock.empty();
    };

})(QfqNS.Element);
/**
 * QFQ AI assist: 'improve / generate / translate' actions on text inputs,
 * textareas and TinyMCE editors (FormElement parameter 'aiAssist').
 *
 * Plain inputs/textareas get a small button group rendered next to the element
 * (install()). TinyMCE gets one 'AI' dropdown registered in Helper/tinyMCE.js
 * which calls tinyMceAction().
 *
 * Server side counterpart: Classes/Api/aiHandler.php. The action is streamed over SSE
 * (_ai_stream=1) so a running request can be cancelled for real (the Stop indicator, or
 * closing the tab, aborts the GPU); the result is applied to the field/editor on completion.
 * All security relevant settings (allowed actions, prompt, context SQL, model, offered
 * translate targets) live behind the SIP created at form render time - the client only sends
 * sip, action, target language (validated server side) and the user input.
 */

/* global $ */
/* global fetch, AbortController, TextDecoder */

var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    n.AiAssist = {};

    n.AiAssist.modeConfig = {
        improve: {
            menuLabel: 'Improve',
            tooltip: 'Improve spelling, grammar and style',
            needsInstruction: false
        },
        generate: {
            menuLabel: 'Generate',
            tooltip: 'Generate text from an instruction',
            needsInstruction: true
        },
        translate: {
            menuLabel: 'Translate',
            tooltip: 'Translate text',
            needsInstruction: false
        }
    };

    /**
     * Short display label for a target language ('German' -> 'DE'). Falls back
     * to the name itself for languages without a known short form.
     */
    n.AiAssist.languageLabel = function (language) {
        var known = {
            english: 'EN',
            german: 'DE',
            french: 'FR',
            italian: 'IT',
            spanish: 'ES'
        };
        return known[String(language).toLowerCase()] || language;
    };

    /**
     * Bind the items of the server rendered 'AI' dropdowns (extra button area,
     * see HelperFormElement::prepareExtraButton). One delegated handler covers
     * everything, including dynamically added elements (templateGroup, dynamic
     * update). TinyMCE elements get their actions via the editor toolbar instead
     * (tinyMCE.js).
     */
    n.AiAssist.install = function () {
        if (n.AiAssist.menuHandlerInstalled) {
            return;
        }
        n.AiAssist.menuHandlerInstalled = true;

        $(document).on('click', '.qfq-ai-dropdown a[data-ai-mode]', function (event) {
            event.preventDefault();
            var $item = $(this);
            var element = document.getElementById(String($item.data('ai-target')));
            if (!element) {
                return;
            }
            var $button = $item.closest('.qfq-ai-dropdown').find('button');
            n.AiAssist.elementAction($(element), String($item.data('ai-mode')),
                String($item.data('ai-language') || ''), $button);
        });

        // The button wrappers live in low stacking contexts (.textarea-button-wrapper
        // has z-index 1, Bootstrap gives following .form-control z-index 2) - an open
        // menu would render below the next form elements. Lift the wrappers while the
        // menu is open.
        $(document).on('shown.bs.dropdown', '.qfq-ai-dropdown', function () {
            $(this).closest('.textarea-button-wrapper, .input-group-btn')
                .add($(this).closest('.textarea-extraButton-wrapper, .input-group'))
                .addClass('qfq-ai-open');
        });
        $(document).on('hidden.bs.dropdown', '.qfq-ai-dropdown', function () {
            $(this).closest('.textarea-button-wrapper, .input-group-btn')
                .add($(this).closest('.textarea-extraButton-wrapper, .input-group'))
                .removeClass('qfq-ai-open');
        });
    };

    /**
     * Dropdown item handler for plain input/textarea elements.
     */
    n.AiAssist.elementAction = function ($element, mode, targetLanguage, $button) {
        // collectInput is async: 'generate' opens an instruction bubble below the
        // AI button (openInstructionBubble); other modes resolve immediately.
        n.AiAssist.collectInput(mode, $element.val(), $button).then(function (input) {
            if (input === null) {
                return;
            }

            // 'generate' passes the current field value as reference material the
            // instruction can work with (e.g. keywords to turn into prose).
            var reference = (mode === 'generate') ? String($element.val()).trim() : '';

            // Busy state: spinner on the button, field locked, and a reasoning box (timer + Stop)
            // shown below the field. The request streams, so Stop cancels for real
            var ctrl = new AbortController();
            // Anchor the box below the whole field block so adding it does not disturb the AI /
            // extra buttons: an inline input wraps field + buttons in .input-group; a textarea
            // wraps them in .textarea-extraButton-wrapper (the buttons sit absolutely inside it).
            // Insert after that container; fall back to the element if neither wrapper is present.
            var $fieldWrap = $element.closest('.textarea-extraButton-wrapper, .input-group');
            var thinking = (String($element.data('ai-thinking')) === '1');
            var $busy = n.AiAssist.showBusy($fieldWrap.length ? $fieldWrap : $element, ctrl,
                thinking ? 'Thinking…' : 'Generating…', thinking);
            var buttonHtml = $button.html();
            $button.prop('disabled', true)
                .html('<span class="glyphicon glyphicon-refresh glyphicon-spin"></span>');
            $element.prop('readonly', true).addClass('qfq-ai-busy');

            n.AiAssist.request($element.data('ai-url'), $element.data('ai-sip'), mode, input, targetLanguage, reference, 'text', ctrl.signal,
                function (text) {
                    n.AiChat.appendReasoningText($busy, text);
                },
                function (count) {
                    n.AiChat.setReasoningTokens($busy, count);
                })
                .then(function (content) {
                    $element.val(content);
                    // Notify the QFQ form handling (dirty state, save button).
                    $element.trigger('change');
                })
                .catch(function (error) {
                    if (!(error && error.name === 'AbortError')) {
                        n.AiAssist.showError(error);
                    }
                })
                .then(function () {
                    $busy.remove();
                    $button.prop('disabled', false).html(buttonHtml);
                    $element.prop('readonly', false).removeClass('qfq-ai-busy');
                });
        });
    };

    /**
     * TinyMCE handler (called from the 'AI' toolbar dropdown). Works on the
     * selection if present, else on the whole editor content.
     */
    n.AiAssist.tinyMceAction = function (editor, url, sip, mode, targetLanguage) {
        var selection = editor.selection.getContent({format: 'html'});
        var useSelection = selection !== '';
        var current = useSelection ? selection : editor.getContent();

        // Anchor the instruction bubble (generate) to the editor container, near
        // its top where the AI toolbar button sits ('top' placement).
        var $anchor = $(editor.getContainer());
        n.AiAssist.collectInput(mode, current, $anchor, 'top').then(function (input) {
            if (input === null) {
                return;
            }

            // 'generate' passes the selection resp. editor content as reference
            // material the instruction can work with. The answer replaces exactly
            // that scope (selection or whole content).
            var reference = (mode === 'generate') ? current : '';

            var ctrl = new AbortController();
            var thinking = (String($(editor.getElement()).data('ai-thinking')) === '1');
            var $busy = n.AiAssist.showBusy($anchor, ctrl, thinking ? 'Thinking…' : 'Generating…', thinking);
            editor.setProgressState(true);

            n.AiAssist.request(url, sip, mode, input, targetLanguage, reference, 'html', ctrl.signal,
                function (text) {
                    n.AiChat.appendReasoningText($busy, text);
                },
                function (count) {
                    n.AiChat.setReasoningTokens($busy, count);
                })
                .then(function (content) {
                    if (useSelection) {
                        editor.selection.setContent(content);
                    } else {
                        editor.setContent(content);
                    }
                    editor.undoManager.add();
                    // Notify the QFQ form handling (dirty state, save button).
                    editor.fire('change');
                })
                .catch(function (error) {
                    if (!(error && error.name === 'AbortError')) {
                        n.AiAssist.showError(error);
                    }
                })
                .then(function () {
                    $busy.remove();
                    editor.setProgressState(false);
                });
        });
    };

    /**
     * Determine the text sent to the server. 'generate' asks for an instruction
     * via an animated bubble below the AI button (openInstructionBubble); the other
     * modes require existing text. Returns a Promise resolving with the text, or
     * null to abort.
     */
    n.AiAssist.collectInput = function (mode, currentValue, $anchor, placement) {
        if (n.AiAssist.modeConfig[mode].needsInstruction) {
            return n.AiAssist.openInstructionBubble($anchor, placement);
        }

        if (String(currentValue).trim() === '') {
            n.AiAssist.showError('Please enter a text first.');
            return Promise.resolve(null);
        }
        return Promise.resolve(currentValue);
    };

    /**
     * Animated instruction bubble shown below the AI button (replaces the native
     * window.prompt). Resolves with the entered instruction, or null if the user
     * cancels (Esc, Cancel button, click outside). Only one bubble exists at a time.
     *
     * @param {jQuery} $anchor  element the bubble is positioned against (the AI
     *                 button, or the TinyMCE container with placement 'top').
     * @param {string} [placement]  'top' positions inside the anchor's top area
     *                 (large anchors); default positions directly below the anchor.
     * @returns {Promise<?string>}
     */
    n.AiAssist.openInstructionBubble = function ($anchor, placement) {
        return new Promise(function (resolve) {
            n.AiAssist.closeBubble();

            var $bubble = $(
                '<div class="qfq-ai-bubble" role="dialog" aria-label="Generate text">' +
                    '<span class="qfq-ai-bubble-arrow"></span>' +
                    '<label class="qfq-ai-bubble-label">Describe the text to generate</label>' +
                    '<textarea class="qfq-ai-bubble-input form-control" rows="3" ' +
                        'placeholder="e.g. a friendly two-sentence welcome message"></textarea>' +
                    '<div class="qfq-ai-bubble-actions">' +
                        '<button type="button" class="btn btn-default btn-sm qfq-ai-bubble-cancel">Cancel</button>' +
                        '<button type="button" class="btn btn-info btn-sm qfq-ai-bubble-submit">Generate</button>' +
                    '</div>' +
                '</div>'
            );
            if (placement === 'top') {
                $bubble.addClass('qfq-ai-bubble-noarrow');
            }

            $('body').append($bubble);
            n.AiAssist.$bubble = $bubble;
            n.AiAssist.positionBubble($bubble, $anchor, placement);

            // Animate in on the next frame so the CSS transition actually runs.
            window.requestAnimationFrame(function () {
                $bubble.addClass('qfq-ai-bubble-open');
            });

            var $input = $bubble.find('.qfq-ai-bubble-input');
            $input.trigger('focus');

            var settled = false;
            function finish(value) {
                if (settled) {
                    return;
                }
                settled = true;
                $(document).off('.qfqAiBubble');
                $(window).off('.qfqAiBubble');
                $bubble.removeClass('qfq-ai-bubble-open');
                window.setTimeout(function () {
                    $bubble.remove();
                    if (n.AiAssist.$bubble === $bubble) {
                        n.AiAssist.$bubble = null;
                    }
                }, 150);
                resolve(value);
            }

            function submit() {
                var text = String($input.val()).trim();
                if (text === '') {
                    $input.trigger('focus');
                    return;
                }
                finish(text);
            }

            $bubble.find('.qfq-ai-bubble-submit').on('click', submit);
            $bubble.find('.qfq-ai-bubble-cancel').on('click', function () {
                finish(null);
            });

            // Enter submits, Shift+Enter inserts a newline, Esc cancels.
            $input.on('keydown', function (event) {
                if (event.key === 'Enter' && !event.shiftKey) {
                    event.preventDefault();
                    submit();
                } else if (event.key === 'Escape') {
                    event.preventDefault();
                    finish(null);
                }
            });

            // Click outside closes (cancel). Bind after the current click finished
            // so the dropdown item click that opened us does not close it instantly.
            window.setTimeout(function () {
                $(document).on('mousedown.qfqAiBubble', function (event) {
                    if (!$bubble[0].contains(event.target)) {
                        finish(null);
                    }
                });
            }, 0);

            // Keep the bubble glued to the anchor while scrolling/resizing.
            $(window).on('scroll.qfqAiBubble resize.qfqAiBubble', function () {
                n.AiAssist.positionBubble($bubble, $anchor, placement);
            });
        });
    };

    /**
     * Position the (body-appended) bubble against the anchor. Default: directly
     * below the anchor, right edges aligned (matches the dropdown-menu-right AI
     * button). 'top': inside the anchor's top-left (for the TinyMCE container).
     */
    n.AiAssist.positionBubble = function ($bubble, $anchor, placement) {
        var anchor = ($anchor && $anchor.length) ? $anchor[0] : null;
        if (!anchor) {
            return;
        }
        var rect = anchor.getBoundingClientRect();
        var width = $bubble.outerWidth();
        var top, left;

        if (placement === 'top') {
            top = rect.top + window.pageYOffset + 44;   // ~ below the editor toolbar
            left = rect.left + window.pageXOffset + 8;
        } else {
            top = rect.bottom + window.pageYOffset + 6; // 6px gap below the button
            left = rect.right + window.pageXOffset - width;
        }

        // Keep the bubble within the viewport horizontally.
        var minLeft = window.pageXOffset + 8;
        var maxLeft = window.pageXOffset + document.documentElement.clientWidth - width - 8;
        if (left > maxLeft) {
            left = maxLeft;
        }
        if (left < minLeft) {
            left = minLeft;
        }

        $bubble.css({top: top + 'px', left: left + 'px'});

        // Point the arrow at the anchor's horizontal centre (default placement).
        if (placement !== 'top') {
            var arrowLeft = rect.left + window.pageXOffset + (rect.width / 2) - left - 6;
            arrowLeft = Math.max(10, Math.min(width - 22, arrowLeft));
            $bubble.find('.qfq-ai-bubble-arrow').css('left', arrowLeft + 'px');
        }
    };

    /**
     * Remove any open instruction bubble immediately (no animation).
     */
    n.AiAssist.closeBubble = function () {
        if (n.AiAssist.$bubble) {
            $(document).off('.qfqAiBubble');
            $(window).off('.qfqAiBubble');
            n.AiAssist.$bubble.remove();
            n.AiAssist.$bubble = null;
        }
    };

    /**
     * Stream the AI action from Api/aiHandler.php over SSE (_ai_stream=1), so it can be
     * cancelled for real via the AbortSignal or by closing the tab. Resolves with the final
     * generated text on the 'done' frame; rejects on an 'error' frame, an abort, or a transport
     * error. 'reasoning' deltas (thinking models) -> onReasoning(text); 'tokens' (cumulative
     * usage) -> onTokens(count). Both callbacks are optional.
     */
    n.AiAssist.request = function (url, sip, mode, input, targetLanguage, reference, format, signal, onReasoning, onTokens) {
        var data = {sip: sip, _ai_mode: mode, _ai_input: input, _ai_stream: '1'};
        if (targetLanguage) {
            data._ai_target_language = targetLanguage;
        }
        if (reference) {
            data._ai_reference = reference;
        }
        if (format) {
            data._ai_format = format;
        }
        return fetch(url, {
            method: 'POST',
            credentials: 'same-origin',
            headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
            body: $.param(data),
            signal: signal
        }).then(function (res) {
            if (!res.ok || !res.body) {
                throw new Error('AI request failed (HTTP ' + res.status + ').');
            }
            var reader = res.body.getReader();
            var decoder = new TextDecoder();
            var buf = '', answer = '', errorMsg = null, gotDone = false, genTokens = 0;
            function pump() {
                return reader.read().then(function (r) {
                    if (r.done) {
                        return;
                    }
                    buf += decoder.decode(r.value, {stream: true});
                    var idx = buf.indexOf('\n\n');
                    while (idx >= 0) {
                        var parsed = n.AiChat.parseSseFrame(buf.slice(0, idx));
                        buf = buf.slice(idx + 2);
                        if (parsed) {
                            if (parsed.event === 'content') {
                                answer += (parsed.data.text || '');
                                genTokens++;   // live estimate; the exact 'tokens' event overrides
                                if (onTokens) {
                                    onTokens(genTokens);
                                }
                            } else if (parsed.event === 'reasoning') {
                                if (onReasoning) {
                                    onReasoning(parsed.data.text || '');
                                }
                                genTokens++;
                                if (onTokens) {
                                    onTokens(genTokens);
                                }
                            } else if (parsed.event === 'tokens') {
                                if (onTokens) {
                                    onTokens(parseInt(parsed.data.text, 10) || 0);
                                }
                            } else if (parsed.event === 'done') {
                                gotDone = true;
                                if (typeof parsed.data.content === 'string') {
                                    answer = parsed.data.content;
                                }
                            } else if (parsed.event === 'error') {
                                errorMsg = parsed.data.message || 'AI request failed.';
                            }
                        }
                        idx = buf.indexOf('\n\n');
                    }
                    return pump();
                });
            }
            return pump().then(function () {
                if (errorMsg !== null) {
                    throw errorMsg;
                }
                if (!gotDone) {
                    throw 'AI request ended unexpectedly.';
                }
                return answer;
            });
        });
    };

    /**
     * Show the running indicator below the aiAssist element: the same collapsed reasoning box as
     * the chat (AiChat.buildReasoning) with header "Thinking…", an elapsed timer and a Stop button
     * (far right inside the header) that aborts the request for real. Starts collapsed; the
     * caller expands it and streams reasoning into its body. $anchor is the element / editor
     * container the box is inserted after. Returns the box (jQuery) - remove it when done.
     */
    n.AiAssist.showBusy = function ($anchor, ctrl, label, collapsible) {
        var $box = n.AiChat.buildReasoning(function () {
            ctrl.abort();
        }, label, collapsible).addClass('collapsed qfq-ai-assist-busy');
        $anchor.after($box);
        return $box;
    };

    n.AiAssist.showError = function (error) {
        var message = (error && error.message) ? error.message : String(error);
        if (n.Alert) {
            new n.Alert({message: message, type: 'warning', timeout: 8000}).show();
        } else {
            window.alert(message);
        }
    };

})(QfqNS);

/**
 * QFQ RAG chatbot widget (native FormElement type 'aiChat').
 *
 * The server renders the host element with class 'qfq-ai-chat' + data-ai-sip /
 * data-ai-url (the SIP carries source/project/acl/prompt). This script turns it into a
 * chat box: each question is POSTed to Api/aiHandler.php with mode 'chat' and _ai_stream=1,
 * and the answer is streamed live (reasoning + answer) via Server-Sent Events. The chat always
 * streams so a running request can always be cancelled for real (Stop); it therefore needs a
 * browser with fetch + streaming. The answer + cited sources are rendered. Per-user access
 * control happens server side (the login is taken from the session, never sent by the client).
 *
 * The model output and source content are untrusted (anti-XSS): the answer is
 * rendered through a tiny Markdown subset built from DOM nodes (text() + created
 * elements, never innerHTML); user input, reasoning and source text use .text() directly.
 */

/* global $ */
/* global Promise */
/* global fetch, AbortController, TextDecoder */

var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    n.AiChat = {};

    /** Install the chat widget on all '.qfq-ai-chat' host elements (idempotent). */
    n.AiChat.install = function () {
        $('.qfq-ai-chat[data-ai-sip]:not(.ai-chat-installed)').each(function () {
            n.AiChat.build($(this).addClass('ai-chat-installed'));
        });
    };

    n.AiChat.build = function ($host) {
        var url = $host.data('ai-url');
        var sip = $host.data('ai-sip');
        var placeholder = $host.data('ai-chat-placeholder') || 'Ask a question…';
        // The chat ALWAYS streams (so a running request can always be cancelled for real).
        // This needs fetch + ReadableStream + AbortController; without them the chat is not run.
        var canStream = !!(window.fetch && window.ReadableStream && window.AbortController);

        // Conversation transcript for multi-turn follow-ups (sent as _ai_history).
        var history = [];
        // Sources cited so far (with dates), resent so follow-ups can rank the exact set.
        var citedSources = [];
        // Active AbortController while a streaming request is in flight (null otherwise).
        var aborter = null;
        // Per-request 'thinking' toggle (lightbulb), shown only when aiThinking is available.
        var thinkingAvailable = String($host.data('ai-thinking')) === '1';
        var thinkingOn = false, $think = null;

        var $widget = $('<div/>', {'class': 'qfq-ai-chat-widget'});
        var $log = $('<div/>', {'class': 'qfq-ai-chat-log'});
        var $input = $('<textarea/>', {'class': 'form-control qfq-ai-chat-textarea', rows: 1, placeholder: placeholder});
        // Grow the textarea with its content up to the CSS max-height (then it scrolls); no resize handle.
        function autoGrow() {
            $input.css('height', 'auto');
            $input.css('height', $input[0].scrollHeight + 'px');
        }
        $input.on('input', autoGrow);
        var $send = $('<button/>', {type: 'button', 'class': 'btn btn-primary', title: 'Send'}).html('<i class="far fa-paper-plane"></i>');
        var $sendGroup = $('<span/>', {'class': 'input-group-btn'});
        if (thinkingAvailable) {
            // Thinking on/off for this request: default off (btn-default), on = blue (btn-info).
            $think = $('<button/>', {type: 'button', 'class': 'btn btn-default qfq-ai-chat-think', title: 'Thinking'}).html('<i class="far fa-lightbulb"></i>');
            $think.on('click', function () {
                thinkingOn = !thinkingOn;
                $think.toggleClass('btn-info', thinkingOn).toggleClass('btn-default', !thinkingOn);
            });
            $sendGroup.append($think);   // glued left of the Send button
        }
        $sendGroup.append($send);
        var $bar = $('<div/>', {'class': 'input-group qfq-ai-chat-input'})
            .append($input)
            .append($sendGroup);
        $widget.append($log).append($bar);
        // Carry any extra classes the server put on the host (e.g. 'qfq-ai-chat-dock' via the
        // aiChatClass FE parameter) onto the widget, since the host is replaced below.
        var extraClasses = ($host.attr('class') || '').split(/\s+/).filter(function (c) {
            return c && c !== 'qfq-ai-chat' && c !== 'ai-chat-installed';
        });
        if (extraClasses.length) {
            $widget.addClass(extraClasses.join(' '));
        }
        // Dock variant: start collapsed (only the input row shows) with an 'AI' toggle at the far
        // left that opens the chat upward (the widget is anchored at the bottom of the viewport).
        var isDock = extraClasses.indexOf('qfq-ai-chat-dock') !== -1;
        if (isDock) {
            $widget.addClass('qfq-ai-chat-collapsed');
            var $toggle = $('<button/>', {type: 'button', 'class': 'btn btn-info qfq-ai-chat-toggle', title: 'Open / close chat'}).text('AI');
            $bar.prepend($('<span/>', {'class': 'input-group-btn'}).append($toggle));
            $toggle.on('click', function () {
                $widget.toggleClass('qfq-ai-chat-collapsed');
                if (!$widget.hasClass('qfq-ai-chat-collapsed')) {
                    $input.trigger('focus');
                }
            });
        }
        // The host field only carries the chat config and is not meant to be edited —
        // replace it with the widget so nothing overlaps and no empty field box remains.
        $host.replaceWith($widget);

        // Toggle the Send button into a Stop button (and back) while streaming.
        function setStop(on) {
            if (on) {
                $send.html('<i class="far fa-window-close"></i>').attr('title', 'Stop').removeClass('btn-primary').addClass('btn-danger qfq-ai-chat-stop').prop('disabled', false);
            } else {
                $send.html('<i class="far fa-paper-plane"></i>').attr('title', 'Send').removeClass('btn-danger qfq-ai-chat-stop').addClass('btn-primary').prop('disabled', false);
            }
        }
        function lockInput() {
            $input.val('').prop('disabled', true);
            $input.css('height', 'auto');   // shrink back to a single row after sending
        }
        function unlockInput() {
            $input.prop('disabled', false).trigger('focus');
        }
        function rememberTurn(q, answer, sources) {
            history.push({role: 'user', content: q}, {role: 'assistant', content: answer});
            if (history.length > 16) {
                history = history.slice(-16);
            }
            n.AiChat.mergeSources(citedSources, sources);
            if (citedSources.length > 40) {
                citedSources = citedSources.slice(-40);
            }
        }
        function requestBody(q) {
            return {
                sip: sip,
                _ai_mode: 'chat',
                _ai_input: q,
                _ai_history: JSON.stringify(history.slice(-8)),
                _ai_sources: JSON.stringify(citedSources.slice(-40)),
                _ai_thinking: thinkingOn ? '1' : '0'
            };
        }

        // The only chat path: SSE from aiHandler.php (_ai_stream=1). Reasoning + answer render
        // live; the Stop button aborts the fetch -> connection closes -> upstream generation is
        // aborted (REAL cancel). There is deliberately no non-streaming fallback for chat,
        // because a blocking request can only be cancelled cosmetically (the GPU keeps running).
        function askStream(q, $pending) {
            aborter = new AbortController();
            setStop(true);
            var $bubble = null, $reason = null, answer = '', firstToken = false, genTokens = 0;
            // Capture the thinking toggle at send time: the lightbulb can be toggled mid-stream, but
            // the box must match what was requested (reasoning only streams when it was on at send).
            var thinking = thinkingOn;

            function dropPending() {
                if (!firstToken) {
                    firstToken = true;
                    $pending.remove();
                }
            }
            function logAtBottom() {
                // Within ~40px of the bottom counts as "following the newest".
                return $log[0].scrollHeight - $log[0].scrollTop - $log[0].clientHeight < 40;
            }
            function onEvent(name, data) {
                // Stick to the bottom as content grows, but only while the user is already there.
                // If they scrolled up to re-read we leave their position alone (and resume following
                // when they scroll back down). Same rule as the reasoning body, applied to the whole
                // log - so a growing thinking box keeps following the newest and stays fully in view,
                // instead of scrolling once on open and then sliding half out of sight.
                var followLog = logAtBottom();
                if (name === 'reasoning') {
                    dropPending();
                    if (!$reason) {
                        $reason = n.AiChat.appendReasoning($log);
                    }
                    n.AiChat.appendReasoningText($reason, data.text || '');
                    genTokens++;   // live estimate (1 delta ≈ 1 token); the exact 'tokens' event overrides
                    n.AiChat.setReasoningTokens($reason, genTokens);
                    if (followLog) {
                        $log.scrollTop($log[0].scrollHeight);
                    }
                } else if (name === 'content') {
                    dropPending();
                    if (!$reason) {
                        // Thinking off has no reasoning stream, but tokens + timer still matter: show
                        // them as a plain non-expandable 'Generating…' row above the answer. (On: the
                        // box already exists, created by the 'reasoning' branch.)
                        $reason = thinking ? n.AiChat.appendReasoning($log) : n.AiChat.appendGenerating($log);
                    }
                    if (!$bubble) {
                        $bubble = n.AiChat.appendStreamingBubble($log);
                    }
                    answer += (data.text || '');
                    $bubble.text(answer);
                    genTokens++;   // keep counting through the answer
                    n.AiChat.setReasoningTokens($reason, genTokens);
                    if (followLog) {
                        $log.scrollTop($log[0].scrollHeight);
                    }
                } else if (name === 'tokens') {
                    n.AiChat.setReasoningTokens($reason, parseInt(data.text, 10) || 0);
                } else if (name === 'done') {
                    dropPending();
                    if ($bubble) {
                        $bubble.remove();   // swap the live raw text for the cleaned, rendered answer
                    }
                    n.AiChat.collapseReasoning($reason);
                    n.AiChat.appendMessage($log, 'bot', data.content, data.sources);
                    rememberTurn(q, data.content || answer, data.sources);
                } else if (name === 'error') {
                    dropPending();
                    if ($bubble) {
                        $bubble.remove();
                    }
                    n.AiChat.appendMessage($log, 'bot', data.message || 'Chat request failed.', null, true);
                }
            }

            var params = requestBody(q);
            params._ai_stream = '1';
            fetch(url, {
                method: 'POST',
                credentials: 'same-origin',   // send the session cookie (SIP lookup needs the session)
                headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
                body: $.param(params),
                signal: aborter.signal
            }).then(function (res) {
                if (!res.ok || !res.body) {
                    throw new Error('Chat request failed (HTTP ' + res.status + ').');
                }
                var reader = res.body.getReader();
                var decoder = new TextDecoder();
                var buf = '';
                function pump() {
                    return reader.read().then(function (r) {
                        if (r.done) {
                            return;
                        }
                        buf += decoder.decode(r.value, {stream: true});
                        var idx = buf.indexOf('\n\n');
                        while (idx >= 0) {
                            var parsed = n.AiChat.parseSseFrame(buf.slice(0, idx));
                            buf = buf.slice(idx + 2);
                            if (parsed) {
                                onEvent(parsed.event, parsed.data);
                            }
                            idx = buf.indexOf('\n\n');
                        }
                        return pump();
                    });
                }
                return pump();
            }).catch(function (err) {
                dropPending();
                if ($bubble) {
                    $bubble.remove();
                }
                n.AiChat.collapseReasoning($reason);
                if (err && err.name === 'AbortError') {
                    n.AiChat.appendMessage($log, 'bot', 'Cancelled.', null, true);
                } else {
                    n.AiChat.appendMessage($log, 'bot', (err && err.message) ? err.message : String(err), null, true);
                }
            }).then(function () {
                aborter = null;
                setStop(false);
                unlockInput();
            });
        }

        function ask() {
            var q = $.trim($input.val());
            if (q === '') {
                return;
            }
            // The chat always streams so a running request can always be cancelled for real.
            // Without streaming support there is no cancellable path, so the chat is not run.
            if (!canStream) {
                n.AiChat.appendMessage($log, 'bot', 'This browser is too old for the AI chat (needs fetch + streaming).', null, true);
                return;
            }
            $widget.removeClass('qfq-ai-chat-collapsed');   // open the dock so the answer is visible
            n.AiChat.appendMessage($log, 'user', q);
            lockInput();
            askStream(q, n.AiChat.appendTyping($log));
        }

        // While a stream is in flight the Send button acts as Stop.
        $send.on('click', function () {
            if (aborter) {
                aborter.abort();
            } else {
                ask();
            }
        });
        $input.on('keydown', function (e) {
            // Enter submits; Shift+Enter inserts a newline (textarea default).
            if (e.which === 13 && !e.shiftKey) {
                e.preventDefault();
                if (!aborter) {
                    ask();
                }
            }
        });
    };

    /** Append a message row. text() only (no HTML) for untrusted model/source content. */
    n.AiChat.appendMessage = function ($log, who, text, sources, isError) {
        var cls = 'qfq-ai-chat-msg qfq-ai-chat-' + who + (isError ? ' qfq-ai-chat-error' : '');
        var $m = $('<div/>', {'class': cls});
        // Bot answers may contain Markdown -> render the safe subset. User input and
        // error strings are shown verbatim via .text() (no Markdown interpretation).
        if (who === 'bot' && !isError) {
            n.AiChat.renderMarkdown($m, text);
        } else {
            $m.text(text);
        }

        if (sources && sources.length) {
            var $src = $('<div/>', {'class': 'qfq-ai-chat-sources'});
            $src.append(document.createTextNode('Sources: '));
            sources.forEach(function (s, i) {
                if (i) {
                    $src.append(document.createTextNode(', '));
                }
                var date = s.updated_on ? ' (' + s.updated_on.substring(0, 10) + ')' : '';
                var label = '#' + s.external_id + date + (s.title ? ' ' + s.title : '');
                if (s.url) {
                    $('<a/>', {href: s.url, target: '_blank', rel: 'noopener'}).text(label).appendTo($src);
                } else {
                    $src.append(document.createTextNode(label));
                }
            });
            $m.append($src);
        }

        $log.append($m);
        // If the optional MathJax library is loaded, typeset LaTeX math ($...$, \(...\)) in the
        // finalized bot answer.
        if (who === 'bot' && !isError) {
            n.AiChat.typeset($m);
        }
        $log.scrollTop($log[0].scrollHeight);
        return $m;
    };

    /**
     * Typeset LaTeX math ($...$, \(...\)) inside $el via MathJax - but only if the MathJax
     * library is actually loaded. MathJax is OPTIONAL in QFQ
     */
    n.AiChat.typeset = function ($el) {
        var mj = window.MathJax;
        if (!$el || !$el.length || !mj || typeof mj.typesetPromise !== 'function') {
            return;
        }
        mj.typesetPromise([$el[0]]).catch(function () {});
    };

    /** Merge new sources into the running list, dedup by external_id (latest wins). */
    n.AiChat.mergeSources = function (list, sources) {
        if (!sources || !sources.length) {
            return;
        }
        sources.forEach(function (s) {
            var i, found = false;
            for (i = 0; i < list.length; i++) {
                if (list[i].external_id === s.external_id) {
                    list[i] = s;
                    found = true;
                    break;
                }
            }
            if (!found) {
                list.push(s);
            }
        });
    };

    /** Tick an elapsed-seconds counter ("12s" then "1:05") into $timer once per second until it
     *  leaves the DOM. Returns the interval id so a caller can stop it explicitly for a box that
     *  stays in the DOM (e.g. the collapsed reasoning box keeps its final time). */
    n.AiChat.startElapsed = function ($timer) {
        var start = Date.now();
        var id = window.setInterval(function () {
            if (!$timer.closest('html').length) {
                window.clearInterval(id);
                return;
            }
            var s = Math.floor((Date.now() - start) / 1000);
            $timer.text(s < 60 ? s + 's' : Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2));
        }, 1000);
        return id;
    };

    /** Append an animated "typing" indicator (three dots bouncing one after another) with an
     *  elapsed timer to its right. Returns the row so it can be removed when the answer arrives. */
    n.AiChat.appendTyping = function ($log) {
        var $m = $('<div/>', {'class': 'qfq-ai-chat-msg qfq-ai-chat-bot qfq-ai-chat-typing'});
        var $dots = $('<span/>', {'class': 'qfq-ai-chat-dots'});
        var i;
        for (i = 0; i < 3; i++) {
            $dots.append($('<span/>', {'class': 'qfq-ai-chat-dot'}));
        }
        $m.append($dots);
        var $timer = $('<span/>', {'class': 'qfq-ai-chat-timer', text: '0s'});
        $m.append($timer);
        n.AiChat.startElapsed($timer);   // self-stops once the row is removed (answer or error)
        $log.append($m);
        $log.scrollTop($log[0].scrollHeight);
        return $m;
    };

    /** Parse one SSE frame ("event: x\ndata: {...}") into {event, data}, or null. */
    n.AiChat.parseSseFrame = function (frame) {
        var lines = frame.split('\n');
        var ev = 'message', dataStr = '';
        var i, ln;
        for (i = 0; i < lines.length; i++) {
            ln = lines[i];
            if (ln.charAt(0) === ':') {
                continue;   // SSE comment line
            }
            if (ln.indexOf('event:') === 0) {
                ev = $.trim(ln.slice(6));
            } else if (ln.indexOf('data:') === 0) {
                dataStr += (dataStr ? '\n' : '') + ln.slice(5).replace(/^ /, '');
            }
        }
        if (dataStr === '') {
            return null;
        }
        try {
            return {event: ev, data: JSON.parse(dataStr)};
        } catch (e) {
            return null;
        }
    };

    /** Build a collapsible reasoning box: a header with a label, an elapsed timer (pushed right)
     *  and an optional Stop button, plus a body for the streamed reasoning text. Reused by the
     *  chat (no Stop - the Send button stops) and by aiAssist (Stop aborts the request). The
     *  timer ticks until collapseReasoning() or removal. Returns the wrapper. */
    n.AiChat.buildReasoning = function (onStop, label, collapsible) {
        var $wrap = $('<div/>', {'class': 'qfq-ai-chat-reasoning'});
        var $head = $('<div/>', {'class': 'qfq-ai-chat-reasoning-head'});
        $head.append($('<span/>', {'class': 'qfq-ai-chat-reasoning-label', text: label || 'Thinking…'}));
        $head.append($('<span/>', {'class': 'qfq-ai-chat-reasoning-tokens', title: 'Tokens'}));
        $head.append($('<span/>', {'class': 'qfq-ai-chat-reasoning-timer', text: '0s'}));
        if (typeof onStop === 'function') {
            $('<button/>', {type: 'button', 'class': 'qfq-ai-chat-reasoning-stop', text: 'Stop', title: 'Stop'})
                .on('click', function (e) {
                    e.stopPropagation();   // don't toggle collapse
                    onStop();
                }).appendTo($head);
        }
        if (collapsible === false) {
            // Nothing to expand (e.g. thinking off) -> header not clickable; Stop still works.
            $wrap.addClass('qfq-ai-no-toggle');
        } else {
            $head.on('click', function () {
                $wrap.toggleClass('collapsed');   // click the header to collapse/expand
            });
        }
        $wrap.append($head).append($('<div/>', {'class': 'qfq-ai-chat-reasoning-body'}));
        $wrap.data('qfqTimerId', n.AiChat.startElapsed($wrap.find('.qfq-ai-chat-reasoning-timer')));
        return $wrap;
    };

    /** Collapsible "Thinking…" panel for the model's live reasoning stream (chat); on 'done' it
     *  collapses and the label becomes "Reasoning". Returns the wrapper. Scrolling is left to the
     *  caller (askStream follows the newest while the user is at the bottom). */
    n.AiChat.appendReasoning = function ($log) {
        var $wrap = n.AiChat.buildReasoning(null).data('qfqDoneLabel', 'Reasoning');
        $log.append($wrap);
        return $wrap;
    };

    /** Plain status row for the thinking-off case: "Generating…" + token count + timer, but NOT
     *  expandable (no triangle, not clickable) and with no reasoning body. */
    n.AiChat.appendGenerating = function ($log) {
        var $wrap = n.AiChat.buildReasoning(null, 'Generating…', false)
            .addClass('collapsed').data('qfqDoneLabel', 'Generated');
        $log.append($wrap);
        return $wrap;
    };

    /** Format a token count for display: "123" or "1.5k" (>= 1000). */
    n.AiChat.formatTokens = function (count) {
        return count < 1000 ? String(count) : (count / 1000).toFixed(1) + 'k';
    };

    /** Show the (accumulating) token count in parentheses, left of the timer, in a reasoning box. */
    n.AiChat.setReasoningTokens = function ($wrap, count) {
        if ($wrap && $wrap.length && count > 0) {
            $wrap.find('.qfq-ai-chat-reasoning-tokens').text('(' + n.AiChat.formatTokens(count) + ')');
        }
    };

    /** Append reasoning text (plain, text() only) and follow the newest - but only while the user
     *  is already at the bottom of the panel. If they scrolled up to re-read we leave their
     *  position alone, and resume following once they scroll back down. */
    n.AiChat.appendReasoningText = function ($wrap, text) {
        var el = $wrap.find('.qfq-ai-chat-reasoning-body')[0];
        var atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8;   // tolerance for sub-pixels
        el.appendChild(document.createTextNode(text));
        if (atBottom) {
            el.scrollTop = el.scrollHeight;
        }
    };

    /** Finalize the status box once the answer is final: freeze its timer and switch the label to its
     *  done text - "Reasoning" for the thinking box, "Generated" for the thinking-off box (stored as
     *  qfqDoneLabel). The thinking box also collapses (hides its body); the non-expandable Generating
     *  box is already collapsed. No-op if there was none. */
    n.AiChat.collapseReasoning = function ($wrap) {
        if ($wrap && $wrap.length) {
            window.clearInterval($wrap.data('qfqTimerId'));
            $wrap.addClass('collapsed');
            $wrap.find('.qfq-ai-chat-reasoning-label').text($wrap.data('qfqDoneLabel') || 'Reasoning');
        }
    };

    /** Bot bubble holding the raw answer text while it streams in (replaced on 'done'). */
    n.AiChat.appendStreamingBubble = function ($log) {
        var $m = $('<div/>', {'class': 'qfq-ai-chat-msg qfq-ai-chat-bot qfq-ai-chat-streaming'});
        $log.append($m);
        return $m;   // scrolling handled by the caller (gated once the thinking box has opened)
    };

    /**
     * Render a tiny, safe Markdown subset into $container: bold, inline code, bullet
     * lists ('-', '*', '+') and paragraphs. DOM only - never innerHTML, so untrusted
     * model output cannot inject markup.
     */
    n.AiChat.renderMarkdown = function ($container, text) {
        var raw = (text === null || text === undefined) ? '' : String(text);
        var lines = raw.replace(/\r\n?/g, '\n').split('\n');
        var bulletRe = /^\s*[-*+]\s+(.*)$/;
        var i = 0;
        while (i < lines.length) {
            if (bulletRe.test(lines[i])) {
                var $ul = $('<ul/>', {'class': 'qfq-ai-chat-md-list'});
                while (i < lines.length) {
                    var m = lines[i].match(bulletRe);
                    if (!m) {
                        break;
                    }
                    n.AiChat.renderInline($('<li/>').appendTo($ul), m[1]);
                    i++;
                }
                $container.append($ul);
                continue;
            }
            if ($.trim(lines[i]) === '') {
                i++;
                continue;
            }
            n.AiChat.renderInline($('<div/>', {'class': 'qfq-ai-chat-md-p'}).appendTo($container), lines[i]);
            i++;
        }
        // Never leave the bubble empty (e.g. answer was only blank lines).
        if ($container.children().length === 0) {
            $container.text(raw);
        }
    };

    /** Inline render: **bold** and `code`; everything else as plain text nodes. */
    n.AiChat.renderInline = function ($el, text) {
        var re = /\*\*([^*]+)\*\*|`([^`]+)`/g;
        var last = 0;
        var match = re.exec(text);
        while (match !== null) {
            if (match.index > last) {
                $el.append(document.createTextNode(text.slice(last, match.index)));
            }
            if (match[1] !== undefined) {
                $el.append($('<strong/>').text(match[1]));
            } else {
                $el.append($('<code/>').text(match[2]));
            }
            last = re.lastIndex;
            match = re.exec(text);
        }
        if (last < text.length) {
            $el.append(document.createTextNode(text.slice(last)));
        }
    };

    // Self-install on DOM ready so the chat widget also works outside a form - e.g. rendered in a
    // Report via the _input special column
    $(function () {
        n.AiChat.install();
    });

})(QfqNS);

/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend AlertManager.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Display a message.
     *
     * A typical call sequence might look like:
     *
     *     var alert = new QfqNS.Alert({
     *       message: "Text being displayed",
     *       type: "info"
     *     });
     *     alert.show();
     *
     * Messages may have different background colors (severity levels), controlled by the `type` property. Possible
     * values are
     *
     *  * `"success"`
     *  * `"info"`
     *  * `"warning"`
     *  * `"error"`, `"danger"`
     *
     * The values are translated into Bootstrap `alert-*` classes internally.
     *
     * If no buttons are configured, a click anywhere on the alert will close it.
     *
     * Buttons are configured by passing an array of objects in the `buttons` property. The properties of the object
     * are as follows
     *
     *     {
     *       label: <button label>,
     *       focus: true | false,
     *       eventName: <eventname>
     *     }
     *
     * You can connect to the button events by using
     *
     *     var alert = new QfqNS.Alert({
     *        message: "Text being displayed",
     *        type: "info",
     *        buttons: [
     *             { label: "OK", eventName: "ok" }
     *        ]
     *     });
     *     alert.on('alert.ok', function(...) { ... });
     *
     * Events are named according to `alert.<eventname>`.
     *
     * If the property `modal` is set to `true`, a kind-of modal alert will be displayed, preventing clicks
     * anywhere but the alert.
     *
     * For compatibility reasons, the old constructor signature is still supported but deprecated
     *
     *      var alert = new QfqNS.Alert(message, type, buttons)
     *
     * @param {object} options option object has following properties
     * @param {string} options.message message to be displayed
     * @param {string} [options.type] type of message, can be `"info"`, `"warning"`, or `"error"`. Default is `"info"`.
     * @param {number} [options.timeout] timeout in milliseconds. If timeout is less than or equal to 0, the alert
     * won't timeout and stay open until dismissed by the user. Default `n.Alert.constants.NO_TIMEOUT`.
     * @param {boolean} [options.modal] whether or not alert is modal, i.e. prevent clicks anywhere but the dialog.
     * Default is `false`.
     * @param {object[]} options.buttons what buttons to display on alert. If empty array is provided, no buttons are
     * displayed and a click anywhere in the alert will dismiss it.
     * @param {string} options.buttons.label label of the button
     * @param {string} options.buttons.eventName name of the event when button is clicked.
     * @param {boolean} [options.buttons.focus] whether or not button has focus by default. Default is `false`.
     *
     * @constructor
     */
    n.Alert = function (options) {
        // Emulate old behavior of method signature
        //  function(message, messageType, buttons)
        if (typeof options === "string") {
            this.message = arguments[0];
            this.messageType = arguments[1] || "info";
            this.buttons = arguments[2] || [];
            this.modal = false;
            // this.timeout < 1 means forever
            this.timeout = n.Alert.constants.NO_TIMEOUT;
        } else {
            // new style
            this.message = options.message || "MESSAGE";
            this.messageType = options.type || "info";
            this.messageTitle = options.title || false;
            this.errorCode = options.code || false;
            this.buttons = options.buttons || [];
            this.modal = options.modal || false;
            this.timeout = options.timeout || n.Alert.constants.NO_TIMEOUT;
        }

        this.$alertDiv = null;
        this.$modalDiv = null;
        this.shown = false;

        this.fadeInDuration = 400;
        this.fadeOutDuration = 400;
        this.timerId = null;
        this.parent = {};
        this.identifier = false;

        this.eventEmitter = new EventEmitter();

        if (this.message.indexOf('qfq-debug-detail') >= 0) {
            this.buttons.push({ label: 'Debug info', eventName: 'show-debug' });
        setTimeout(function() {
            this.on('alert.show-debug', function() {
                $('.qfq-debug-detail').toggleClass('qfq-alert-hidden');
            });
        }.bind(this), 100);
        }
    };

    n.Alert.prototype.on = n.EventEmitter.onMixin;
    n.Alert.constants = {
        alertContainerId: "alert-interactive",
        alertContainerSelector: "#qfqAlertContainer",
        jQueryAlertRemoveEventName: "qfqalert.remove:",
        NO_TIMEOUT: 0
    };

    /**
     *
     * @private
     */
    n.Alert.prototype.makeAlertContainerSingleton = function () {
        if (!n.QfqPage.alertManager) {
            n.QfqPage.alertManager = new n.AlertManager({});
        }

        return n.QfqPage.alertManager;
    };

    n.Alert.prototype.setIdentifier = function (i) {
        this.identifier = i;
    };

    /**
     *
     * @returns {number|jQuery}
     * @private
     */
    n.Alert.prototype.countAlertsInAlertContainer = function () {
        return $(n.Alert.constants.alertContainerSelector + " > div").length;
    };

    /**
     * @private
     */
    n.Alert.prototype.removeAlertContainer = function () {
        if (this.modal) {
            this.shown = false;
            this.parent.removeModalAlert();
        }
    };

    /**
     * @private
     */
    n.Alert.prototype.getAlertClassBasedOnMessageType = function () {
        switch (this.messageType) {
            case "warning":
                return "border-warning";
            case "danger":
            case "error":
                return "border-error";
            case "info":
                return "border-info";
            case "success":
                return "border-success";
            /* jshint -W086 */
            default:
                n.Log.warning("Message type '" + this.messageType + "' unknown. Use default type.");
            /* jshint +W086 */
        }
    };

    /**
     * @private
     */
    n.Alert.prototype.getButtons = function () {
        var $buttons = null;
        var $container = $("<p>").addClass("buttons");
        var numberOfButtons = this.buttons.length;
        var index;
        var buttonConfiguration;

        for (index = 0; index < numberOfButtons; index++) {
            buttonConfiguration = this.buttons[index];

            if (!$buttons) {
                if (numberOfButtons > 1) {
                    $buttons = $("<div>").addClass("btn-group");
                } else {
                    $buttons = $container;
                }
            }

            var focus = buttonConfiguration.focus ? buttonConfiguration.focus : false;

            $buttons.append($("<button>").append(buttonConfiguration.label)
                .attr('type', 'button')
                .addClass("btn btn-default" + (focus ? " wants-focus" : ""))
                .click(buttonConfiguration, this.buttonHandler.bind(this)));
        }

        if (numberOfButtons > 1) {
            $container.append($buttons);
            $buttons = $container;
        }

        return $buttons;
    };

    /**
     * @public
     */
    n.Alert.prototype.show = function () {
        $(".removeMe").remove();
        var alertContainer;
        if (this.shown) {
            // We only allow showing once
            return;
        }

        this.parent = this.makeAlertContainerSingleton();
        this.parent.addAlert(this);

        if (this.modal) {
            this.$modalDiv = $("<div>", {
                class: "removeMe"
            });
            this.parent.createBlockScreen(this);
        }

        if (this.messageTitle) {
            this.$titleWrap = $("<p>")
                .addClass("title")
                .append(this.messageTitle);
        }

        this.$messageWrap = $("<p>")
            .addClass("body")
            .append(this.message);

        this.$alertDiv = $("<div>")
            .hide()
            .addClass(this.getAlertClassBasedOnMessageType())
            .attr("role", "alert")
            .append(this.$messageWrap);

        if (this.$titleWrap) {
            this.$alertDiv.prepend(this.$titleWrap);
        }

        if (this.modal) {
            this.$alertDiv.addClass("alert-interactive");
            this.$alertDiv.css('z-index', 1000);
        } else {
            this.$alertDiv.addClass("alert-side");
        }
        this.$alertDiv.addClass("removeMe");

        var buttons = this.getButtons();
        if (buttons) {
            // Buttons will take care of removing the message
            this.$alertDiv.append(buttons);
        } else {
            // Click on the message anywhere will remove the message
            this.$alertDiv.click(this.removeAlert.bind(this));
            // Allows to remove all alerts that do not require user interaction, programmatically. Yes, we could send
            // the "click" event, but we want to communicate our intention clearly.
            this.$alertDiv.on(n.Alert.constants.jQueryAlertRemoveEventName, this.removeAlert.bind(this));
        }

        this.parent.$container.append(this.$alertDiv);


        //this.$alertDiv.slideDown(this.fadeInDuration, this.afterFadeIn.bind(this));
        if (!this.modal) {
            this.$alertDiv.animate({width:'show'}, this.fadeInDuration, this.afterFadeIn.bind(this));
        } else {
            this.$alertDiv.fadeIn(this.fadeInDuration);
        }

        this.$alertDiv.find(".wants-focus").focus();
        this.shown = true;
    };

    /**
     * @private
     */
    n.Alert.prototype.afterFadeIn = function () {
        if (this.timeout > 0) {
            this.timerId = window.setTimeout(this.removeAlert.bind(this), this.timeout);
        }
    };

    /**
     *
     *
     * @private
     */
    n.Alert.prototype.removeAlert = function () {

        // In case we have an armed timer (or expired timer, for that matter), disarm it.
        if (this.timerId) {
            window.clearTimeout(this.timerId);
            this.timerId = null;
        }

        var that = this;
        if (!this.modal) {
            this.$alertDiv.animate({width:'hide'}, this.fadeOutDuration, function () {
                that.$alertDiv.remove();
                that.$alertDiv = null;
                that.shown = false;
                that.parent.removeOutdatedAlerts();
            });
        } else {
            this.$alertDiv.fadeOut(this.fadeOutDuration, function(){
                that.$alertDiv.remove();
                that.$alertDiv = null;
                that.shown = false;
                that.removeAlertContainer();
            });
        }
        this.parent.removeAlert(this.identifier);
    };

    /**
     *
     * @param handler
     *
     * @private
     */
    n.Alert.prototype.buttonHandler = function (event) {
        if(event.data.eventName !== "show-debug") this.removeAlert();
        this.eventEmitter.emitEvent('alert.' + event.data.eventName, n.EventEmitter.makePayload(this, null));
    };

    n.Alert.prototype.isShown = function () {
        return this.shown;
    };

})(QfqNS);
/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Manages multiple Alerts and provides an Alert Container.
     *
     * Is usually initialized with the first Alert that is created. (see n.Alert)
     *
     * @param {object} options For later
     */

    n.AlertManager = function(options) {
        this.screenBlocked = false;
        this.alerts = [];
        this.$container = $("<div>");
        this.blockingAlert = {};
        this.eventEmitter = new EventEmitter();
        this.regularCheck = {};
        this.parent = $(".container") || $(".container-fluid");
        if(this.parent.length === 0) {
            this.parent = $(".container-fluid");
        }

        $("body").append(this.$container);
        console.log("Created Alert Container");
    };

    n.AlertManager.prototype.on = n.EventEmitter.onMixin;

    /**
     * Add an Alert to the Alert Manager
     * @param alert
     */
    n.AlertManager.prototype.addAlert = function(alert) {
        this.alerts.push(alert);
        alert.setIdentifier(this.alerts.length);
        console.log(this.alerts);
    };

    n.AlertManager.prototype.removeAlert = function(identifier) {
        for(var i=0; this.alerts.length > i; i++) {
            if (this.alerts[i].identifier === identifier) {
                this.alerts.splice(i, 1);
            }
        }
    };

    /**
     * Removes the last Alert in the Array. Can be used to safely delete all alerts in a loop.
     * Returns false when the AlertManager has no more Alerts.
     * @returns Boolean
     */
    n.AlertManager.prototype.removeLastAlert = function() {
        if (this.alert.length > 0) {
            var alert = this.alerts.pop();
            alert.removeAlert();
            return true;
        } else {
            return false;
        }
    };

    /**
     * Savely removes outdated Alerts with isShown = false
     */
    n.AlertManager.prototype.removeOutdatedAlerts = function() {
        for(var i = 0; this.alerts.length > i; i++) {
            if(!this.alerts[i].isShown) {
                this.alerts[i].removeAlert();
                this.alerts.splice(i, 1);
            }
        }
    };

    /**
     * Returns the number of Alerts currently active
     * @returns {number}
     */
    n.AlertManager.prototype.count = function() {
        return this.alerts.length;
    };

    /**
     * Creates a semi-transparent black screen behind the alert.
     * Used to block other user input by modal alerts
     * @param alert
     */
    n.AlertManager.prototype.createBlockScreen = function(alert) {
        if (!this.screenBlocked) {
            var $blockScreen = $("<div>")
                .addClass("blockscreenQfq")
                .appendTo(this.$container);
            $blockScreen.css({
                'width': '100%',
                'height': Math.max($(document).height(), $(window).height()) + "px"
            });
            this.parent.addClass("blur");

            var that = this;
            this.screenBlocked = true;
            this.blockingAlert = alert;
            this.regularCheck = setInterval(function () {
                that.checkAlert();
            }, 500);
        }
    };

    /**
     * Is used by the interval this.regularcheck to guarantee
     * that the screen block is removed.
     */
    n.AlertManager.prototype.checkAlert = function() {
        if (!this.blockingAlert.isShown) {
            this.removeModalAlert();
        }
    };

    /**
     * Remove modal alerts
     */
    n.AlertManager.prototype.removeModalAlert = function() {
        if (this.screenBlocked) {
            $(".blockscreenQfq").remove();
            this.parent.removeClass("blur");
            clearInterval(this.regularCheck);
            this.screenBlocked = false;
        }
        this.removeOutdatedAlerts();
    };

})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */
/* global console */
/* global EventEmitter */

/* @depend QfqEvents.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};


(function (n) {
    'use strict';
    /**
     * Tab Constructor.
     *
     * Programmatically access Bootstrap nav-tabs.
     *
     * @param {string} tabId HTML id of the element having `nav` and `nav-tabs` classes
     * @constructor
     *
     * @name QfqNS.BSTabs
     */
    n.BSTabs = function (tabId) {
        this.tabId = tabId;
        this._tabContainerLinkSelector = '#' + this.tabId + ' a[data-toggle="tab"]';
        this._tabActiveSelector = '#' + this.tabId + ' .active a[data-toggle="tab"]';
        this.tabs = {};
        this.currentTab = this.getActiveTabFromDOM();
        this.eventEmitter = new EventEmitter();
        this.currentFormName = $('#' + this.tabId + ' .active a[data-toggle="tab"]')[0].hash.slice(1).split("_")[0];
        this.currentRecordId = $('#' + this.tabId + ' a[data-toggle="tab"]')[0].id.split("-")[2];
        this.currentActiveLastPill = document.getElementById(this.tabId).getAttribute('data-active-last-pill');

        // Fill this.tabs
        this.fillTabInformation();

        // Enable update of current tab field
        this.installTabHandlers();
    };

    n.BSTabs.prototype.on = n.EventEmitter.onMixin;

    /**
     * Get active tab from DOM.
     *
     * Used upon object creation to fill the currentTab. It gets the ID of the currently shown tab. It does it, by
     * targeting the element in the navigator having the `active` class set.
     *
     * @private
     */
    n.BSTabs.prototype.getActiveTabFromDOM = function () {
        var activeTabAnchors = $(this._tabActiveSelector);
        if (activeTabAnchors.length < 1) {
            // This could be due to the DOM not fully loaded. If that's really the case, then the active tab
            // attribute should be set by the show.bs.tab handler
            return null;
        }

        return activeTabAnchors[0].hash.slice(1);
    };

    /**
     * Fill tabs object.
     *
     * Fill the tabs object using the tab HTML id as attribute name
     *
     * @private
     */
    n.BSTabs.prototype.fillTabInformation = function () {
        var tabLinks = $(this._tabContainerLinkSelector);
        if ($(tabLinks).length === 0) {
            throw new Error("Unable to find a BootStrap container matching: " + this._tabContainerLinkSelector);
        }

        var that = this;
        tabLinks.each(function (index, element) {
                if (element.hash !== "") {
                    var tabId = element.hash.slice(1);

                    that.tabs[tabId] = {
                        index: index,
                        element: element
                    };
                }
            }
        );
    };

    /**
     * @private
     */
    n.BSTabs.prototype.installTabHandlers = function () {
        $(this._tabContainerLinkSelector)
            .on('show.bs.tab', this.tabShowHandler.bind(this));

    };

    /**
     * Tab Show handler.
     *
     * Sets this.currentTab to the clicked tab and calls all registered tab click handlers.
     *
     * @private
     * @param event
     */
    n.BSTabs.prototype.tabShowHandler = function (event) {
        n.Log.debug('Enter: BSTabs.tabShowHandler()');
        this.currentTab = event.target.hash.slice(1);
        n.Log.debug("BSTabs.tabShowHandler(): invoke user handler(s)");
        this.eventEmitter.emitEvent('bootstrap.tab.shown', n.EventEmitter.makePayload(this, null));
        this.removeDot(this.currentTab);
        n.Log.debug('Exit: BSTabs.tabShowHandler()');
    };

    

    /**
     * Get all tab IDs.
     *
     * @returns {Array}
     *
     * @public
     */
    n.BSTabs.prototype.getTabIds = function () {
        var tabIds = [];
        for (var tabId in this.tabs) {
            if (this.tabs.hasOwnProperty(tabId)) {
                tabIds.push(tabId);
            }
        }
        return tabIds;
    };

    /**
     *
     * @returns {Array}
     *
     * @public
     */
    n.BSTabs.prototype.getTabAnchors = function () {
        var tabLinks = [];
        for (var tabId in this.tabs) {
            if (this.tabs.hasOwnProperty(tabId)) {
                tabLinks.push(this.tabs[tabId].element);
            }
        }

        return tabLinks;
    };

    /**
     * Activate a given tab.
     *
     * @param {string} tabId Id of the tab to activate
     *
     */
    n.BSTabs.prototype.activateTab = function (tabId) {
        if (!this.tabs[tabId]) {
            console.error("Unable to find tab with id: " + tabId);
            return false;
        }

        $(this.tabs[tabId].element).tab('show');
        this.removeDot(tabId);
        return true;
    };

    n.BSTabs.prototype.getCurrentTab = function () {
        return this.currentTab;
    };

    n.BSTabs.prototype.getTabName = function (tabId) {
        if (!this.tabs[tabId]) {
            console.error("Unable to find tab with id: " + tabId);
            return null;
        }

        return $(this.tabs[tabId].element).text().trim();
    };

    n.BSTabs.prototype.setTabName = function (tabId, text) {
        if(!this.tabs[tabId]) {
            console.error("Unable to find tab with id: " + tabId);
        }
        var $tab = $(this.tabs[tabId].element);
        $tab.text(text);
    };

    n.BSTabs.prototype.addDot = function(tabId) {
        var $tab = $(this.tabs[tabId].element);
        $tab.find(".qfq-dot").remove();
        var $coolBadge = $("<span>", {
            class: 'qfq-dot'
        });
        $tab.append($coolBadge);
    };

    n.BSTabs.prototype.removeDot = function(tabId) {
        $(this.tabs[tabId].element).find(".qfq-dot").remove();
    };

    n.BSTabs.prototype.getActiveTab = function () {
        return this.currentTab;
    };

    n.BSTabs.prototype.getContainingTabIdForFormControl = function (formControlName) {
        var $formControl = $("[name='" + formControlName + "']");
        if ($formControl.length === 0) {
            n.Log.debug("BSTabs.getContainingTabForFormControl(): unable to find form control with name '" + formControlName + "'");
            return null;
        }

        var i;
        var iterator;
        for (i = 0; i < $formControl.length; i++) {  // workaroud: checkbox renders two input elements with the same name so we loop through all input elements with that name. See issue #11752
            iterator = $formControl[i];
            while (iterator !== null) {
                if (iterator.hasAttribute('role') &&
                    iterator.getAttribute('role') === 'tabpanel') {
                    return iterator.id || null;
                }
                iterator = iterator.parentElement;
            }
        }

        return null;
    };

})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */


/* global $ */

var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    n.CharacterCount = {};

    /**
     * Initialize character count.
     *
     * Character count keeps track of characters in `<input>` and `<textarea>` elements (tracked elements). By default,
     * all elements having the `qfq-character-count` class are initialized. When non-`<input>/<textarea>` elements are
     * encountered during initialization, the behavior is undefined.
     *
     * Each element eligible for character count must provide a `data-character-count-id` attribute holding the element
     * id of the element receiving the character count. The receiving element's text is replaced by the current number
     * of characters of the tracked element. The number of characters in a tracked element is updated in the receiving
     * element upon a `change` or `keyup` event.
     *
     * If the `maxlength` attribute is present on the tracked element, the receiving element will display
     *
     *     N/<maxlength>
     *
     * where `N` is the current number of characters of the tracked element. If `maxlength` is not present, the
     * receiving element will display
     *
     *     N/∞
     *
     * where `N` is the current number of characters of the tracked element.
     *
     * @param selector {string} optional selector. Defaults to `.qfq-character-count`.
     */
    n.CharacterCount.initialize = function (selector) {
        selector = selector || ".qfq-character-count";
        $(selector).each(function () {
            var characterCountTarget, $targetElement;

            var $element = $(this);

            characterCountTarget = "#" + n.CharacterCount.getCharacterCountTargetId($element);

            $element.data('character-count-display', $(characterCountTarget));

            n.CharacterCount.updateCountForElement($element);

            $element.on('change keyup', function (evt) {
                n.CharacterCount.updateCountForElement($(evt.delegateTarget));
            });

        });
    };

    n.CharacterCount.updateCountForElement = function ($targetElement) {
        var maxLength = $targetElement.attr('maxlength') || "∞";
        var currentLength = $targetElement.val().length;
        $targetElement.data('character-count-display').text(currentLength + "/" + maxLength);
    };

    n.CharacterCount.getCharacterCountTargetId = function ($element) {
        return $element.data('character-count-id');
    };

})(QfqNS);


/* @author Benjamin Baer <benjamin.baer@math.uzh.ch> */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */


var QfqNS = QfqNS || {};

(function (n) {

    n.Clipboard = function (data) {
        this.text = '';
        this.events = [];
        if(data.text) {
            this.copyTextToClipboardAsync(data.text);
            return;
        }
        if(data.uri) {
            this.getDataFromUri(data.uri);
            return;
        }
        this.buildError("Called Clipboard without any Data to copy");
        console.error("Clipboard has to be called with an url or text to copy");
    };

    /**
     * @private
     * Has to be a bit hacky, since copy to clipboard only works with user confirmation from an API.
     * We fake this user confirmation by listening for the mouseup event after the button press.
     * As a fallback we also listen to a click event if the API took longer than the original click.
     * @param uri
     */
    n.Clipboard.prototype.getDataFromUri = function(uri) {
        var that = this;
        $.getJSON(uri, function(data) {
            if (data.text) {
                that.text = data.text;
                $(document).click(function() {that.copyTextToClipboardAsync(that.text); $(this).off();});
                $(document).mouseup(function() {that.copyTextToClipboardAsync(that.text); $(this).off();});
            } else {
                console.error("JSON Response didn't include a variable called 'text'");
                that.buildError("Didn't receive any Data to copy");
            }
        });
    };

    n.Clipboard.prototype.copyTextToClipboard = function(text) {
        var textArea = document.createElement("textarea");
       /* var focusedElement = $(":focus"); */
        textArea.value = text;
        document.body.appendChild(textArea);
       /* textArea.focus(); */
        textArea.select();

        try {
            var successful = document.execCommand('copy');
            var msg = successful ? 'successful' : 'unsuccessful';
            console.log('Fallback: Copying text command was ' + msg);
        } catch (err) {
            this.buildError("Couldn't copy text to clipboard: " + err);
        }

        document.body.removeChild(textArea);
       /* focusedElement.focus(); */
    };

    /**
     * Tries to copy text to the clipboard using asynchronous browser API.
     * If it doesn't exist, calls copyTextToClipboard (synchronous) instead.
     * @private String text
     */
    n.Clipboard.prototype.copyTextToClipboardAsync = function(text) {
        if (!navigator.clipboard) {
            this.copyTextToClipboard(text);
            return;
        }

        var that = this;
        navigator.clipboard.writeText(text).then(function() {
            console.log('Async: Copying to clipboard was successful!');
        }, function(err) {
            that.buildError("Could not copy text: " + err);
        });
    };

    n.Clipboard.prototype.buildError = function(message) {
        var alert = new n.Alert(
            {
                type: "error",
                message: message,
                modal: true,
                buttons: [{label: "Ok", eventName: 'close'}]
            }
        );
        alert.show();
    };

})(QfqNS);

/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */
/* @depend Comment.js */
/* @depend CommentController */
/* @depend SyntaxHighlighter */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Displays Code in a stylized fashion and allows
     * to write and display comments for each line of code.
     *
     * @param form Reference to the parent qfq Element
     * @param data Object containing the to be displayed data
     * @param $container Reference to the HTML Element that displays
     *                   the code correction
     * @param $target Reference to the HTML Element where the output (comment JSON)
     *                should be stored.
     */
    n.CodeCorrection = function () {
        this.page = {};
        this.data = {};
        this.eventEmitter = new EventEmitter();
        this.$parent = {};
        this.$target = {};
        this.$rows = [];
        this.annotations = [];
        this.users = [];
        this.currentUser = {};
        this.language = "";
        this.readOnly = false;
        this.syntaxHighlight = {};
    };

    /**
     * Initializes the Code Correction object, fetches data from URL or API if needed
     */
    n.CodeCorrection.prototype.initialize = function($container, page) {
        this.$parent = $container;
        this.$target = $("#" + $container.data("target"));
        this.data = {
            url: this.$parent.data("file"),
            text: this.$parent.data("text")

        };
        this.page = page;
        this.language = this.$parent.data("highlight") || "typo3conf/ext/qfq/Resources/Public/Json/javascript.json";
        this.readOnly = this.$parent.data("view-only") || false;
        this.currentUser = $container.data("uid");
        var that = this;
        if (this.readOnly) {
            if (this.$parent.data("annotations")) {
                var jsonAnnotations = this.$parent.data("annotations");
                this.annotations = jsonAnnotations.annotations;
                this.users = jsonAnnotations.users;
            } else {
                this._importFromTarget();
            }
        } else {
            this._importFromTarget();
        }

        if (this.data.url) {
            // Get data of a file and write it to data.text
            $.get(this.data.url, function(response) {
                that.data.text = response;
                that._prepareBuild();
            });
        } else if (this.data.text) {
            this._prepareBuild();
        } else {
            console.error("[CodeCorrection] No Code to correct passed to the object.");
        }
    };

    n.CodeCorrection.prototype._importFromTarget = function() {
        if (this.$target.val()) {
            var jImport = $.parseJSON(this.$target.val());
            if (jImport.annotations) {
                this.annotations = jImport.annotations;
                console.log("[CodeCorrection] Imported Annotations: " + this.annotations.length);
            }
            if (jImport.users) {
                this.users = jImport.users;
                console.log("[CodeCorrection] Imported Users: " + this.users.length);
            }
        }
    };

    n.CodeCorrection.prototype._prepareBuild = function() {
        var that = this;
        this.syntaxHighlight = new n.SyntaxHighlighter();
        this.syntaxHighlight.importInstructions(this.language, function() {
            that._buildEditor();
        });
    };

    /**
     * Breaks up the String by line and returns it as an Array
     * @param text Unix formatted text of the Code File
     * @returns {Array} Array with the Code broken up by Line
     */
    n.CodeCorrection.prototype.createLineByLineArray = function(text) {
        var textArray = [];
        if (typeof text === 'string' || text instanceof String) {
            textArray = text.split("\n");
        }
        return textArray;
    };

    /**
     * Builds the Code Correction HTML Element that should be displayed to the user.
     * @private
     */
    n.CodeCorrection.prototype._buildEditor = function() {
        var that = this;
        var $title = $('<div/>', {
            class: 'qfqCodeCorrectionTitle'
        });
        $title.appendTo(this.$parent);
        var container = $('<div/>', {
            class: 'codeCorrectionWrap'
        });
        var lineCount = 1;
        var textArray = this.createLineByLineArray(this.data.text);
        textArray.forEach(function(line) {
            that.$rows[lineCount] = that._buildLine(lineCount, line);
            that.$rows[lineCount].appendTo(container);
            lineCount++;
        });
        container.appendTo(this.$parent);
        this._buildAnnotations();

    };

    /**
     * Checks which codelines have annotations and initializes a CommentController
     * for each of them.
     * @private
     */
    n.CodeCorrection.prototype._buildAnnotations = function() {
        for (var i = 0; i < this.annotations.length; i++) {
            var annotation = this.annotations[i];
            var $hook = this.$rows[annotation.lineNumber];
            var commentController = this._buildCommentContainer($hook);
            commentController.importComments(annotation.comments, this.users);
            $hook.append(this._getCommentMarker(annotation.comments.length));
            this._setListeners(commentController);
            this._setCommentController(annotation.lineNumber, commentController);
        }
    };

    n.CodeCorrection.prototype._getCommentMarker = function(numberOfComments) {
        var container = $('<span/>', {
            class: "badge qfq-comment-marker",
            text: numberOfComments + ' '
        });
        container.append($('<span/>', {
            class: "glyphicon glyphicon-comment"
        }));
        return container;
    };

    /**
     * Builds a Line as a combination of HTML Elements. Binds the necessary Events.
     *
     * @param lineCount
     * @param line
     * @returns {jQuery|HTMLElement}
     * @private
     */
    n.CodeCorrection.prototype._buildLine = function(lineCount, line) {
        var that = this;
        var htmlRow = $('<div/>', {
            class: 'clearfix qfqCodeLine',
            id: 'qfqC' + lineCount
        });
        htmlRow.on("click", function() { that._handleClick(htmlRow, lineCount);});
        var htmlLineNumber = $('<div/>', {
            class: 'pull-left qfqLineCount',
            text: lineCount
        });
        var cLine = line.replace('&', '&amp;')
            .replace(';', '&semi;')
            .replace('<', '&lt;')
            .replace('>', '&gt;')
            .replace(/\s/g, '&nbsp;')
            .replace('"', '&quot;')
            .replace('\'', '&apos;')
            .replace('\\', '&bsol;');
        cLine = this.syntaxHighlight.highlightLine(cLine);
        var htmlCodeLine = $('<div/>', {
            class: 'pull-right qfqCode'
        });
        htmlCodeLine.html(cLine);
        htmlLineNumber.appendTo(htmlRow);
        htmlCodeLine.appendTo(htmlRow);
        return htmlRow;
    };

    /**
     * Initializes a CommentContainer at a given jQuery Hook and returns
     * the CommentContainer object
     * @param $hook
     * @returns {QfqNS.CommentController}
     * @private
     */
    n.CodeCorrection.prototype._buildCommentContainer = function($hook) {
        var options = {
            readOnly: this.readOnly
        };
        var commentController = new n.CommentController();
        commentController.buildContainer($hook, options);
        commentController.setCurrentUser(this.currentUser);
        return commentController;
    };

    /**
     * References the CommentController in this.annotations Array
     * for easy access later.
     * @param lineCount
     * @param commentController
     * @returns {boolean}
     * @private
     */
    n.CodeCorrection.prototype._setCommentController = function(lineCount, commentController) {
        for (var i=0; i < this.annotations.length; i++) {
            if (this.annotations[i].lineNumber === lineCount) {
                this.annotations[i].commentController = commentController;
                return true;
            }
        }
        return false;
    };

    /**
     * Sets listeners for events generated by the CommentController object
     * @param commentController
     * @private
     */
    n.CodeCorrection.prototype._setListeners = function(commentController) {
        var that = this;
        commentController.on('comment.added', function(argument) {
            console.log("Catch event: " + that.annotations.length);
            console.log("With data: " + argument.data);
            that._handleNew(argument.data);
        });
        commentController.on('comment.edited', function() {
            that._updateJSON();
        });
        commentController.on('comment.removed', function(e) {
            console.log(e);
            if(that._checkUserRemoval(e.data.uid)) {
                console.log("Removed User uid: " + e.data.uid);
            }
            that._checkLineRemoval();
            that._updateJSON();
        });
    };

    n.CodeCorrection.prototype._checkLineRemoval = function() {
        var removeLines = [];
        for (var i = 0; i < this.annotations.length; i++) {
            var comments = this.annotations[i].commentController.exportComments();

            if(comments.length == 0) {
                removeLines.push(i);
            }

        }

        for (var ii = 0; ii < removeLines.length; ii++) {
            this.annotations.splice(removeLines[ii], 1);
        }
    };

    n.CodeCorrection.prototype._handleNew = function(eventData) {
        this._addCurrentUser();
        this._updateJSON();
    };

    n.CodeCorrection.prototype._checkUserRemoval = function(uid) {
        var removeUser = true;
        for (var i = 0; i < this.annotations.length; i++) {
            var comments = this.annotations[i].commentController.exportComments();
            for (var ii = 0; ii < comments.length; ii++) {
                if(comments[ii].uid === uid) {
                    removeUser = false;
                    return false;
                }
            }
        }
        if (removeUser) {
            for (var iii = 0; iii < this.users.length; iii++) {
                if (this.users[iii].uid === uid) {
                    this.users.splice(i, 1);
                }
            }
        }
        return true;
    };

    n.CodeCorrection.prototype._addCurrentUser = function() {
        if (!this.checkUserExists(this.currentUser.uid)) {
            this.users.push(this.currentUser);
        }
    };

    n.CodeCorrection.prototype.checkUserExists = function(uid) {
        for (var i = 0; i < this.users.length; i++) {
            if (this.users[i].uid === uid) {
                return true;
            }
        }
        return false;
    };

    n.CodeCorrection.prototype._updateJSON = function() {
        var jexport = {};
        jexport.annotations = [];
        for (var i = 0; i < this.annotations.length; i++) {
            var annotation = {
                lineNumber: this.annotations[i].lineNumber,
                comments: this.annotations[i].commentController.exportComments()
            };
            jexport.annotations.push(annotation);
        }
        jexport.users = this.users;
        this.$target.val(JSON.stringify(jexport));
        var that = this;
        if (this.page.qfqForm) {
            this.page.qfqForm.eventEmitter.emitEvent('form.changed',
                n.EventEmitter.makePayload(that, null));
            this.page.qfqForm.changeHandler();
            this.page.qfqForm.form.formChanged = true;
        } else {
            console.log(this.page);
            throw("Error: Couldn't initialize qfqForm - not possible to send form.changed event");
        }
    };


    /**
     * Places a comment editor under the hook.
     * @param $hook
     * @param lineCount
     * @private
     */
    n.CodeCorrection.prototype._handleClick = function($hook, lineCount) {
        var comments = {};
        if (this._hasComments(lineCount)) {
            comments = this._getComments(lineCount);
            comments.commentController.toggle();
            comments.commentController.emitEvent("new");
        } else {
            if (!this.readOnly) {
                comments.lineNumber = lineCount;
                comments.commentController = new n.CommentController();
                comments.commentController.buildContainer($hook, {readOnly: this.readOnly});
                comments.commentController.setCurrentUser(this.currentUser);
                comments.commentController.displayEditor();
                this._setListeners(comments.commentController);
                this.annotations.push(comments);
            }
        }
    };

    /**
     * Checks if a line already has comments
     * @param lineCount
     * @returns {boolean}
     * @private
     */
    n.CodeCorrection.prototype._hasComments = function(lineCount) {
        for (var i=0; i < this.annotations.length; i++) {
            if (this.annotations[i].lineNumber === lineCount) {
                return true;
            }
        }
        return false;
    };

    /**
     * Gets comments for a specific line or returns false if said
     * comments can't be found.
     * @param lineCount
     * @returns {*}
     * @private
     */
    n.CodeCorrection.prototype._getComments = function(lineCount) {
        for (var i=0; i < this.annotations.length; i++) {
            if (this.annotations[i].lineNumber === lineCount) {
                return this.annotations[i];
            }
        }
        return false;
    };

})(QfqNS);
/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Displays Comment or an Editor to create a comment
     *
     * https://www.quora.com/What-is-the-best-way-to-check-if-a-property-or-variable-is-undefined
     *
     */
    n.Comment = function (comment, user, $container, options) {
        this.comment = comment;
        this.user = user;
        this.$parent = $container;
        this.$comment = {};
        this.$text = {};
        if (arguments.length === 3) {
            this.options = { readOnly: false };
        } else {
            this.options = options;
        }
        this.childrenController = {};
        this.eventEmitter = new EventEmitter();
        this.deleted = false;
    };

    n.Comment.prototype.on = n.EventEmitter.onMixin;

    n.Comment.prototype.display = function() {
        var displayElement;
        displayElement = this._buildComment();
        displayElement.appendTo(this.$parent);
        this.$comment = displayElement;
    };

    n.Comment.prototype.getParent = function() {
        return this.$parent;
    };

    n.Comment.prototype.height = function() {
        return this.$comment.height();
    };

    n.Comment.prototype._buildComment = function(allowEdit) {
        var $commentWrap = $('<div/>', {
            class: "qfqComment"
        });
        var $avatar = $('<img>', {
            src: this.user.avatar,
            class: "qfqCommentAvatar"
        });
        $avatar.appendTo($commentWrap);
        var $topLine = $('<div />', {
            class: "qfqCommentTopLine"
        });
        $('<span />', {
            class: "qfqCommentAuthor",
            text: this.user.name + ":"
        }).appendTo($topLine);
        $('<span />', {
            class: "qfqCommentDateTime",
            text: this.comment.dateTime
        }).appendTo($topLine);
        $topLine.appendTo($commentWrap);
        var $comment = $('<div />', {
            class: "qfqCommentText"
        });
        $comment.html(this.comment.comment);
        if (!this.options.readOnly) {
            $comment.append(this._getCommands());
        }
        this.$text= $comment;
        $comment.appendTo($commentWrap);
        return $commentWrap;
    };

    n.Comment.prototype._updateText = function(text) {
          this.$text.html(text);
          this.$text.append(this._getCommands());
    };

    n.Comment.prototype._getCommands = function () {
        var $commentCommands = $("<div />", {
            class: "qfqCommentCommands"
        });
        var that = this;
        $commentCommands.append(this._getCommand("Edit", "pencil", function(e) {
            that._editMe(e);
        }));
        $commentCommands.append(this._getCommand("Delete", "trash", function(e) {
            that._deleteMe(e);
        }));
        $commentCommands.append(this._getCommand("Reply", "comment", function(e) {
            that._replyToMe(e);
        }));
        return $commentCommands;
    };

    n.Comment.prototype._getCommand = function(description, icon, onClick) {
        var $command = $('<span />', {
            class: "glyphicon glyphicon-" + icon + " qfqCommentCommand",
            title: description
        });
        $command.bind("click", this, onClick);
        return $command;
    };

    n.Comment.prototype._deleteMe = function(e) {
        this.deleted = true;
        this.$comment.remove();
        this.eventEmitter.emitEvent('comment.deleted',
            n.EventEmitter.makePayload(this, this.comment));
    };

    n.Comment.prototype._editMe = function(e) {
        this.$comment.hide();
        var that = this;
        var editor = new QfqNS.Editor();
        var $editor = editor.buildEditor(this.comment.comment);
        editor.on("editor.submit", function(e) {
             that._updateComment(e);
        });
        this.$comment.after($editor);

    };

    n.Comment.prototype._replyToMe = function(e) {
        this.eventEmitter.emitEvent('comment.reply',
            n.EventEmitter.makePayload(this, this.comment));
    };

    n.Comment.prototype._updateComment = function(e) {
        this.comment.comment = e.data.text;
        this._updateText(e.data.text);
        this.$comment.show();
        e.data.$container.remove();
        this.eventEmitter.emitEvent('comment.edited',
            n.EventEmitter.makePayload(this, this.comment));
    };

})(QfqNS);
/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */
/* @depend Comment.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Manages a group of comments
     *
     */
    n.CommentController = function () {
        this.comments = [];
        this.currentUser = {};
        this.$container = {};
        this.$parent = {};
        this.height = "auto";
        this.options = {};
        // Event Emitter is a Library qfq uses to emit custom Events.
        this.eventEmitter = new EventEmitter();
    };

    n.CommentController.prototype.on = n.EventEmitter.onMixin;

    n.CommentController.prototype.setCurrentUser = function(user) {
        this.currentUser = user;
    };

    /**
     * changeHandler emits custom events for actions.
     * Additionally writes log entries to console for easier
     * testing.
     * @private
     * @param event String containing possible change states
     * @return {boolean} true on success
     */
    n.CommentController.prototype._changeHandler = function(event, comment) {
        if (event === "edit") {
            this.eventEmitter.emitEvent('comment.edited',
                n.EventEmitter.makePayload(this, "edit"));
            console.log("[CommentController] Event comment.edit emitted");
            return true;
        } else if (event === "new") {
            this.eventEmitter.emitEvent('comment.added',
                n.EventEmitter.makePayload(this, comment));
            console.log("[CommentController] Event comment.add emitted");
            return true;
        } else if (event === "remove") {
            this.eventEmitter.emitEvent('comment.removed',
                n.EventEmitter.makePayload(this, comment));
        }
        console.error("[CommentController] Changehandler called without valid event");
        return false;
    };

    n.CommentController.prototype.emitEvent = function(event) {
        this._changeHandler(event);
    };

    n.CommentController.prototype.buildContainer = function($hook, options) {
        var $container = $("<div />", {
            class: "qfqCommentContainer"
        });
        this.options = options;
        $hook.after($container);
        this.$container = $container;
    };

    n.CommentController.prototype.hasComments = function() {
        if (this.comments.length > 0) {
            return true;
        } else {
            return false;
        }
    };

    n.CommentController.prototype.toggle = function() {
        this.$container.slideToggle("swing");
    };

    n.CommentController.prototype.getComment = function(reference) {
        if (reference < this.comments.length && reference >= 0) {
            return this.comments[reference];
        } else {
            console.error("[CommentController] Requested Comment doesn't exist");
            return false;
        }
    };

    n.CommentController.prototype.addComment = function(comment, user) {
        var commentObject = new n.Comment(comment, user, this.$container, this.options);
        commentObject.display();
        this.comments.push(commentObject);
        this._changeHandler("new", commentObject);
        this._setListeners(commentObject);
        this.updateHeight();
        return this.comments.length - 1;
    };

    n.CommentController.prototype._setListeners = function(commentObject) {
        var that = this;
        commentObject.on('comment.edited', function(e) {
            that.updateComment(e.data);
        });
        commentObject.on('comment.reply', function(e) {
            that.requestReply(e.data);
        });
        commentObject.on('comment.deleted', function(e) {
            that.removeComment(e);
        });
    };

    n.CommentController.prototype.displayComments = function() {
        for (var i = 0; this.comments; i++) {
            this.comments[i].display();
        }
        this.updateHeight();
    };

    n.CommentController.prototype.displayEditor = function() {
        if (!this.options.readOnly) {
            var editor = new n.Editor();
            var that = this;
            var $editor = editor.buildEditor();
            editor.on("editor.submit", function (editor) {
                that._handleEditorSubmit(editor);
            });
            $editor.appendTo(this.$container);
            editor.$textArea.focus();
        }
    };

    n.CommentController.prototype._handleEditorSubmit = function(editor) {
        var comment = this.buildCommentObject(editor.data.text);
        this.addComment(comment, this.currentUser);
        editor.data.destroy();
    };

    n.CommentController.prototype.buildCommentObject = function(text) {
        var comment = {};
        comment.comment = text.replace("&quot;", "'");
        comment.dateTime = new Date().toLocaleString('de-CH');
        comment.uid = this.currentUser.uid;
        return comment;
    };

    n.CommentController.prototype.getContainer = function() {
        return this.$container;
    };

    n.CommentController.prototype.removeComment = function(reference) {
        this._changeHandler("remove", reference);
    };

    n.CommentController.prototype.updateComment = function(data) {
        console.log("[Comment Changed] User: " + data.uid +
            " Text:" + data.comment.substring(0, 20) + "...");
        this.emitEvent("edit");
    };

    n.CommentController.prototype.requestReply = function(data) {
        this.displayEditor();
    };

    n.CommentController.prototype.updateHeight = function() {
        //this.height = this.$container.height();
        //this.$container.css("max-height", this.height);
    };

    n.CommentController.prototype.importComments = function(comments, users) {
        for (var i=0; i < comments.length; i++) {
            var user = this._searchUsersByUid(users, comments[i].uid);
            this.addComment(comments[i], user);
        }
        if (comments.length === 0) {
            this.displayEditor();
        }
    };

    n.CommentController.prototype.exportComments = function() {
        var comments = [];
        for(var i=0; i < this.comments.length; i++) {
            if (!this.comments[i].deleted) {
                comments.push(this.comments[i].comment);
            }
        }
        return comments;
    };

    n.CommentController.prototype._searchUsersByUid = function (users, uid) {
        for (var i=0; i < users.length; i++) {
            if (users[i].uid === uid) {
                return users[i];
            }
        }
    };

})(QfqNS);
/**
 * Created by raos on 6/28/17.
 */

/* @depend QfqEvents.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    n.Dirty = function (dirtyUrl) {
        this.dirtyUrl = dirtyUrl;

        this.eventEmitter = new EventEmitter();
        this.successTimerId = null;
        this.deniedTimerId = null;
        this.lockTimeoutInMilliseconds = n.Dirty.NO_LOCK_TIMEOUT;
    };

    n.Dirty.NO_LOCK_TIMEOUT = -1;

    n.Dirty.EVENTS = {
        STARTED: 'dirty.notify.started',
        SUCCESS: 'dirty.notify.success',
        ENDED: 'dirty.notify.ended',
        FAILED: 'dirty.notify.failed',
        DENIED: 'dirty.notify.denied',
        DENIED_TIMEOUT: 'dirty.timeout.denied',
        SUCCESS_TIMEOUT: 'dirty.timeout.success',

        RELEASE_STARTED: 'dirty.release.started',
        RELEASE_ENDED: 'dirty.release.ended',
        RELEASE_SUCCESS: 'dirty.release.success',
        RELEASE_FAILED: 'dirty.release.failed',

        RENEWAL_STARTED: 'dirty.renewal.started',
        RENEWAL_ENDED: 'dirty.renewal.ended',
        RENEWAL_SUCCESS: 'dirty.renewal.success',
        RENEWAL_DENIED: 'dirty.renewal.denied',
        RENEWAL_FAILED: 'dirty.renewal.failed',

        CHECK_STARTED: 'dirty.check.started',
        CHECK_SUCCESS: 'dirty.check.success',
        CHECK_FAILED: 'dirty.check.failed',
        CHECK_ENDED: 'dirty.check.ended',
    };

    n.Dirty.ENDPOINT_OPERATIONS = {
        /** Acquire Lock */
        LOCK: "lock",
        /** Release Lock */
        RELEASE: "release",
        /** Renew Lock */
        RENEW: "extend",
        /** Check Lock */
        CHECK: "check"
    };

    n.Dirty.MINIMUM_TIMER_DELAY_IN_SECONDS = 5;
    n.Dirty.MILLISECONDS_PER_SECOND = 1000;

    n.Dirty.prototype.on = n.EventEmitter.onMixin;

    /**
     * Notify the server that SIP is becoming dirty.
     *
     * @param sip {string} sip.
     * @public
     */
    n.Dirty.prototype.notify = function (sip, optionalQueryParameters) {
        var eventData;

        if (!this.dirtyUrl) {
            n.Log.debug("notify: cannot contact server, no dirtyUrl.");
            return;
        }
        eventData = n.EventEmitter.makePayload(this, null);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.STARTED, eventData);
        $.ajax({
            url: this.makeUrl(sip, n.Dirty.ENDPOINT_OPERATIONS.LOCK, optionalQueryParameters),
            type: 'GET',
            cache: false
        })
            .done(this.ajaxNotifySuccessHandler.bind(this))
            .fail(this.ajaxNotifyErrorHandler.bind(this));
    };

    /**
     * @private
     * @param data
     * @param textStatus
     * @param jqXHR
     */
    n.Dirty.prototype.ajaxNotifySuccessHandler = function (data, textStatus, jqXHR) {
        var eventData = n.EventEmitter.makePayload(this, data);
        if (data.status && data.status === "success") {
            this.eventEmitter.emitEvent(n.Dirty.EVENTS.SUCCESS, eventData);
        } else {
            this.eventEmitter.emitEvent(n.Dirty.EVENTS.DENIED, eventData);
        }

        this.setTimeoutIfRequired(data, eventData);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.ENDED, eventData);
    };

    /**
     * Set timers based on server response. Timers are only set when `ajaxData` contains a `lock_timeout` attribute.
     * `lock_timeout` is expected to hold the timeout in seconds.
     *
     * @param ajaxData data received from server
     * @param eventData data passed when event is emitted.
     * @private
     */
    n.Dirty.prototype.setTimeoutIfRequired = function (ajaxData, eventData) {
        var timeoutInMilliseconds;

        if (!ajaxData.lock_timeout || ajaxData.lock_timeout < n.Dirty.MINIMUM_TIMER_DELAY_IN_SECONDS) {
            this.lockTimeoutInMilliseconds = n.Dirty.NO_LOCK_TIMEOUT;
            return;
        }

        this.lockTimeoutInMilliseconds = ajaxData.lock_timeout * n.Dirty.MILLISECONDS_PER_SECOND;

        if (ajaxData.status &&
            ajaxData.status === "success") {
            this.clearSuccessTimeoutTimerIfSet();

            this.successTimerId = setTimeout((function (that, data) {
                return function () {
                    that.eventEmitter.emitEvent(n.Dirty.EVENTS.SUCCESS_TIMEOUT, data);
                };
            })(this, eventData), this.lockTimeoutInMilliseconds);
        } else {
            this.clearDeniedTimeoutTimerIfSet();

            this.deniedTimerId = setTimeout((function (that, data) {
                return function () {
                    that.eventEmitter.emitEvent(n.Dirty.EVENTS.DENIED_TIMEOUT, data);
                };
            })(this, eventData), this.lockTimeoutInMilliseconds);
        }
    };

    /**
     * @public
     */
    n.Dirty.prototype.clearSuccessTimeoutTimerIfSet = function () {
        if (this.successTimerId) {
            clearTimeout(this.successTimerId);
            this.successTimerId = null;
        }
    };

    /**
     * @private
     */
    n.Dirty.prototype.clearDeniedTimeoutTimerIfSet = function () {
        if (this.deniedTimerId) {
            clearTimeout(this.deniedTimerId);
            this.deniedTimerId = null;
        }
    };


    /**
     * @private
     * @param jqXHR
     * @param textStatus
     * @param errorThrown
     */
    n.Dirty.prototype.ajaxNotifyErrorHandler = function (jqXHR, textStatus, errorThrown) {
        var eventData = n.EventEmitter.makePayload(this, null, {
            textStatus: textStatus,
            errorThrown: errorThrown,
            jqXHR: jqXHR
        });

        this.lockTimeoutInMilliseconds = n.Dirty.NO_LOCK_TIMEOUT;

        this.eventEmitter.emitEvent(n.Dirty.EVENTS.FAILED, eventData);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.ENDED, eventData);
    };

    /**
     * Release lock.
     *
     * @param sip {string} sip
     * @public
     */
    n.Dirty.prototype.release = function (sip, optionalQueryParameters, async) {
        var eventData;
        if (async === undefined) {
            async = true;
        }

        if (!this.dirtyUrl) {
            n.Log.debug("release: cannot contact server, no dirtyUrl.");
            return;
        }
        eventData = n.EventEmitter.makePayload(this, null);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.RELEASE_STARTED, eventData);
        $.ajax({
            url: this.makeUrl(sip, n.Dirty.ENDPOINT_OPERATIONS.RELEASE, optionalQueryParameters),
            type: 'GET',
            cache: false,
            async: async
        })
            .done(this.ajaxReleaseSuccessHandler.bind(this))
            .fail(this.ajaxReleaseErrorHandler.bind(this));
    };

    /**
     * @private
     * @param data
     * @param textStatus
     * @param jqXHR
     */
    n.Dirty.prototype.ajaxReleaseSuccessHandler = function (data, textStatus, jqXHR) {
        n.Log.debug("Dirty Release: Response received.");
        var eventData = n.EventEmitter.makePayload(this, data);
        if (data.status && data.status === "success") {
            this.eventEmitter.emitEvent(n.Dirty.EVENTS.RELEASE_SUCCESS, eventData);
        } else {
            this.eventEmitter.emitEvent(n.Dirty.EVENTS.RELEASE_FAILED, eventData);
        }

        this.lockTimeoutInMilliseconds = n.Dirty.NO_LOCK_TIMEOUT;

        this.eventEmitter.emitEvent(n.Dirty.EVENTS.RELEASE_ENDED, eventData);
    };

    /**
     * @private
     * @param jqXHR
     * @param textStatus
     * @param errorThrown
     */
    n.Dirty.prototype.ajaxReleaseErrorHandler = function (jqXHR, textStatus, errorThrown) {
        var eventData = n.EventEmitter.makePayload(this, null, {
            textStatus: textStatus,
            errorThrown: errorThrown,
            jqXHR: jqXHR
        });

        this.lockTimeoutInMilliseconds = n.Dirty.NO_LOCK_TIMEOUT;

        this.eventEmitter.emitEvent(n.Dirty.EVENTS.RELEASE_FAILED, eventData);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.RELEASE_ENDED, eventData);
    };

    /**
     * Request renewal of lock.
     *
     * @param sip {string} sip.
     * @public
     */
    n.Dirty.prototype.renew = function (sip, optionalQueryParameters) {
        var eventData;

        if (!this.dirtyUrl) {
            n.Log.debug("renew: cannot contact server, no dirtyUrl.");
            return;
        }
        eventData = n.EventEmitter.makePayload(this, null);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.RENEWAL_STARTED, eventData);
        $.ajax({
            url: this.makeUrl(sip, n.Dirty.ENDPOINT_OPERATIONS.RENEW, optionalQueryParameters),
            type: 'GET',
            cache: false
        })
            .done(this.ajaxRenewalSuccessHandler.bind(this))
            .fail(this.ajaxRenewalErrorHandler.bind(this));
    };

    /**
     * @private
     * @param data
     * @param textStatus
     * @param jqXHR
     */
    n.Dirty.prototype.ajaxRenewalSuccessHandler = function (data, textStatus, jqXHR) {
        var eventData = n.EventEmitter.makePayload(this, data);
        if (data.status && data.status === "success") {
            this.eventEmitter.emitEvent(n.Dirty.EVENTS.RENEWAL_SUCCESS, eventData);
        } else {
            this.eventEmitter.emitEvent(n.Dirty.EVENTS.RENEWAL_DENIED, eventData);
        }
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.RENEWAL_ENDED, eventData);

        this.setTimeoutIfRequired(data, eventData);
    };

    /**
     * @private
     * @param jqXHR
     * @param textStatus
     * @param errorThrown
     */
    n.Dirty.prototype.ajaxRenewalErrorHandler = function (jqXHR, textStatus, errorThrown) {
        var eventData = n.EventEmitter.makePayload(this, null, {
            textStatus: textStatus,
            errorThrown: errorThrown,
            jqXHR: jqXHR
        });

        this.lockTimeoutInMilliseconds = n.Dirty.NO_LOCK_TIMEOUT;

        this.eventEmitter.emitEvent(n.Dirty.EVENTS.RENEWAL_FAILED, eventData);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.RENEWAL_ENDED, eventData);
    };

    /**
     * Create URL to endpoint.
     *
     * @param sip {string} sip of the request
     * @param operation {string} operation
     * @returns {string} complete url
     * @private
     */
    n.Dirty.prototype.makeUrl = function (sip, operation, optionalQueryParameters) {
        var queryString, mergedQueryParameterObject;

        mergedQueryParameterObject = $.extend(
            optionalQueryParameters,
            {
                s: sip,
                action: operation
            }
        );

        queryString = $.param(mergedQueryParameterObject);
        return this.dirtyUrl + "?" + queryString;
    };

    /**
     * Check with the server if record is already locked.
     *
     * @param sip {string} sip.
     * @public
     */
    n.Dirty.prototype.check = function (sip, optionalQueryParameters) {
        var eventData;

        if (!this.dirtyUrl) {
            n.Log.debug("notify: cannot contact server, no dirtyUrl.");
            return;
        }
        eventData = n.EventEmitter.makePayload(this, null);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.CHECK_STARTED, eventData);
        $.ajax({
            url: this.makeUrl(sip, n.Dirty.ENDPOINT_OPERATIONS.CHECK, optionalQueryParameters),
            type: 'GET',
            cache: false
        })
            .done(this.ajaxCheckSuccessHandler.bind(this))
            .fail(this.ajaxCheckErrorHandler.bind(this));
    };

    /**
     * @private
     * @param data
     * @param textStatus
     * @param jqXHR
     */
    n.Dirty.prototype.ajaxCheckSuccessHandler = function (data, textStatus, jqXHR) {
        var eventData = n.EventEmitter.makePayload(this, data);
        if (data.status && data.status === "success") {
            this.eventEmitter.emitEvent(n.Dirty.EVENTS.CHECK_SUCCESS, eventData);
        } else {
            this.eventEmitter.emitEvent(n.Dirty.EVENTS.CHECK_FAILED, eventData);
        }

        this.setTimeoutIfRequired(data, eventData);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.CHECK_ENDED, eventData);
    };

    /**
     * @private
     * @param jqXHR
     * @param textStatus
     * @param errorThrown
     */
    n.Dirty.prototype.ajaxCheckErrorHandler = function (jqXHR, textStatus, errorThrown) {
        var eventData = n.EventEmitter.makePayload(this, null, {
            textStatus: textStatus,
            errorThrown: errorThrown,
            jqXHR: jqXHR
        });

        this.lockTimeoutInMilliseconds = n.Dirty.NO_LOCK_TIMEOUT;

        this.eventEmitter.emitEvent(n.Dirty.EVENTS.CHECK_FAILED, eventData);
        this.eventEmitter.emitEvent(n.Dirty.EVENTS.CHECK_ENDED, eventData);
    };
})(QfqNS);

/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Display content of a file
     *
     *
     * @param {object} options option object has following properties
     * @param {string} options.webworker Path to webworker JS file
     * @param {string} options.filePath file that has to be displayed
     * @param {number} [options.interval] time interval in milliseconds.
     * @param {string} [options.highlight] Path to highlight instructions, optional
     * @param {string} [options.targetId] HTML id of the target where it should be displayed
     * @param {boolean} [options.isContinuous] Appends the text instead of replacing it
     *
     * @constructor
     */
    n.DisplayFile = function (options) {
        this.options = options;
        this.options.webworker = options.webworker ||
            "typo3conf/ext/qfq/Resources/Public/Javascript/Worker/GetFileContent.js";
    };

    n.DisplayFile.prototype.show = function() {
        var that = this;
        if (this.options.filePath) {
            if (this.options.interval) {
                var webWorker = new Worker(this.options.webworker);
                webWorker.postMessage([this.options.filePath, this.options.interval]);

                webWorker.onmessage = function(e) {
                      that._handleResponse(e);
                };
            }
        } else {
            console.alert("No filePath supplied");
          }
    };

    n.DisplayFile.prototype._handleResponse = function(e) {
        var result = e.data;
        if (this.options.targetId) {
            var $target = $("#" + this.options.targetId);
            if (this.options.isContinuous) {
                $target.append(result);
            } else {
                $target.text(result);
            }
        }
    };

})(QfqNS);
/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */
/* @depend ElementUpdate.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     *
     */
    n.DragAndDrop = function ($hook) {
        this.$container = $hook;
        this.eventEmitter = new EventEmitter();
        this.dropZones = [];
        this.elements = [];
        this.active = false;
        this.api = $hook.data("dnd-api");
        this.draggedId = "";
        this.lastChild = "";
        this.$tempObject = {};
    };

    n.DragAndDrop.prototype.on = n.EventEmitter.onMixin;

    n.DragAndDrop.prototype.buildDropArea = function(position, relatedId, otherPos) {
        var that = this;
        var $dropArea = {};

        if (this.$container.data("columns")) {
            $dropArea = $("<tr />", {
                class: "qfqDropTarget"
            });
            var $fluff = $("<td />", {
                class: "qfqDropTarget",
                colspan: this.$container.data("columns")
            });
            $fluff.appendTo($dropArea);
        } else {
            $dropArea = $("<div />", {
                class: "qfqDropTarget"
            });
        }

        $dropArea.data("position", position);
        $dropArea.data("related", relatedId);
        $dropArea.data("other-pos", otherPos);
        $dropArea.on("dragenter", function(e) {
            e.preventDefault();
            $dropArea.addClass("qfqTargetDisplay");
        });
        $dropArea.on("dragover", function(e) {
            e.preventDefault();
            e.originalEvent.dataTransfer.dropEffect = "move";
        });
        $dropArea.on("drop", function(e) {
            e.originalEvent.preventDefault();
            that._moveObjectBefore(e, $dropArea);
        });

        return $dropArea;
    };

    n.DragAndDrop.prototype.setDropZones = function($objects) {
        var that = this;

        $objects.each(function() {
            var $dropZone = $(this);
            $dropZone.on("dragenter", function(e) {
                e.preventDefault();
                $dropZone.addClass("qfqTargetDisplay");
                e.Effect = "all";
                that._handleDragEnter(e);
            });
            $dropZone.on("dragleave", function(e) {
                e.preventDefault();
                $dropZone.removeClass("qfqTargetDisplay");
                that._handleDragLeave(e);
            });
            $dropZone.on("dragover", function(e) {
                e.preventDefault();
                e.stopPropagation();
            });
            $dropZone.on("drop", function(e) {
                e.preventDefault();
                e.stopPropagation();
                that._dropHandler(e);
            });
            $dropZone.css("z-index", 5);
            that.dropZones.push($dropZone);
        });
    };

    n.DragAndDrop.prototype.setElements = function($objects) {
        var that = this;

        $objects.each(function() {
            var $element = $(this);
            $element.prop("draggable", true);
            $element.on("dragstart", function(e) {
                that.draggedId = $element[0].id;
            });
            that.elements.push($element);
        });
    };

    n.DragAndDrop.prototype._handleDragEnter = function(event) {
        var $tempObject = $("#" + this.draggedId).clone();
        $tempObject.css("opacity", 0.5);
        $tempObject.css("z-index", 0);
        $tempObject.off();
        if (this.$tempObject[0]) {
            if ($tempObject[0].id !== this.$tempObject[0].id) {
                this.$tempObject = $tempObject;
                this.$tempObject.appendTo($("#" + event.currentTarget.id));
            }
        } else {
            this.$tempObject = $tempObject;
            this.$tempObject.appendTo($("#" + event.currentTarget.id));
        }
    };

    n.DragAndDrop.prototype._handleDragLeave = function(event) {
        if(this.$tempObject[0]) {
            this.$tempObject.remove();
            this.$tempObject = {};
        }
    };

    n.DragAndDrop.prototype.makeBasketCase = function() {
        var dzSelector = this.$container.data("dnd-dropzone") || false;
        var elSelector = this.$container.data("dnd-element") || false;

        if (elSelector) {
            this.setElements($("." + elSelector));
        }
        if (dzSelector) {
            this.setDropZones($("." + dzSelector));
        }
    };

    n.DragAndDrop.prototype._dropHandler = function(event) {
        if(this.$tempObject[0]) {
            this.$tempObject.remove();
            this.$tempObject = {};
        }
        $("#" + this.draggedId).appendTo($("#" + event.currentTarget.id));
    };

    n.DragAndDrop.prototype._buildOrderDropZones = function($object, e) {
        this.removeDropAreas();

        //if ($object[0].id !== this.draggedId) {
            //if ($object.data("dnd-position") !== $("#" + this.draggedId).data("dnd-position") + 1) {
                var $dropArea = this.buildDropArea("before", $object.data("dnd-id"), $object.data("dnd-position"));
                //$dropArea.hide();
                $object.before($dropArea);
                //$dropArea.slideDown(500, 'swing');

                var $lastDrop = this.buildDropArea("after", $object.data("dnd-id"), $object.data("dnd-position"));
                //$lastDrop.hide();
                $object.after($lastDrop);
                //$lastDrop.slideDown(500, 'swing');
                //$lastDrop.appendTo(this.$container);
            //}

            /*if ($object[0].id === this.lastChild) {
                var $lastDrop = this.buildDropArea("after", $object.data("dnd-id"), $object.data("dnd-position"));
                $lastDrop.appendTo(this.$container);
            }*/
        //}
    };

    n.DragAndDrop.prototype.removeDropAreas = function() {
        if (this.$container.data("column")) {
            this.$container.children(".qfqTempTable").remove();
        }

        this.$container.children(".qfqDropTarget").remove();
    };

    n.DragAndDrop.prototype.removeDropTarget = function(e) {
        console.log(e);
    };

    n.DragAndDrop.prototype.makeSortable = function() {
        var that = this;
        var numberOfChildren = this.$container.children().length;
        var count = 0;

        this.$container.children().each( function() {
            count++;

            var child = $(this);
            if (numberOfChildren === count) {
                that.lastChild = child[0].id;
            }
            child.data("dnd-position", count);
            child.prop("draggable", true);
            child.on("dragstart", function(e) {
                e.originalEvent.dataTransfer.setData("text", child[0].id);
                that.draggedId = child[0].id;
                that.active = true;
                e.originalEvent.dataTransfer.effectAllowed = "move";
            });
            child.on("dragenter", function(e) {
                if (that.active) {
                    that._buildOrderDropZones($(this), e);
                }
            });
            child.on("dragend", function() {
                that.active = false;
                that.removeDropAreas();
            });
        });
    };

    n.DragAndDrop.prototype._moveObjectBefore = function(e, $hook) {
        var id = e.originalEvent.dataTransfer.getData("text");
        var $object = $("#" + id);
        var posTo = $hook.data("position");

        if (posTo === "after") {
            this.lastChild = $object[0].id;
        }

        $hook.before(document.getElementById(id));
        this._buildOrderUpdate($object, $hook.data("position"), $hook.data("related"), $hook.data("other-pos"));
        this.removeDropAreas();
    };

    n.DragAndDrop.prototype._buildOrderUpdate = function($object, position, otherId, otherPos) {
        var jObject = {};
        jObject.dragId = $object.data("dnd-id");
        jObject.dragPosition = $object.data("dnd-position");
        jObject.setTo = position;
        jObject.hoverId = otherId;
        jObject.hoverPosition = otherPos;
        this._sendToAPI(jObject);
    };

    n.DragAndDrop.prototype._sendToAPI = function(object) {
        var that = this;
        $.getJSON(this.api, object, function(data) {
            that._successHandler(data);
        });
    };

    n.DragAndDrop.prototype._successHandler = function(data) {
        if (data.status === "error") {
            var alert = new n.Alert({
                type: data.status,
                message: data.message,
                modal: true,
                buttons: [{
                    label: "Ok", eventName: "ok"
                }]
            });
            alert.show();
            console.error(data.message);
        } else {
            console.log("status:" + data.status + " message: " + data.message);
            if (data['element-update']) {
                var configuration = data['element-update'];
                n.ElementUpdate.updateAll(configuration);
            }
        }
    };

})(QfqNS);
/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */
/* @depend Element/ElementBuilder.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {

    n.Droplet = function (url, color) {
        this._$trigger = {};
        this.position = {};
        this._$container = {};
        this.visible = false;
        this.eventEmitter = new EventEmitter();
        this.content = {};
        this.forms = [];
        this.url = url;
        this.color = color;
    };

    n.Droplet.prototype.on = n.EventEmitter.onMixin;

    n.Droplet.prototype.setTrigger = function($trigger) {
        this._$trigger = $trigger;
        var that = this;
        this._$trigger.click(function() {that.toggleDroplet();});
    };

    n.Droplet.prototype.setContainer = function($container) {
        this._$container = $container;
    };

    n.Droplet.prototype.setPosition = function(left, top) {
        this.position = {
            left: left,
            top: top
        };
    };

    n.Droplet.prototype.getContent = function() {
        var that = this;
        var response = jQuery.getJSON(this.url, function(data) {
            that._processResponse(data);
        });
        this._$container.text("Getting Data...");
    };

    n.Droplet.prototype._processResponse = function(data) {
        this._$container.text('');
        for(var i=0; i < data.elements.length; i++) {
            var element = data.elements[i];
            var $element = new n.ElementBuilder(element);
            this._$container.append($element.display());
            var that = this;
            if (element.type === "form") this.forms.push($element);
        }

        this.forms[0].on('form.submit.success',
            function() { that.toggleDroplet(); });
    };

    n.Droplet.prototype.createContainerBellowTrigger = function () {
        this.setPosition(
            this._$trigger.offset().left,
            this._$trigger.offset().top + this._$trigger.outerHeight()
        );
        var $container = $("<div />", {
            class: "qfq-droplet-container qfq-droplet-" + this.color
        });
        $container.css({
            position: 'absolute',
            zIndex: '100',
            top: this.position.top + 10 + "px",
            left: this.position.left + "px"
        });

        $(document.body).append($container);
        $container.addClass("qfq-droplet-container");
        $container.hide();
        return $container;
    };

    n.Droplet.prototype.getContainer = function() {
        if (this._$container) {
            console.error("No container has been created");
        } else {
            return this._$container;
        }
    };

    n.Droplet.prototype.toggleDroplet = function () {
        if (this.visible) {
            this._$container.hide();
            this.visible = false;
            this.forms = [];
        } else {
            this.eventEmitter.emitEvent('droplet.toggle',
                n.EventEmitter.makePayload(this, "toggle"));
            this._$container.show();
            this.visible = true;
            if (this.url) {
                this.getContent();
            }
        }
    };

    n.Droplet.prototype.hideDroplet = function() {
        if (this.visible) {
            this._$container.hide();
            this.visible = false;
        }
    };

})(QfqNS);

/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */
/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */
/* @depend Droplet.js */
/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
(function (n) {
    'use strict';

    n.DropletController = function() {
        this.droplets = [];
        this.eventEmitter = new EventEmitter();
    };

    n.DropletController.prototype.setUpDroplets = function() {
        var that = this;

        $(".qfq-droplet").each(function() {
            var url = false;
            var color = "grey";
            if ($(this).data("content")) {
                url = $(this).data("content");
            }
            if ($(this).data("color")) {
                color = $(this).data("color");
            }
            var droplet = new QfqNS.Droplet(url, color);
            droplet.setTrigger($(this));
            droplet.setContainer(droplet.createContainerBellowTrigger());

            that.droplets.push(droplet);
            droplet.on('droplet.toggle', function() { that.hideDroplets(); });
        });
    };

    n.DropletController.prototype.getDroplet = function(reference) {
        if (reference < this.droplets.length && reference >= 0) {
            return this.droplets[reference];
        }
    };

    n.DropletController.prototype.hideDroplets = function() {
        for(var i=0; i < this.droplets.length; i++) {
            this.droplets[i].hideDroplet();
        }
    };

})(QfqNS);
/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */
/* @depend Comment.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Manages Text Editor for Comments
     *
     */
    n.Editor = function () {
        this.$container = {};
        this.$textArea = {};
        this.$submitButton = {};
        this.text = "";
        // Event Emitter is a Library qfq uses to emit custom Events.
        this.eventEmitter = new EventEmitter();
    };

    n.Editor.prototype.on = n.EventEmitter.onMixin;


    n.Editor.prototype.buildEditor = function(text) {
        var that = this;
        this.$container = $("<div />", {
            class: "qfqEditorContainer"
        });
        this.$textArea = $("<div />", {
            class: "qfqEditor",
            contenteditable: true,
            tabindex: 0
        });
        if (text) {
            this.$textArea.html(text);
        }
        this.$textArea.keydown(function() { that.activateSubmit(); });
        this._addEditorControls();
        var controls = $("<div />", {
            class: "qfqEditorControls"
        });
        var submitButton = $("<button />", {
            class: "btn btn-primary",
            disabled: true,
            text: "Send"
        });
        submitButton.on("click", function() { that._handleClick();});
        submitButton.appendTo(controls);
        this.$submitButton = submitButton;

        this.$textArea.appendTo(this.$container);
        controls.appendTo(this.$container);
        return this.$container;
    };

    n.Editor.prototype.activateSubmit = function() {
        this.$submitButton.attr("disabled", false);
    };

    n.Editor.prototype.destroy = function() {
          this.$container.remove();
    };

    n.Editor.prototype._handleClick = function() {
        var that = this;
        var text = this.$textArea.text().replace(/\s/g, '');
        if (text === "") {
            var alert = new n.Alert({
                message: "Please input text before sending.",
                type: "warning",
                modal: true,
                buttons: [
                    {label: "Ok", eventName: "ok"}
                ]
            });
            alert.show();
        } else {
            this.text = this.$textArea.html();
            this.eventEmitter.emitEvent('editor.submit',
                n.EventEmitter.makePayload(this, that));
        }
    };

    n.Editor.prototype._playingWithSelection = function() {
        var selection = window.getSelection();
        console.log(selection);
        if (!selection.isCollapsed) {
            var currentNode = selection.anchorNode;
            var count = 1;
            if (selection.anchorNode.nextSibling !== null) {
                if (currentNode.nextSibling.isSameNode(selection.focusNode)) {
                    console.log("Selected 2 nodes");
                } else {
                    while (!currentNode.isSameNode(selection.focusNode)) {
                        count++;
                        if (currentNode.nextSibling !== null) {
                            currentNode = currentNode.nextSibling;
                        } else {
                            console.error("whoops");
                            break;
                        }
                    }
                    console.log("Selected " + count + " nodes");
                }
            } else {
                if (selection.focusNode.nextSibling !== null) {
                    if (selection.focusNode.nextSibling.isSameNode(selection.anchorNode)) {
                        console.log("Selected 2 nodes");
                    } else {
                        currentNode = selection.focusNode;
                        while (!currentNode.isSameNode(selection.focusNode)) {
                            count++;
                            if (currentNode.previousSibling !== null) {
                                currentNode = currentNode.previousSibling;
                            } else {
                                console.error("whoops");
                                break;
                            }
                        }
                        console.log("Selected " + count + " nodes");
                    }
                } else {
                    console.log(selection.toString());
                    selection.deleteFromDocument();
                }
            }
        } else {
            console.log("Selected one node");
        }
    };

    n.Editor.prototype._addEditorControls = function() {
        var that = this;
        var $addCode = $("<span />", {
            class: "glyphicon glyphicon-console qfqEditorControl qfqCodeAdd"
        });
        $addCode.on("click", function() { that._addCodeElement();});
        $addCode.appendTo(this.$container);
        var $addList = $("<span />", {
            class: "glyphicon glyphicon-list qfqEditorControl qfqCodeList"
        });
        $addList.on("click", function() { that._addListElement();});
        $addList.appendTo(this.$container);
        this._addStandardTextElement();
    };

    n.Editor.prototype._addCodeElement = function() {
        var $code = $("<code />", {
            class: "qfqCodeElement",
            text: "Write your code here"
        });
        $code.appendTo(this.$textArea);
        this._addStandardTextElement();
    };

    n.Editor.prototype._addListElement = function() {
        var $unsortedList = $("<ul />");
        var selection = window.getSelection();
        console.log(selection);
        var $listElement = $("<li />", {
            text: "Write your list"
        });
        $listElement.appendTo($unsortedList);
        $unsortedList.appendTo(this.$textArea);
        this._addStandardTextElement();
    };

    n.Editor.prototype._addStandardTextElement = function() {
        var $regular = $("<p />", {
                text: "\xA0"
            }
        );
        $regular.appendTo(this.$textArea);
    };

}(QfqNS));
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */
/* global console */

/* @depend Utils.js */

var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Update HTML elements by a given id. Supports adding, setting, and removing attributes as well as setting the
     * text enclosed by the element.
     *
     * @type {{}}
     */
    n.ElementUpdate = {};


    /**
     * Update all elements according to configuration.
     *
     * @param config JSON configuration
     * @public
     */
    n.ElementUpdate.updateAll = function (config) {
        for (var idName in config) {
            if (!config.hasOwnProperty(idName)) {
                continue;
            }

            n.ElementUpdate.update(idName, config[idName]);
        }
        // Have to reapply QMORE
        n.initializeQmore();
    };

    /**
     *
     * @param elementId id of the element to update
     * @param config configuration
     */
    n.ElementUpdate.update = function (elementId, config) {
        var $element = n.ElementUpdate.$getElementById(elementId);

        if (config.subElements && config.typeToReplace) {
            n.ElementUpdate.handleReplaceElement($element, config.subElements, config.typeToReplace);
        }

        if (config.attr) {
            n.ElementUpdate.handleAttributeUpdate($element, config.attr);
        }

        if (config.content || config.content === '') {
            n.ElementUpdate.setElementText($element, config.content);
        }

    };

    n.ElementUpdate.$getElementById = function (id) {
        return $("#" + n.escapeJqueryIdSelector(id));
    };

    n.ElementUpdate.handleAttributeUpdate = function ($element, attributes) {
        var attributeValue;
        for (var attributeName in attributes) {
            if (!attributes.hasOwnProperty(attributeName)) {
                continue;
            }

            attributeValue = attributes[attributeName];

            if (attributeValue === null || attributeValue === false || attributeValue === "false") {
                n.ElementUpdate.deleteAttribute($element, attributeName);
            } else {
                n.ElementUpdate.setAttribute($element, attributeName, attributeValue);
            }
        }
    };

    n.ElementUpdate.setAttribute = function ($element, attributeName, attributeValue) {
        // Skip ICJ visual inputs - they manage their own values through the ICJ editor
        if ($element.data('icjTextarea') === 'true' || $element.hasClass('qfq-icj-input')) {
            return;
        }
        $element.attr(attributeName, attributeValue);
        if (attributeName.toLowerCase() === "value") {
            $element.val(attributeValue);
            $element.trigger('qfqChange');
        }
    };

    n.ElementUpdate.deleteAttribute = function ($element, attributeName) {
        $element.removeAttr(attributeName);
    };

    n.ElementUpdate.setElementText = function ($element, text) {
        $element.empty().append($.parseHTML(text));
    };

    /**
     * Replaces existing form elements with new ones to support dynamic updates for elements that generate multiple sub-elements
     * and whose content may change based on SQL queries.
     *
     * Example:
     * Before: handleReplaceElement($element = <div>, subElements = [<label>Text10</label>, <label>Text11</label>, <label>Text12</label>, <label>Text13</label>], typeToReplace = 'label')
     *
     * Original HTML:
     * <div>
     *     <label>Text1</label>
     *     <label>Text2</label>
     *     <label>Text3</label>
     *     <label>Text4</label>
     *     <label>Text5</label>
     *     <label>Text6</label>
     *     <div>
     *         <span>Other Element</span>
     *     </div>
     * </div>
     *
     * After replacement:
     * <div>
     *     <label>Text10</label>
     *     <label>Text11</label>
     *     <label>Text12</label>
     *     <label>Text13</label>
     *     <div>
     *         <span>Other Element</span>
     *     </div>
     * </div>
     *
     * @param $element      The jQuery object containing the element whose children will be replaced.
     * @param {Array} subElements    An array of new sub-elements that will replace the existing ones.
     * @param {string} typeToReplace The tag type (e.g., 'label') to be replaced.
     */
    n.ElementUpdate.handleReplaceElement = function ($element, subElements, typeToReplace) {
        // Remove children of type `typeToReplace` from the wrapper element.
        $element.children(typeToReplace).remove();
        // Prepend new elements in reverse order to maintain their original order.
        for (let i = subElements.length - 1; i >= 0; i--) {
            $element.prepend(subElements[i]);
        }
    }

    n.ElementUpdate.handleChangedElements = function (elements) {
        Object.entries(elements).forEach(([key, value]) => {
            switch(key) {
                case "delete-buttons":
                    var recordList = new n.QfqRecordList(n.form.qfqForm.apiDeleteUrl, true);

                    // Handle delete buttons
                    Object.entries(value).forEach(([buttonKey, buttonValue]) => {

                        if (buttonValue.formType) {
                            switch (buttonValue.formType) {
                                case "multi":
                                    const $button = n.ElementUpdate.updateMultiFormDeleteBtn(buttonValue);
                                    recordList.connectClickHandler($button);
                                    break;
                                // Placeholder for other form types (single)
                            }
                        }
                    });
                    break;

                // Placeholder for other element types (if needed)
                default:
                    console.log("Unhandled button type:", key);
            }
        });
    };

    n.ElementUpdate.updateMultiFormDeleteBtn = function (element) {
        // Get parent row
        var $parentRow = $("[name='" + element.htmlName + "']")
            .closest("tr");

        // Replace the delete button with new html
        $parentRow.find(".deleteRowBtn")
            .replaceWith(element.html);

        // Get the new button and add record class to parent row
        var $button = $parentRow.find(".record-delete");
        $parentRow.addClass("record");

        return $button;
    }
})(QfqNS);

/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */


/* global $ */
/* @depend Utils.js */

var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * @name addFields
     * @function
     * @global
     * @deprecated use appendTemplate().
     *
     */
    n.addFields = function (templateSelector, targetSelector, maxLines) {
        n.appendTemplate(templateSelector, targetSelector, maxLines);
        // New added fields are not initialized, need to be initialized.
        n.initializeQfqClearMe();
        n.initializeDatetimepicker(true);
        QfqNS.TypeAhead.install(null);
        QfqNS.AiAssist.install();
        QfqNS.AiChat.install();
        n.initializeCheckmarks(n.lastAppendElement);
    };

    /**
     * Append a child created from a template to a target. All occurrences of `%d` in attribute values will be replaced
     * by the number of children of target.
     *
     * @name appendTemplate
     * @public
     * @global
     * @function
     *
     * @param {string} templateSelector jQuery selector uniquely identifying the template.
     * @param {string} targetSelector jQuery selector uniquely identifying the target container.
     * @param {number} maxChildren do not allow more than `maxChildren` of children in target.
     *
     */
    n.appendTemplate = function (templateSelector, targetSelector, maxChildren) {
        var responsibleButton;
        var escapedTemplateSelector = n.escapeJqueryIdSelector(templateSelector);
        var escapedTargetSelector = n.escapeJqueryIdSelector(targetSelector);

        var $template = $(escapedTemplateSelector);

        var $target = $(escapedTargetSelector);

        var lines = n.countLines(escapedTargetSelector) + 1;

        if (lines >= maxChildren + 1) {
            return;
        }

        var deserializedTemplate = n.deserializeTemplateAndRetainPlaceholders($template.text());
        n.expandRetainedPlaceholders(deserializedTemplate, lines);

        // Change ids to their real ones
        var elements = deserializedTemplate[0].childNodes;
        elements.forEach(function(element) {
            n.adjustIds(element, lines);
        });


        // Store the button object, so we can easily access it when this `line` is removed by the user
        responsibleButton = n.getResponsibleButtonFromTarget($target);
        if (responsibleButton) {
            deserializedTemplate.data('field.template.addButton', responsibleButton);
        }

        $target.append(deserializedTemplate);
        n.informFormOfChange($target);

        // Store last append element
        n.lastAppendElement = deserializedTemplate;

        lines = n.countLines(escapedTargetSelector);
        if (lines >= maxChildren && responsibleButton) {
            n.disableButton(responsibleButton);
        }
    };

    n.adjustIds = function (item, startId) {
        if (item.id !== undefined && item.id !== '' && !item.classList.contains("hidden") && (item.tagName === "INPUT" || item.tagName === "DIV")) {
            var id = item.id;
            var parts = id.split('-');

            if (item.tagName === "INPUT") {
                parts[parts.length -1] = startId;
            } else {
                parts[parts.length -2] = startId;
            }

            item.id = parts.join('-');
        }

        // Get inner children and adjust id
        if (item.childNodes !== undefined && item.childNodes.length > 0) {
            n.adjustIds(item.childNodes, startId);
        }

        // Recursive function to iterate over elements without childNodes, which itself can contain childNodes.
        if (item.length > 0 && item.childNodes === undefined) {
            item.forEach(function(element) {
                n.adjustIds(element, startId);
            });
        }
    };

    n.getResponsibleButtonFromTarget = function ($target) {
        var $button;
        var buttonSelector = $target.data('qfq-line-add-button');
        if (!buttonSelector) {
            return null;
        }

        $button = $(buttonSelector);
        if ($button.length === 0) {
            return null;
        }

        return $button[0];
    };

    n.disableButton = function (button) {
        $(button).attr('disabled', 'disabled');
    };

    n.enableButton = function (button) {
        $(button).removeAttr('disabled');
    };

    n.informFormOfChange = function ($sourceOfChange) {
        var $enclosingForm = $sourceOfChange.closest("form");
        $enclosingForm.change();
    };

    /**
     * @name initializeFields
     * @global
     * @function
     * @deprecated use initializeTemplateTarget()
     */
    n.initializeFields = function (element, templateSelectorData) {
        n.initializeTemplateTarget(element, templateSelectorData);
    };

    /**
     * When the template target contains no children, it initializes the target element by appending the first child
     * created from the template.
     *
     * @name initializeTemplateTarget
     * @global
     * @function
     *
     * @param {HTMLElement} element the target HTMLElement to be initialized
     * @param {string} [templateSelectorData=qfq-line-template] name of the `data-` attribute containing the jQuery
     * selector
     * selecting the template
     */
    n.initializeTemplateTarget = function (element, templateSelectorData) {
        var responsibleButton;
        var $element = $(element);
        var templateSelector, escapedTemplateSelector, $template, deserializedTemplate;

        templateSelector = $element.data(templateSelectorData || 'qfq-line-template');
        escapedTemplateSelector = n.escapeJqueryIdSelector(templateSelector);

        $template = $(escapedTemplateSelector);

        if ($element.children().length > 0) {
            n.setPlaceholderRetainers($template.text(), element);
            return;
        }

        deserializedTemplate = n.deserializeTemplateAndRetainPlaceholders($template.text());
        n.expandRetainedPlaceholders(deserializedTemplate, 1);

        deserializedTemplate.find('.qfq-delete-button').remove();
        $element.append(deserializedTemplate);
    };

    /**
     * @name removeFields
     * @global
     * @function
     * @deprecated use removeThisChild()
     */
    n.removeFields = function (target) {
        n.removeThisChild(target);
    };

    /**
     * Remove the element having a class `qfq-line`. Uses `eventTarget` as start point for determining the closest
     * element.
     *
     * @name removeFields
     * @global
     * @function
     * @param {HTMLElement} eventTarget start point for determining the closest `.qfq-line`.
     */
    n.removeThisChild = function (eventTarget) {
        var $line = $(eventTarget).closest('.qfq-line');
        var $container = $line.parent();
        var buttonToEnable = $line.data('field.template.addButton');

        var parts = $line[0].childNodes[0].id.split('-');
        var startId = parts[parts.length - 2];
        var arrayCount = startId - 1;

        function adjustChildIds (actualChild, startId) {
            actualChild.childNodes.forEach(function(element) {
                n.adjustIds(element, startId);
            });
        }

        $line.remove();

        n.reExpandLineByLine($container);
        n.informFormOfChange($container);
        if (buttonToEnable) {
            n.enableButton(buttonToEnable);
        }

        for (var i = arrayCount; i < $container[0].childNodes.length; i++) {
            var actualChild = $container[0].childNodes[i];
                adjustChildIds(actualChild, startId);
            startId++;
        }
    };

    /**
     * Takes a template as string and deserializes it into DOM. Any attributes having a value containing `%d` will be
     *
     * @private
     * @param template
     *
     */
    n.deserializeTemplateAndRetainPlaceholders = function (template) {
        var $deserializedTemplate = $(template);

        $deserializedTemplate.find("*").each(function () {
            var $element = $(this);
            $.each(this.attributes, function () {
                if (this.value.indexOf('%d') !== -1) {
                    $element.data(this.name, this.value);
                }
            });

            if (n.isTextRetainable($element)) {
                $element.data('element-text', $element.text());
            }
        });

        return $deserializedTemplate;
    };


    /**
     * Set placeholder retainer on existing lines
     * @private
     * @param template
     * @param element container element
     */
    n.setPlaceholderRetainers = function (template, element) {
        var responsibleButton;
        var $deserializedTemplate = $(template);

        var $flatTemplate = $deserializedTemplate.find('*');

        var $element = $(element);

        var $childrenOfElement = $element.children();

        responsibleButton = n.getResponsibleButtonFromTarget($element);

        $childrenOfElement.each(function () {
            var $this = $(this);

            // Add button reference
            $this.data('field.template.addButton', responsibleButton);

            // We use .find() to increase chances of $flatTemplate and $childrenOfChild having the same ordering.
            var $childrenOfChild = $this.find('*');
            $childrenOfChild.each(function (childIndex) {
                var correspondingTemplateElement, $correspondingTemplateElement, $child;

                if (childIndex >= ($flatTemplate.length - 1)) {
                    // the current child element has no corresponding element in the template, so no use of trying to
                    // copy over retaining information.
                    return;
                }

                $child = $(this);

                correspondingTemplateElement = $flatTemplate[childIndex];
                $correspondingTemplateElement = $(correspondingTemplateElement);

                // Create the retainers on the child for each child
                $.each(correspondingTemplateElement.attributes, function () {
                    if (this.value.indexOf('%d') !== -1) {
                        $child.data(this.name, this.value);
                    }
                });

                if (n.isTextRetainable($correspondingTemplateElement)) {
                    $child.data('element-text', $correspondingTemplateElement.text());
                }
            });
        });
    };

    /**
     * @private
     */
    n.expandRetainedPlaceholders = function ($elements, value) {
        $elements.find('*').each(function () {
            var $element = $(this);
            $.each(this.attributes, function () {
                var retainedPlaceholder = $element.data(this.name);
                if (retainedPlaceholder) {
                    this.value = n.replacePlaceholder(retainedPlaceholder, value);
                }
            });

            if (n.hasRetainedText($element)) {
                $element.text(n.replacePlaceholder($element.data('element-text'), value));
            }

        });
    };

    /**
     * @private
     * @param $element
     * @returns {*}
     */
    n.isTextRetainable = function ($element) {
        return $element.is("label") && $element.text().indexOf('%d') !== -1 && $element.children().length === 0;
    };

    /**
     * @private
     * @param $element
     * @returns {boolean}
     */
    n.hasRetainedText = function ($element) {
        return $element.data('element-text') !== undefined;
    };

    /**
     * @private
     * @param targetSelector
     * @returns {jQuery}
     */
    n.countLines = function (targetSelector) {
        var escapedTargetSelector = n.escapeJqueryIdSelector(targetSelector);
        return $(targetSelector).children().length;
    };

    /**
     * @private
     */
    n.replacePlaceholder = function (haystack, by) {
        return haystack.replace(/%d/g, by);
    };

    /**
     * @private
     * @param $container
     */
    n.reExpandLineByLine = function ($container) {
        $container.children().each(function (index) {
            var $element = $(this);
            n.expandRetainedPlaceholders($element, index + 1);
        });
    };

    /**
     * Initialize checkmark elements for checkboxes and radio buttons.
     * @private
     */
    n.initializeCheckmarks = function (element) {

        var $target = $(element);

        $target.find(".radio-inline").each(function() {
            if (!$(this).find(".checkmark").length) {
                $(this).append($("<span>", { class: "checkmark", aria: "hidden"}));
            }
        });

        $target.find(".checkbox-inline").each(function() {
            if (!$(this).find(".checkmark").length) {
                $(this).append($("<span>", { class: "checkmark", aria: "hidden"}));
            }
        });

        $target.find(".radio").each(function() {
            if (!$(this).find(".checkmark").length) {
                $(this).append($("<span>", { class: "checkmark", aria: "hidden"}));
            }
        });

        $target.find(".checkbox").each(function() {
            if (!$(this).find(".checkmark").length) {
                $(this).append($("<span>", { class: "checkmark", aria: "hidden"}));
            }
        });

    };
})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */
/* global $ */
/* global EventEmitter */

/* @depend QfqEvents.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Handle file deletion.
     *
     * Upon instantiation, it attaches to all `<button>` elements having the class `delete-file`, within the elements
     * selected by the `formSelector`.
     *
     * @param formSelector jQuery selector of the `<form>` this instance is responsible for.
     * @param targetUrl endpoint URL to send delete requests to.
     * @constructor
     *
     * @name QfqNS.FileDelete
     */
    n.FileDelete = function (formSelector, targetUrl) {
        this.formSelector = formSelector;
        this.targetUrl = targetUrl;
        this.eventEmitter = new EventEmitter();

        this.setupOnClickHandler();
    };

    n.FileDelete.prototype.on = n.EventEmitter.onMixin;

    n.FileDelete.prototype.setupOnClickHandler = function () {
        $(this.formSelector + " button.delete-file").click(this.buttonClicked.bind(this));
    };

    n.FileDelete.prototype.buttonClicked = function (event) {
        event.preventDefault();
        var alert = new n.Alert({
            message: "Do you want to delete the file?",
            type: "warning",
            modal: true,
            buttons: [
                {label: "OK", eventName: "ok"},
                {label: "Cancel", eventName: "cancel", focus: true}
            ]
        });
        alert.on('alert.ok', function () {
            this.performFileDelete(event);
        }.bind(this));
        alert.show();
    };

    n.FileDelete.prototype.performFileDelete = function (event) {
        this.eventEmitter.emitEvent('filedelete.started', n.EventEmitter.makePayload(event.delegateTarget, null));

        var data = this.prepareData(event.delegateTarget);

        $.ajax({
            url: this.targetUrl,
            type: 'POST',
            data: data,
            cache: false
        })
            .done(this.ajaxSuccessHandler.bind(this, event.delegateTarget))
            .fail(this.ajaxErrorHandler.bind(this, event.delegateTarget));
    };

    n.FileDelete.prototype.prepareData = function (htmlButton) {
        if (!htmlButton.hasAttribute("name")) {
            throw new Error("File delete button element requires 'name' attribute");
        }

        var fileDeleteName = htmlButton.getAttribute('name');

        var data = {
            s: $(htmlButton).data('sip'),
            name: fileDeleteName
        };

        return data;
    };

    n.FileDelete.prototype.ajaxSuccessHandler = function (uploadTriggeredBy, data, textStatus, jqXHR) {
        var eventData = n.EventEmitter.makePayload(uploadTriggeredBy, data, {
            textStatus: textStatus,
            jqXHR: jqXHR
        });
        this.eventEmitter.emitEvent('filedelete.delete.successful', eventData);
        this.eventEmitter.emitEvent('filedelete.ended', eventData);
    };

    n.FileDelete.prototype.ajaxErrorHandler = function (uploadTriggeredBy, jqXHR, textStatus, errorThrown) {
        var eventData = n.EventEmitter.makePayload(uploadTriggeredBy, null, {
            textStatus: textStatus,
            errorThrown: errorThrown,
            jqXHR: jqXHR
        });
        this.eventEmitter.emitEvent('filedelete.delete.failed', eventData);
        this.eventEmitter.emitEvent('filedelete.ended', eventData);
    };


})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */

/* @depend QfqEvents.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     *
     * @param formSelector
     * @param targetUrl
     * @constructor
     * @name QfqNS.FileUpload
     */
    n.FileUpload = function (formSelector, targetUrl) {
        this.formSelector = formSelector;
        this.targetUrl = targetUrl;
        this.eventEmitter = new EventEmitter();

        this.setupOnChangeHandler();
    };

    n.FileUpload.prototype.on = n.EventEmitter.onMixin;

    /**
     *
     * @private
     */
    n.FileUpload.prototype.setupOnChangeHandler = function () {
        $(this.formSelector + " :file").on('change', this.performFileUpload.bind(this));
    };

    /**
     * @private
     * @param event
     */
    n.FileUpload.prototype.performFileUpload = function (event) {
        this.eventEmitter.emitEvent('fileupload.started', n.EventEmitter.makePayload(event.target, null));

        var data = this.prepareData(event.target);

        $.ajax({
            url: this.targetUrl,
            type: 'POST',
            data: data,
            cache: false,
            processData: false,
            contentType: false
        })
            .done(this.ajaxSuccessHandler.bind(this, event.target))
            .fail(this.ajaxErrorHandler.bind(this, event.target));
    };

    n.FileUpload.prototype.prepareData = function (htmlFileInput) {
        if (!htmlFileInput.hasAttribute("name")) {
            throw new Error("File input element requires 'name' attribute");
        }

        var fileInputName = htmlFileInput.getAttribute('name');

        var data = new FormData();
        $.each(htmlFileInput.files, function (key, value) {
            data.append(fileInputName + '_' + key, value);
        });

        data.append('s', $(htmlFileInput).data('sip'));
        data.append('name', fileInputName);

        return data;
    };

    /**
     * @private
     * @param data
     * @param textStatus
     * @param jqXHR
     */

    n.FileUpload.prototype.ajaxSuccessHandler = function (uploadTriggeredBy, data, textStatus, jqXHR) {
        var eventData = n.EventEmitter.makePayload(uploadTriggeredBy, data, {
            textStatus: textStatus,
            jqXHR: jqXHR
        });
        this.eventEmitter.emitEvent('fileupload.upload.successful', eventData);
        this.eventEmitter.emitEvent('fileupload.ended', eventData);
    };

    /**
     * @private
     * @param jqXHR
     * @param textStatus
     * @param errorThrown
     */
    n.FileUpload.prototype.ajaxErrorHandler = function (uploadTriggeredBy, jqXHR, textStatus, errorThrown) {
        var eventData = n.EventEmitter.makePayload(uploadTriggeredBy, null, {
            textStatus: textStatus,
            errorThrown: errorThrown,
            jqXHR: jqXHR
        });
        this.eventEmitter.emitEvent('fileupload.upload.failed', eventData);
        this.eventEmitter.emitEvent('fileupload.ended', eventData);
    };


})(QfqNS);

/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Abstraction of `<form>`.
     *
     * @param formId
     * @param formChanged
     * @constructor
     * @name QfqNS.Form
     */
    n.Form = function (formId, formChanged) {
        this.formId = formId;
        this.eventEmitter = new EventEmitter();
        // In order to immediately emit events, we bind event handlers on `<form>` `change` and `input` and `paste` on
        // `<input> and `<textarea>`s. Without precaution, this will lead to emitting `form.changed` twice upon
        // `input` and `paste` events, since they eventually will raise a form `change` event.  We perform bookkeeping
        // using this flag, to avoid emitting `form.changed` twice when bubbling.
        //
        // Since we cannot predict the effect on disable bubbling of the `input` and `paste` events, we resort to this
        // home-brew solution.
        this.inputAndPasteHandlerCalled = false;
        this.filepondUploadProcessing = [];
        this.saveInProgress = false;

        if (!document.forms[this.formId]) {
            throw new Error("Form '" + formId + "' does not exist.");
        }

        this.formChanged = !!formChanged;
        this.formChangedTimestampInMilliseconds = n.Form.NO_CHANGE_TIMESTAMP;
        this.$form = $(document.forms[this.formId]);
        this.$form.on("change", (event) => {
            if (this.filepondUploadProcessing.length === 0) {
                this.changeHandler(event);
            }
        });

        this.$form.on("invalid.bs.validator", this.validationError.bind(this));
        this.$form.on("valid.bs.validator", this.validationSuccess.bind(this));

        // On <input> elements, we specifically bind this events, in order to update the formChanged property
        // immediately, not only after loosing focus. Same goes for <textarea>
        this.$form.find("input, textarea").on("input paste", (event) => {
            // Only trigger if no filepond upload is in progress
            if (this.filepondUploadProcessing.length === 0) {
                this.inputAndPasteHandler(event);
            }
        });

        // Fire handler while using dateTimePickerType qfq
        function getDatetimePickerChanges(element) {
            $('div tbody').on('click', 'td.day:not(.disabled)', formObject.inputAndPasteHandler.bind(formObject));
            var timepickerElements = 'td a.btn[data-action="incrementHours"], td a.btn[data-action="incrementMinutes"], td a.btn[data-action="incrementSeconds"], td a.btn[data-action="decrementHours"], td a.btn[data-action="decrementMinutes"], td a.btn[data-action="decrementSeconds"]';
            $('div table').on('click', 'td.hour, td.minute, td a[data-action="clear"], '+timepickerElements, formObject.inputAndPasteHandler.bind(formObject));

            element.addEventListener('keydown', function(event) {
                if (event.key === 'Delete') {
                    formObject.inputAndPasteHandler(event);
                }
            });
        }

        // Function to trigger onfocus event again while element is already focused
        function triggerFocus(element) {
            var eventType = "onfocusin" in element ? "focusin" : "focus",
                bubbles = "onfocusin" in element,
                event;

            if ("createEvent" in document) {
                event = document.createEvent("Event");
                event.initEvent(eventType, bubbles, true);
            }
            else if ("Event" in window) {
                event = new Event(eventType, { bubbles: bubbles, cancelable: true });
            }

            element.focus();
            element.dispatchEvent(event);
        }

        var formObject = this;
        // Open datetimepicker over click event even if first element is already focused and get all changes of datetimepicker for dirty lock
        this.$form.find(".qfq-datepicker").on("click", function(){
            triggerFocus(this);
            if (formObject.filepondUploadProcessing.length === 0) {
                getDatetimePickerChanges(this);
            }
        });

        // Fire handler while using dateTimePickerType browser
        this.$form.find("input[type=datetime-local]").on("click", (event) => {
            // Only trigger if no filepond upload is in progress
            if (this.filepondUploadProcessing.length === 0) {
                this.inputAndPasteHandler(event);
            }
        });

        // Use ctrl+alt+s for saving form
        document.addEventListener('keydown', function(event) {
            if (event.ctrlKey && event.altKey && event.key === 's') {
                $("#save-button-" + this.formId + ":not([disabled=disabled])").click();
            }
        });

        this.$form.on('submit', function (event) {
            event.preventDefault();
        });
    };

    n.Form.NO_CHANGE_TIMESTAMP = -1;

    n.Form.prototype.on = n.EventEmitter.onMixin;

    /**
     *
     * @param event
     *
     * @private
     */
    n.Form.prototype.changeHandler = function (event) {
        // Trim whitespace after change. Before validation happens.
        if (event.target.value !== undefined && event.target.type !== 'file') {
            var value = event.target.value;
            event.target.value = value.trim();
        }

        if (this.inputAndPasteHandlerCalled) {
            // reset the flag
            this.inputAndPasteHandlerCalled = false;
            // and return. The `form.changed` event has already been emitted by `Form#inputAndPasteHandler()`.
            return;
        }
        this.markChanged(event.target);
    };

    n.Form.prototype.inputAndPasteHandler = function (event) {
        this.inputAndPasteHandlerCalled = true;
        this.markChanged(event.target);
    };

    n.Form.prototype.getFormChanged = function () {
        return this.formChanged;
    };

    n.Form.prototype.markChanged = function (initiator) {
        if(!!initiator) {
            if(initiator.classList.contains("qfq-skip-dirty")) {
                return;
            }
        }
        this.setFormChangedState();
        this.eventEmitter.emitEvent('form.changed', n.EventEmitter.makePayload(this, null));
    };

    /**
     * @private
     */
    n.Form.prototype.setFormChangedState = function () {
        this.formChanged = true;
        this.formChangedTimestampInMilliseconds = Date.now();
    };

    n.Form.prototype.resetFormChanged = function () {
        this.resetFormChangedState();
        this.eventEmitter.emitEvent('form.reset', n.EventEmitter.makePayload(this, null));

    };

    /**
     * @private
     */
    n.Form.prototype.resetFormChangedState = function () {
        this.formChanged = false;
        this.formChangedTimestampInMilliseconds = n.Form.NO_CHANGE_TIMESTAMP;
    };

    n.Form.prototype.submitTo = function (to, queryParameters) {
        var submitUrl;

        this.eventEmitter.emitEvent('form.submit.before', n.EventEmitter.makePayload(this, null));
        submitUrl = this.makeUrl(to, queryParameters);

        // For better dynamic update compatibility (checkboxes). All input elements need to be not disabled for fully serializing by jquery.
        var form = $(this.$form[0]);
        // Get even disabled inputs
        var disabled = form.find(':input:disabled').removeAttr('disabled');
        var serializedForm = this.$form.serialize();
        // Reset disabled inputs
        disabled.attr('disabled','disabled');

        $.post(submitUrl, serializedForm)
            .done(this.ajaxSuccessHandler.bind(this))
            .fail(this.submitFailureHandler.bind(this));
    };

    n.Form.prototype.serialize = function () {
        return this.$form.serialize();
    };


    /**
     * @private
     * @param url base url
     * @param queryParameters additional query parameters
     * @returns {*}
     */
    n.Form.prototype.makeUrl = function (url, queryParameters) {
        var notFound = -1;
        var querySeparator = '?';
        var parameterSeparator = '&';
        var queryString;

        if (!queryParameters) {
            return url;
        }

        queryString = $.param(queryParameters);
        if (url.indexOf(querySeparator) === notFound) {
            return url + querySeparator + queryString;
        } else {
            return url + parameterSeparator + queryString;
        }
    };

    /**
     *
     * @param data
     * @param textStatus
     * @param jqXHR
     *
     * @private
     */
    n.Form.prototype.ajaxSuccessHandler = function (data, textStatus, jqXHR) {
        // If there are some needed element changes after save - update them here
        // Currently used for multiform delete buttons after save
        if (data['changed-elements'] !== undefined) {
            n.ElementUpdate.handleChangedElements(data['changed-elements']);
        }

        this.resetFormChangedState();
        this.eventEmitter.emitEvent('form.submit.successful',
            n.EventEmitter.makePayload(this, data, {
                textStatus: textStatus,
                jqXHR: jqXHR
            }));
        
        this.saveInProgress = false;
    };

    /**
     *
     *
     * @private
     */
    n.Form.prototype.submitFailureHandler = function (jqXHR, textStatus, errorThrown) {
        this.eventEmitter.emitEvent('form.submit.failed', n.EventEmitter.makePayload(this, null, {
            textStatus: textStatus,
            errorThrown: errorThrown,
            jqXHR: jqXHR
        }));
    };

    /**
     * @public
     * @returns {*}
     */
    n.Form.prototype.validate = function () {
        this.eventEmitter.emitEvent('form.validation.before', n.EventEmitter.makePayload(this, null));
        var isValid;
        var form = document.forms[this.formId];
        var $form = $(form);

        if ($form.data('bs.validator')) {
            $form.validator('validate');
            isValid = !$form.data('bs.validator').hasErrors();
        } else {
            isValid = form.checkValidity();
        }

        this.eventEmitter.emitEvent('form.validation.after', n.EventEmitter.makePayload(this, {validationResult: isValid}));

        return isValid;
    };

    /**
     * @private
     */
    n.Form.prototype.validationError = function (data) {
        this.eventEmitter.emitEvent('form.validation.failed', n.EventEmitter.makePayload(this, {element: data.relatedTarget}));
    };

    /**
     * @private
     */
    n.Form.prototype.validationSuccess = function (data) {
        this.eventEmitter.emitEvent('form.validation.success', n.EventEmitter.makePayload(this, {element: data.relatedTarget}));
    };

    /**
     * Uses standard HTML Validation in native javascript for input elements
     *
     * @public
     */
    n.Form.prototype.getFirstNonValidElement = function () {
        var index;
        var form = document.getElementById(this.formId);
        var inputs = form.elements;
        var elementNumber = inputs.length;

        for (index = 0; index < elementNumber; index++) {
            var element = inputs[index];
            if (!element.willValidate) {
                continue;
            }

            if (!element.checkValidity()) {
                return element;
            }
        }

        return null;
    };

})(QfqNS);
/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

/**
 * A custom history to use for undo and redo functionality.
 **/

    n.History = function() {
        this.history = [];
        this.pointer = 0;
    };

    n.History.prototype.put = function(object) {
        if (this.history.length > 1) {
            if (this.canGoForward()) {
                console.log("trying to remove history");
                this._removeForwardHistory();
            }
        }
        if (JSON.stringify(this.history[this.pointer]) !== JSON.stringify(object)) {
            this.history.push(object);
            this.pointer = this.history.length - 1;
        }
        console.log(this);
    };

    n.History.prototype.back = function() {
        if (this.canGoBack()) {
            this.pointer = this.pointer - 1;
            console.log(this.pointer + "/" + this.history.length);
            console.log(this.history);
            return this.history[this.pointer];
        } else {
            console.log("At the beginning of history");
            return false;
        }
    };

    n.History.prototype.forward = function() {
        console.log(this.pointer);
        if (this.canGoForward()) {
            this.pointer = this.pointer + 1;
            return this.history[this.pointer];
        } else {
            console.log("At the end of history");
            return false;
        }
    };

    n.History.prototype.canGoBack = function() {
        return this.pointer > 0;
    };

    n.History.prototype.canGoForward = function() {
        return this.pointer < this.history.length - 1;
    };

    n.History.prototype._removeForwardHistory = function() {
        this.history.splice(this.pointer + 1, this.history.length - this.pointer);
    };



})(QfqNS);
var QfqNS = QfqNS || {};
$(document).ready(function() {
    (function(n) {
        $('.qfq-inline-edit').on('click', function() {
            var dataSip = $(this).data('sip');
            var $input = $(this).children('textarea');
            var $label = $(this).children('div.qfq-inline-edit-label');

            if ($input.length === 0) {
                // If the textarea doesn't exist, retrieve it
                retrieveTextbox(dataSip, $label);
            } else {
                $input.removeClass('hidden');
                $input.focus();
                $label.addClass('hidden');
            }
        });
    })(QfqNS);
});
function retrieveTextbox(dataSip, $label) {
    $.ajax({
        url: "typo3conf/ext/qfq/Classes/Api/inlineEditLoad.php?s=" + dataSip,
        method: "POST",
        success: function(response) {
            // Create the textbox and modify its properties
            var $textBox = createTextbox(response);
            $textBox.removeClass('qfq-auto-grow');
            $label.addClass('hidden');
            insertAfterLabel($label, $textBox);
            setTextboxHeight($textBox);
            enableAutoGrow($textBox);
            registerBlurHandler($textBox, dataSip, $label);
        },
        error: function() {
            console.error('inlineEditLoad.php API error');
        }
    });
}
function registerBlurHandler($textBox, dataSip, $label) {
    // Register the blur event handler for the textbox
    $textBox.on('blur', function() {
        var updatedValue = $(this).val();

        // Send an AJAX request to update the record in the database
        $.ajax({
            url: "typo3conf/ext/qfq/Classes/Api/inlineEditSave.php?s=" + dataSip,
            method: "POST",
            data: { 'updatedValue': updatedValue },
            success: function(response) {
                $textBox.addClass('hidden');
                $label.text(response).removeClass('hidden');
            },
            error: function() {
                $textBox.addClass('hidden');
                $label.removeClass('hidden');
            }
        });
    });
}
function createTextbox(response) {
    // Create a textbox element from the server response
    var $textBox = $(response);
    $textBox.addClass('qfq-inline-edit-input');
    $textBox.attr('spellcheck', 'false');
    return $textBox;
}
function insertAfterLabel($label, $textBox) {
    // Insert the textbox after the label and set focus on the textbox
    $label.after($textBox);
    $textBox.focus();
}
function setTextboxHeight($textBox) {
    // Set the height of the textbox to match its content
    $textBox.css('height', 'auto');
    $textBox.css('height', $textBox[0].scrollHeight + 'px');
}
function enableAutoGrow($textBox) {
    // Enable the textbox to auto-grow based on its content
    $textBox.on('input', function() {
        // Set the height of the textbox to match its content
        $textBox.css('height', 'auto');
        $textBox.css('height', $textBox[0].scrollHeight + 'px');
    });
}



/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend Alert.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Manages Text Editor for Comments
     */
    n.LocalStorage = function (key) {
        this.storage = {};
        this.key = key;
        this.storage[this.key] = {};

        // Event Emitter is a Library qfq uses to emit custom Events.
        this.eventEmitter = new EventEmitter();
        this._read();
        if (this.storage[this.key] !== "undefined") {
            this.storage[this.key] = {};
        }
    };

    n.LocalStorage.prototype.on = n.EventEmitter.onMixin;

    n.LocalStorage.prototype._read = function() {
        var o = JSON.parse(localStorage.getItem("qfq"));
        if(o) {
            this.storage = o;
        }
    };

    n.LocalStorage.prototype._write = function() {
        localStorage.setItem("qfq", JSON.stringify(this.storage));
        console.log(localStorage.getItem("qfq"));
    };

    n.LocalStorage.prototype.get = function(key) {
        if (this.storage[this.key][key] !== "undefined") {
            console.log(this.storage);
            return this.storage[this.key][key];
        } else {
            return false;
        }
    };

    n.LocalStorage.prototype.set = function(key, object) {
        if (this.storage[this.key][key] === "undefined") {
            this.storage[this.key][key] = {};
        }
        if(object) {
            this.storage[this.key][key] = object;
            this._write();
        }
    };

    n.LocalStorage.prototype.update = function() {
        this._read();
    };


}(QfqNS));
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global console */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     *
     * @type {{level: number, message: Function, debug: Function, warning: Function, error: Function}}
     *
     * @name QfqNS.Log
     */
    n.Log = {
        level: 3,
        message: function (msg) {
            if (this.level <= 0) {
                console.log('[message] ' + msg);
            }
        },
        debug: function (msg) {
            if (this.level <= 1) {
                console.log('[debug] ' + msg);
            }
        },
        warning: function (msg) {
            if (this.level <= 2) {
                console.log('[warning] ' + msg);
            }
        },
        error: function (msg) {
            if (this.level <= 3) {
                console.log('[error] ' + msg);
            }
        }

    };
})(QfqNS);
/* global $ */
/* @depend TablesorterController.js */

Function.prototype.bind = Function.prototype.bind || function (thisp) {
    var fn = this;

    return function () {
        return fn.apply(thisp, arguments);
    };
};

var QfqNS = QfqNS || {};

$(document).ready(function () {
    (function (n) {
        n.form = '';
        n.messenger = '';
        try {
            var tablesorterController = new n.TablesorterController();
            $('.tablesorter').each(function (i) {
                tablesorterController.setup($(this), i);
            }); // end .each()

            $('.tablesorter-filter').addClass('qfq-skip-dirty');
            $('select.qfq-tablesorter-menu-item').addClass('qfq-skip-dirty');
            $('.tablesorter-column-selector>label>input').addClass('qfq-skip-dirty');

            // This is needed because after changing table-view, class of input field is empty again
            $('button.qfq-column-selector').click(function () {
                $('.tablesorter-column-selector>label>input').addClass('qfq-skip-dirty');
            });

            // Ensures filter input/select use dynamic width
            $('.tablesorter-filter-row input').attr('size', 1);
            $('.tablesorter-filter-row select').attr('size', 1);

            // Initialize QfqInlineFilter for Report inline filtering
            if (n.Helper && n.Helper.QfqInlineFilter) {
                var qfqInlineFilter = new n.Helper.QfqInlineFilter();
                qfqInlineFilter.init();
            }

            var collection = document.getElementsByClassName("qfq-form");
            var qfqPages = [];
            for (const form of collection) {
                const page = new n.QfqPage(form.dataset);
                qfqPages.push(page);
            }

            // Get form object for later manipulations (example: filePond objects)
            if (qfqPages[0] !== undefined) {
                n.form = qfqPages[0].qfqForm.form;
            } else {
                // Readonly fabric for report
                $(".annotate-graphic").each(function () {
                    var qfqFabric = new QfqNS.Fabric();
                    var page = {}
                    qfqFabric.initialize($(this), page);
                });

                $(".annotate-text").each(function () {
                    var codeCorrection = new QfqNS.CodeCorrection();
                    var page = {}
                    codeCorrection.initialize($(this), page);
                });

            }
        } catch (e) {
            console.log(e);
        }

        $('.qfq-auto-grow').each(function () {
            var minHeight = $(this).attr("rows") * 14 + 18;
            var newHeight = $(this).prop('scrollHeight');
            var maxHeight = $(this).data('max-height') || 0;

            if ($(this).val === '' || newHeight < minHeight) {
                return;
            }

            if (newHeight < maxHeight || maxHeight === 0) {
                $(this).height(newHeight);
            } else {
                $(this).height(maxHeight);
            }
        });

        $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
            $('.qfq-auto-grow').each(function () {
                // Reset height before re-calculation
                $(this).css('height', 'auto');

                var minHeight = $(this).attr("rows") * 14 + 18;
                var newHeight = $(this).prop('scrollHeight');
                var maxHeight = $(this).data('max-height') || 0;

                if ($(this).val === '' || newHeight < minHeight) {
                    return;
                }

                if (newHeight < maxHeight || maxHeight === 0) {
                    $(this).height(newHeight);
                } else {
                    $(this).height(maxHeight);
                }
            });
        });

        $('.qfq-auto-grow').on('input paste', function () {
            var newHeight = $(this).prop('scrollHeight');
            var maxHeight = $(this).data('max-height') || 0;
            if ($(this).outerHeight() < newHeight) {
                if (newHeight < maxHeight || maxHeight === 0) {
                    $(this).height(newHeight);
                }
            }
        });

        n.initializeQfqClearMe = function () {
            $('.qfq-clear-me').each(function () {
                var myInput = $(this);
                if (!myInput.is("input,textarea") || myInput.parent().children().hasClass('qfq-clear-me-button')) {
                    return;
                }
                var closeButton = $("<span>", {
                    class: "qfq-clear-me-button",
                    html: "&times;"
                });
                if (myInput.hasClass('qfq-clear-me-table-sorter')) {
                    // Clear me is in table-sorter table and needs some different style
                    closeButton.attr('style', 'right: 3px;top: 11px;');
                }
                if (myInput.val() == '' || myInput.is('[disabled=disabled]')) {
                    closeButton.addClass("hidden");
                }
                closeButton.on("click", function (e) {
                    myInput.val('');
                    closeButton.addClass("hidden");
                    myInput.trigger('change');
                });
                $(this).after(closeButton);
                $(this).on("input", function () {
                    if (myInput.val() != '' && !myInput.is('[disabled=disabled]')) {
                        closeButton.removeClass("hidden");
                    } else {
                        closeButton.addClass("hidden");
                    }
                });

            });


            $('.qfq-clear-me-multiform').each(function () {
                var myInput = $(this);
                if (!myInput.is("input,textarea") || myInput.parent().children().hasClass('qfq-clear-me-button')) {
                    return;
                }
                var closeButton = $("<span>", {
                    class: "qfq-clear-me-multiform-button",
                    html: "&times;"
                });
                if (myInput.val() == '' || myInput.is('[disabled=disabled]')) {
                    closeButton.addClass("hidden");
                }
                closeButton.on("click", function (e) {
                    myInput.val('');
                    closeButton.addClass("hidden");
                    myInput.trigger('change');
                });
                $(this).after(closeButton);
                $(this).on("input", function () {
                    if (myInput.val() != '' && !myInput.is('[disabled=disabled]')) {
                        closeButton.removeClass("hidden");
                    } else {
                        closeButton.addClass("hidden");
                    }
                });

            });
        };

        n.initializeToggleInput = function () {
            document.querySelectorAll('.toggle-input-checkbox').forEach(checkbox => {
                const targetId = checkbox.dataset.toggleTarget;
                const defaultValue = checkbox.dataset.defaultValue || '';
                const target = document.getElementById(targetId);
                const isDynamicUpdate = checkbox.dataset.dynamicUpdate || false;
                if (!target) return;
                const originalValues = new Map(); // Tracks user-entered values
                const inputs = target.querySelectorAll('input, textarea, select');
                const applyToggle = () => {
                    const isChecked = checkbox.checked;

                    target.classList.toggle('qfq-toggle-hidden', !isChecked);
                    inputs.forEach((input, index) => {
                        const inputKey = input.name || input.id || `input-${index}`;

                        // === Handle <select> ===
                        if (input.tagName === 'SELECT') {

                            if (!isChecked) {
                                // Save original selection
                                const selected = Array.from(input.selectedOptions).map(o => o.value);
                                originalValues.set(inputKey, selected);


                                // If no matching default, insert a fallback <option> with the defaultValue
                                const hasMatch = Array.from(input.options).some(opt => opt.value === defaultValue);
                                if (!hasMatch) {
                                    let fallbackOption = input.querySelector('option[data-toggle-fallback]');
                                    if (!fallbackOption) {
                                        fallbackOption = document.createElement('option');
                                        fallbackOption.value = defaultValue;
                                        fallbackOption.text = '—';
                                        fallbackOption.setAttribute('data-toggle-fallback', 'true');
                                        input.prepend(fallbackOption);
                                    }
                                    fallbackOption.selected = true;
                                } else {
                                    // Apply default values
                                    Array.from(input.options).forEach(option => {
                                        option.selected = option.value === defaultValue;
                                    });
                                }

                            } else {
                                // Restore previous value
                                const fallbackOption = input.querySelector('option[data-toggle-fallback]');
                                if (fallbackOption) {
                                    fallbackOption.remove();
                                }
                                if (originalValues.has(inputKey)) {
                                    const saved = originalValues.get(inputKey);
                                    Array.from(input.options).forEach(option => {
                                        option.selected = saved.includes(option.value);
                                    });
                                }
                            }
                        } else {
                            // === Handle <input>, <textarea> ===
                            if (!isChecked) {

                                originalValues.set(inputKey, input.value);

                                input.value = defaultValue;
                            } else {
                                if (originalValues.has(inputKey)) {
                                    input.value = originalValues.get(inputKey);
                                }
                            }
                        }

                        if (isDynamicUpdate) {
                            n.form.qfqForm.formUpdateHandler();
                        }
                    });
                };

                // Initial state check
                applyToggle();
                // Bind to change event
                checkbox.addEventListener('change', applyToggle);
            });
        };


        n.initializeDatetimepicker = function (flagDynamicElement) {
            var selector = '.qfq-datepicker';
            if (flagDynamicElement) {
                selector = '.qfq-datepicker:empty';
            }

            $(selector).each(function () {
                var dates = {};
                var dateArray = {};
                var datesToFormat = ["minDate", "maxDate"];
                var correctAttributeNames = ["mindate", "maxdate"];
                var onlyTime = false;
                for (var i = 0; i < datesToFormat.length; i++) {
                    var date = false;
                    if ($(this).data(correctAttributeNames[i])) {
                        var cleanDate = $(this).data(correctAttributeNames[i]).split(" ")[0];
                        var cleanTime = $(this).data(correctAttributeNames[i]).split(" ")[1];
                        if (cleanDate.includes('.')) {
                            dateArray = cleanDate.split(".");
                            date = dateArray[1] + "/" + dateArray[0] + "/" + dateArray[2];
                        } else if (cleanDate.includes('-')) {
                            dateArray = cleanDate.split("-");
                            date = dateArray[1] + "/" + dateArray[2] + "/" + dateArray[0];
                        } else {
                            var today = new Date();
                            var dd = String(today.getDate()).padStart(2, '0');
                            var mm = String(today.getMonth() + 1).padStart(2, '0'); //January is 0!
                            var yyyy = today.getFullYear();
                            date = mm + "/" + dd + "/" + yyyy + " " + cleanDate;
                            onlyTime = true;
                        }
                        if (cleanTime !== '' && cleanTime !== undefined) {
                            date = date + " " + cleanTime;
                        } else if (correctAttributeNames[i] === "maxdate" && !onlyTime) {
                            date = date + " 23:59:59";
                        }
                    }
                    dates[datesToFormat[i]] = date;
                }

                var options = {
                    locale: $(this).data("locale") || "en",
                    daysOfWeekDisabled: $(this).data("days-of-week-disabled") || [],
                    minDate: dates.minDate,
                    maxDate: dates.maxDate,
                    format: $(this).data("format") || "DD.MM.YYYY HH:mm",
                    viewMode: $(this).data("view-mode-default") || "days",
                    showClear: ($(this).data("show-clear-button") !== undefined) ? $(this).data("show-clear-button") : true,
                    calendarWeeks: ($(this).data("show-calendar-weeks") !== undefined) ? $(this).data("show-calendar-weeks") : false,
                    useCurrent: ($(this).data("use-current-datetime") !== undefined) ? $(this).data("use-current-datetime") : false,
                    sideBySide: ($(this).data("datetime-side-by-side") !== undefined) ? $(this).data("datetime-side-by-side") : false,
                };
                var currentDatePicker = $(this).datetimepicker(options);

                currentDatePicker.on('dp.error', function(event) {
                    // Clear the input field
                    $(this).find('input').val('');
                    $(this).data('DateTimePicker').clear();

                    // Display your custom error message
                    var alert = new QfqNS.Alert({
                        message: "Invalid Date. Min: " + dates.minDate + " Max: " + dates.maxDate,
                        type: "warning",
                        timeout: 5000
                    });
                    alert.show();
                });

                //  Markiere das formular als geändert, damit der Speichern-Button freigegeben wird
                $(this).on('dp.change', function(e) {
                    const $input = $(this).find('input');

                    if (QfqNS.form && typeof QfqNS.form.markChanged === 'function') {
                        QfqNS.form.markChanged($input[0]);
                    }
                });

            });
        };

        n.initializeIgnoreHistoryBtn = function () {
            // Attaching the event listener to the document
            document.addEventListener('click', function (event) {
                var element = event.target;

                // Traverse up to find the element with 'data-ignore-history'
                while (element && !element.hasAttribute('data-ignore-history')) {
                    element = element.parentNode;
                    if (element === document) {
                        return; // Exit if reached the document without finding the target
                    }
                }
                // Only execute if the element exists and is not Disabled
                if (element && !element.classList.contains('disabled')) {
                    handleIgnoreHistoryClick(event, element);
                }
            });
        };

        function handleIgnoreHistoryClick(event, element) {
            event.preventDefault();
            let alertButton = document.querySelector('.alert-interactive .btn-group button:first-child');
            let url = element.href;

            if (alertButton) {
                alertButton.onclick = function () {
                    if (url) {
                        window.location.replace(url);
                    }
                };
            } else {
                window.location.replace(url);
            }
        }

        n.initializeImageModal = function () {

            // Get images
            var images = document.getElementsByClassName('qfq-img-modal');
            var len = images.length;

            // Iterate through images and add modal functionality to each one of them
            for (let i = 0; i < len; i++) {
                let modalHtml = `
                    <div id="qfq-img-modal-container-${i}" class="modal qfq-img-modal-container" role="dialog">
                        <div class="modal-dialog qfq-img-modal-dialog" role="document">
                            <div class="modal-content qfq-img-modal-content">
                                <img id="qfq-img-modal-${i}" class="qfq-img-modal-img">
                            </div>
                        </div>
                    </div>`;

                let img = images[i];

                // Add modal HTML and attributes
                img.insertAdjacentHTML('afterend', modalHtml);
                img.setAttribute('data-toggle', 'modal');
                img.setAttribute('data-target', `#qfq-img-modal-container-${i}`);

                // Add source
                let modalImg = document.getElementById(`qfq-img-modal-${i}`);
                modalImg.src = img.src;
            }
        }

        n.initializeQfqClearMe();
        n.initializeDatetimepicker();
        n.initializeIgnoreHistoryBtn();
        n.initializeImageModal();
        n.initializeToggleInput();
        n.Helper.codemirror();
        n.Helper.calendar();
        n.Helper.selectBS();
        n.Helper.initializeStickyToolTip();
        n.Helper.initializeDownloadProcess();
        n.Helper.initializeDevPanel();
        n.Helper.initializeEmailSelect();
        n.Helper.initColorPicker(n.form);
        n.messenger = n.Helper.qfqMessenger({
            autoConnect: true
        });

        // Initialize chat elements
        let chatWindowsElements = document.getElementsByClassName("qfq-chat-window");
        let chatInstances = n.Helper.qfqChat(chatWindowsElements);

        if (typeof FilePond !== 'undefined') {
            FilePond.registerPlugin(FilePondPluginFileValidateSize);
            FilePond.registerPlugin(FilePondPluginFileValidateType);
        }

        // Initialize merge window
        let mergeWindow = document.querySelector('.merge-window');
        if (mergeWindow !== null) {
            let mergeInstance = n.Helper.qfqMerge(mergeWindow);
        }

        // Initialize form history
        let formHistoryBtn = document.querySelector('.qfq-history-btn');
        if (formHistoryBtn !== null) {
            n.Helper.qfqHistory(formHistoryBtn, n);
        }

        // Initialize SyncByRule buttons AND containers
        let syncByRuleElements = document.querySelectorAll('.qfq-syncByRule, .qfq-syncByRule-container');
        if (syncByRuleElements.length > 0) {
            n.Helper.qfqSyncByRule(syncByRuleElements, n);
        }

        // Initialize InputCollectionJson configuration editors
        let icjElements = document.querySelectorAll('textarea.qfq-icj');
        if (icjElements.length > 0) {
            n.Helper.qfqIcj(icjElements, n);
        }

        // Get currently shown upload elements to initialize them
        let activeTabContent = null;
        if (n.form) {
            const activeTabId = n.form.qfqForm.bsTabs ? n.form.qfqForm.bsTabs.currentTab : null;
            activeTabContent = document.getElementById(activeTabId);
        }
        if (activeTabContent) {
            n.Helper.initializeFilePondInContainer(activeTabContent, n.form);

            const reportUploadElements = document.querySelectorAll('div.fileuploadContainer');
            reportUploadElements.forEach(container => {
                n.Helper.initializeFilePondInContainer(container, n.form);
            });
        } else {
            // In case of forms without tabs or reports
            n.Helper.initializeFilePondInContainer(document, n.form);
        }
        n.initializeQmore();
    })(QfqNS);
});
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* @depend QfqEvents.js */
/* global EventEmitter */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';


    /**
     *
     * @constructor
     * @name QfqNS.PageState
     */
    n.PageState = function () {
        let hashValue = location.hash.slice(1).split(',')[0];
        // Check if hash value is not from tablesorter view
        const regex = /(=public:|=private:)/;
        if (regex.test(hashValue)) {
            hashValue = '';
        }

        this.pageState = hashValue;
        this.data = null;
        this.inPoppingHandler = false;
        this.eventEmitter = new EventEmitter();

        window.addEventListener("popstate", this.popStateHandler.bind(this));
    };

    n.PageState.prototype.on = n.EventEmitter.onMixin;
    /**
     *
     * @param event
     *
     * @private
     */
    n.PageState.prototype.popStateHandler = function (event) {

        n.Log.debug("Enter: PageState.popStateHandler()");

        this.inPoppingHandler = true;
        this.pageState = location.hash.slice(1);
        this.data = window.history.state;

        n.Log.debug("PageState.popStateHandler(): invoke user pop state handler(s)");

        this.eventEmitter.emitEvent('pagestate.state.popped', n.EventEmitter.makePayload(this, null));

        this.inPoppingHandler = false;
        n.Log.debug("Exit: PageState.popStateHandler()");

    };

    n.PageState.prototype.getPageState = function () {
        return this.pageState;
    };

    n.PageState.prototype.getPageData = function () {
        return this.data;
    };

    n.PageState.prototype.setPageState = function (state, data) {
        if (state.startsWith('#')) {
            this.pageState = state.slice(1);
            window.history.replaceState(data, null, state);
        } else {
            this.pageState = state;
            window.history.replaceState(data, null, '#' + state);
        }
        this.data = data;
    };

    n.PageState.prototype.newPageState = function (state, data) {
        if (state.startsWith('#')) {
            this.pageState = state.slice(1);
            window.history.pushState(data, null, state);
        } else {
            this.pageState = state;
            window.history.pushState(data, null, '#' + state);
        }

        this.data = data;
    };

})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     *
     * @type {{set: Function, get: Function, setSubTitle: Function}}
     *
     * @name QfqNS.PageTitle
     */
    n.PageTitle = {
        set: function (title) {
            if (title !== null) {
                document.title = title;
            }
        },
        get: function () {
            return document.title;
        },
        setSubTitle: function (subTitle) {
            var currentTitle = this.get();
            var subtitleStrippedOff = currentTitle.replace(/ - (.*)$/, '');
            document.title = subtitleStrippedOff + " - (" + subTitle + ")";
        }
    };
})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */
/* @depend ElementUpdate.js */
/* @depend Dirty.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    // TODO: This object is getting its own gravitational field. Start refactoring.
    /**
     * Represents a QFQ Form.
     *
     * QfqForm will autonomously fire a lock `extend` request when the lock expired, but the last change `t_c` has
     * been made during the lock period `t_l`. I.e. let `t_{current}` be the current time, an `extend` request is made
     * when
     *
     *    t_c + t_l > t_{current}
     *
     * holds.
     *
     * @param formId {string} value of the form's id attribute
     * @param submitTo {string} url where data will be submitted to
     * @param deleteUrl {string} url to call upon record deletion
     * @param dataRefreshUrl {string} url where to fetch new element values from
     * @param fileUploadTo {string} url used for file uploads
     * @param fileDeleteUrl {string} url used to delete files
     * @param dirtyUrl {string} url used to query
     * @param apiDeleteUrl {string} url used for multiform delete
     * @constructor
     *
     * @name QfqNS.QfqForm
     */
    n.QfqForm = function (formId, submitTo, deleteUrl, dataRefreshUrl, fileUploadTo, fileDeleteUrl, dirtyUrl, apiDeleteUrl) {
        this.formId = formId;
        this.submitTo = submitTo;
        this.deleteUrl = deleteUrl;
        this.dataRefreshUrl = dataRefreshUrl;
        this.fileUploadTo = fileUploadTo;
        this.fileDeleteUrl = fileDeleteUrl;
        this.dirtyUrl = dirtyUrl;
        this.apiDeleteUrl = apiDeleteUrl;
        this.dirtyFired = false;
        this.lockAcquired = false;
        this.formImmutableDueToConcurrentAccess = false;
        this.lockRenewalPhase = false;
        this.goToAfterSave = false;
        this.skipRequiredCheck = false;
        this.activateFirstRequiredTab = true;

        this.additionalQueryParameters = {
            'recordHashMd5': this.getRecordHashMd5()
        };

        if (!!$('#' + QfqNS.escapeJqueryIdSelector(this.formId)).data('enable-save-button')) {
            this.form = new n.Form(this.formId, false);
            this.getSaveButton().removeClass("disabled").removeAttr("disabled");
        } else {
            this.getSaveButton().addClass("disabled").attr("disabled", "disabled");
            this.form = new n.Form(this.formId, false);
        }

        if ($('#' + QfqNS.escapeJqueryIdSelector(this.formId)).data('required-off-but-mark')) {
            this.skipRequiredCheck = true;
        } else {
            this.skipRequiredCheck = false;
        }

        if (typeof $('#' + QfqNS.escapeJqueryIdSelector(this.formId)).data('activate-first-required-tab') !== 'undefined') {
            this.activateFirstRequiredTab = $('#' + QfqNS.escapeJqueryIdSelector(this.formId)).data('activate-first-required-tab');
        }

        this.infoLockedButton = this.infoLockedButton.bind(this);

        // This is required when displaying validation messages, in order to activate the tab, which has validation
        // issues
        this.bsTabs = null;
        this.lastButtonPress = null;

        this.eventEmitter = new EventEmitter();

        this.dirty = new n.Dirty(this.dirtyUrl);
        this.dirty.on(n.Dirty.EVENTS.SUCCESS, this.dirtyNotifySuccess.bind(this));
        this.dirty.on(n.Dirty.EVENTS.DENIED, this.dirtyNotifyDenied.bind(this));
        this.dirty.on(n.Dirty.EVENTS.FAILED, this.dirtyNotifyFailed.bind(this));
        this.dirty.on(n.Dirty.EVENTS.SUCCESS_TIMEOUT, this.dirtyTimeout.bind(this));
        this.dirty.on(n.Dirty.EVENTS.RENEWAL_DENIED, this.dirtyRenewalDenied.bind(this));
        this.dirty.on(n.Dirty.EVENTS.RENEWAL_SUCCESS, this.dirtyRenewalSuccess.bind(this));
        this.dirty.on(n.Dirty.EVENTS.CHECK_SUCCESS, this.dirtyCheckSuccess.bind(this));
        this.dirty.on(n.Dirty.EVENTS.CHECK_FAILED, this.dirtyCheckFailed.bind(this));

        this.form.on('form.changed', this.changeHandler.bind(this));
        this.form.on('form.reset', this.resetHandler.bind(this));
        this.form.on('form.submit.successful', this.submitSuccessDispatcher.bind(this));
        this.form.on('form.submit.failed', function (obj) {
            n.Helper.showAjaxError(null, obj.textStatus, obj.errorThrown);
        });

        this.getSaveButton().click(this.handleSaveClick.bind(this));
        this.getCloseButton().click(this.handleCloseClick.bind(this));
        this.getNewButton().click(this.handleNewClick.bind(this));
        this.getDeleteButton().click(this.handleDeleteClick.bind(this));
        this.getButtonPrevious().click(this.handlePreviousNextClick.bind(this));
        this.getNextButton().click(this.handlePreviousNextClick.bind(this));

        var that = this;
        $('.external-save').click(function(e) {
            var uri = $(this).data('target') || $(this).attr("href");
            that.callSave(uri);
            e.preventDefault();
            return false;
        });

        const multiToggles = document.querySelectorAll('[data-multi-toggle]');
        var initialized = false;
        var beautyContainer = null;
        multiToggles.forEach((multiToggle) => {
            const container = multiToggle.dataset.container || "tr"
            const element = multiToggle.closest(container).querySelector("" + multiToggle.dataset.multiToggle)
            if (!multiToggle.checked && element.value == '') { 
                element.classList.add("hidden")
            } else {
                multiToggle.checked = true
            }
            multiToggle.addEventListener("change", (e) => {
                if(multiToggle.checked) {
                    element.classList.remove("hidden");
                } else {
                    element.classList.add("hidden");
                }
            });

            if(multiToggle.dataset.beautyHack) {
                const groupContainer = multiToggle.closest("table");
                if (!initialized)  {
                    beautyContainer = document.createElement("div");
                    beautyContainer.classList.add("text");
                    groupContainer.parentElement.prepend(beautyContainer);
                    groupContainer.classList.add("hidden");
                    initialized = true;
                }
                const bElement = document.createElement("div");
                bElement.style.marginBottom = "25px"
                const cContainer = multiToggle.closest(container)
                beautyContainer.append(bElement);
                console.log("beautify", bElement);
                console.log("Container", element);
                Array.from(cContainer.children).forEach((child) => {
                    const myElement = document.createElement("div");
                    Array.from(child.children).forEach((bChild) => {
                        myElement.append(bChild);
                    })
                    bElement.append(myElement);
                    console.log("Children", child)
                });
            }
        });

        this.setupFormUpdateHandler();
        if (!!$('#' + QfqNS.escapeJqueryIdSelector(this.formId)).data('disable-return-key-submit')) {
            // Nothing to do
        } else {
            this.setupEnterKeyHandler();
        }

        this.fileUploader = new n.FileUpload('#' + this.formId, this.fileUploadTo);
        this.startUploadHandler = this.startUploadHandler.bind(this);
        this.fileUploader.on('fileupload.started', this.startUploadHandler);
        this.fileUploader.on('fileupload.upload.successful', that.fileUploadSuccessHandler);

        this.fileUploader.on('fileupload.upload.failed',
            function (obj) {
                n.Helper.showAjaxError(null, obj.textStatus, obj.errorThrown);
            });
        this.endUploadHandler = this.endUploadHandler.bind(this);
        this.fileUploader.on('fileupload.ended', this.endUploadHandler);

        this.fileDeleter = new n.FileDelete("#" + this.formId, this.fileDeleteUrl);
        this.fileDeleter.on('filedelete.delete.successful', this.fileDeleteSuccessHandler.bind(this));

        this.fileDeleter.on('filedelete.delete.failed',
            function (obj) {
                n.Helper.showAjaxError(null, obj.textStatus, obj.errorThrown);
            });

        var configurationData = this.readFormConfigurationData();
        this.applyFormConfiguration(configurationData);

        n.Helper.tinyMce();
        n.Helper.codemirror();

        this.form.on('form.submit.before', n.Helper.tinyMce.prepareSave);
        this.form.on('form.validation.before', n.Helper.tinyMce.prepareSave);
        this.form.on('form.validation.failed', this.validationError);
        this.form.on('form.validation.success', this.validationSuccess);
        this.form.qfqForm = this;

        $(".radio-inline").append($("<span>", { class: "checkmark", aria: "hidden"}));
        $(".checkbox-inline").append($("<span>", { class: "checkmark", aria: "hidden"}));
        $(".radio").append($("<span>", { class: "checkmark", aria: "hidden"}));
        $(".checkbox").each(function() {
            if (!this.querySelector(".checkmark")) {
                $(this).append($("<span>", {class: "checkmark", aria: "hidden"}));
            }
        });

        // Feature process all rows
        $(".process-row-all input[type=checkbox]").on("click", function() {
            var checkboxes = document.querySelectorAll('input[name^="_processRow-"]');
            for (var i = 0; i < checkboxes.length; i++) {
                if ($(checkboxes[i]).is(':visible')) {
                    checkboxes[i].checked = $(this).is(':checked');
                }
            }
        });

        this.dirty.check(this.getSip(), this.getRecordHashMd5AsQueryParameter());
        this.handleMultiStateCheckBox();
    };

    n.QfqForm.prototype.on = n.EventEmitter.onMixin;

    n.QfqForm.prototype.dirtyNotifySuccess = function (obj) {
        this.lockAcquired = true;
        // Intentionally empty. May be used later on.
    };

    n.QfqForm.prototype.dirtyCheckSuccess = function (obj) {
        // Same user has Locked the form give the option to release Lock and reload Page
        if (obj.data.sameUser) {
            this.lockAcquired = true;
            const that = this;

            const alert = new n.Alert({
                message: obj.data.message + "<br>Do you want to release the lock and reload?",
                type: "warning",
                modal: true,
                buttons: [
                    {label: "Yes", eventName: "ok"},
                    {label: "No", eventName: "cancel", focus: true}
                ]
            });

            alert.on("alert.ok", function () {
                that.releaseLock(true);
                setTimeout(() => {
                    window.location.reload();
                }, 300);
            });

            alert.show();
        }
    };

    n.QfqForm.prototype.dirtyCheckFailed = function (obj) {
        var message = new n.Alert({
            message: obj.data.message,
            type: "error",
            timeout: n.Alert.constants.NO_TIMEOUT,
            modal: true,
            buttons: [{
                label: "Close",
                eventName: 'close'
            }]
        });
        message.show();
    };

    n.QfqForm.prototype.dirtyRenewalSuccess = function (obj) {
        this.lockAcquired = true;
    };

    /**
     * @public
     */
    n.QfqForm.prototype.releaseLock = function (async) {
        if (!this.lockAcquired) {
            n.Log.debug("releaseLock(): no lock acquired or already released.");
            return;
        }
        n.Log.debug("releaseLock(): releasing lock.");
        this.dirty.release(this.getSip(), this.getRecordHashMd5AsQueryParameter(), async);
        this.resetLockState();
    };

    n.QfqForm.prototype.resetLockState = function () {
        this.dirty.clearSuccessTimeoutTimerIfSet();
        this.dirtyFired = false;
        this.formImmutableDueToConcurrentAccess = false;
        this.lockRenewalPhase = false;
        this.lockAcquired = false;
    };

    n.QfqForm.prototype.dirtyRenewalDenied = function (obj) {
        var that = this;
        var messageButtons = [{
            label: "Reload",
            eventName: 'reload'
        }];
        if (obj.data.status == "conflict_allow_force") {
            messageButtons.push({
                label: "Continue",
                eventName: 'ignore'
            });
        }
        var alert = new n.Alert(
            {
                type: "error",
                message: obj.data.message,
                modal: true,
                buttons: messageButtons
            }
        );
        alert.on('alert.reload', function () {
            that.eventEmitter.emitEvent('qfqform.close-intentional', n.EventEmitter.makePayload(that, null));
            window.location.reload(true);
        });
        alert.on('alert.ignore', function () {
            console.log("Ignored Recordlock");
        });
        alert.show();
    };

    n.QfqForm.prototype.dirtyTimeout = function (obj) {
        this.dirtyFired = false;
        this.lockAcquired = false;
        this.lockRenewalPhase = true;

        // Figure out whether the user made changes in the lock timeout period
        if (this.form.formChangedTimestampInMilliseconds + this.dirty.lockTimeoutInMilliseconds >
            Date.now()) {
            // Renew without user intervention.
            this.fireDirtyRequestIfRequired();
            // and bail out
            return;
        }
        var alert = new n.Alert(
            {
                message: "Exclusive access to document timed out.",
                type: "warning"
            }
        );
        alert.show();
    };

    n.QfqForm.prototype.dirtyNotifyDenied = function (obj) {
        var messageType;
        var isModal = true;
        var messageButtons = [{
            label: "Reload",
            eventName: 'reload'
        }];
        var message;
        var that = this;

        switch (obj.data.status) {
            case "conflict":
                messageType = "error";
                this.setButtonEnabled(this.getSaveButton(), false);
                this.getSaveButton().removeClass(this.getSaveButtonAttentionClass());
                this.setButtonEnabled(this.getDeleteButton(), false);
                this.formImmutableDueToConcurrentAccess = true;
                this.lockAcquired = false;
                break;
            case "conflict_allow_force":
                messageType = "warning";

                messageButtons = [{
                    label: "Continue",
                    eventName: 'ignore'
                }, {
                    label: "Cancel",
                    eventName: 'reload'
                }];
                break;
            case "error":
                messageType = "error";
                this.setButtonEnabled(this.getSaveButton(), false);
                this.getSaveButton().removeClass(this.getSaveButtonAttentionClass());
                this.setButtonEnabled(this.getDeleteButton(), false);
                // Do not make the form ask for saving changes.
                this.form.formChanged = false;
                this.formImmutableDueToConcurrentAccess = true;
                this.lockAcquired = false;
                break;
            default:
                n.Log.error('Invalid dirty status: \'' + obj.data.status + '\'. Assume messageType \'error\'');
                messageType = "error";
                break;
        }

        message = new n.Alert({
            message: obj.data.message,
            type: messageType,
            timeout: n.Alert.constants.NO_TIMEOUT,
            modal: isModal,
            buttons: messageButtons
        });
        message.on('alert.reload', function () {
            that.eventEmitter.emitEvent('qfqform.close-intentional', n.EventEmitter.makePayload(that, null));
            window.location.reload(true);
        });
        message.show();
    };

    n.QfqForm.prototype.dirtyNotifyFailed = function () {
        this.dirtyFired = false;
        this.lockAcquired = false;
    };

    n.QfqForm.prototype.validationError = function (info) {
        var $formControl = $(info.data.element);
        var $messageContainer = $formControl.siblings('.hidden.with-errors');

        if ($messageContainer.length === 0) {
            if ($formControl.parent().hasClass('input-group') || $formControl.parent('.twitter-typeahead')) {
                $messageContainer = $formControl.parent().siblings('.hidden.with-errors');
            }
        }

        $messageContainer.data('qfq.hidden.message', true);
        $messageContainer.removeClass('hidden');
    };

    n.QfqForm.prototype.validationSuccess = function (info) {
        var $formControl = $(info.data.element);
        var $messageContainer = $formControl.siblings('.with-errors');

        if ($messageContainer.length === 0) {
            if ($formControl.parent().hasClass('input-group') || $formControl.parent('.twitter-typeahead')) {
                $messageContainer = $formControl.parent().siblings('.with-errors');
            }
        }

        if ($messageContainer.data('qfq.hidden.message') === true) {
            $messageContainer.addClass('hidden');
        }
    };

    /**
     * @private
     */
    n.QfqForm.prototype.setupEnterKeyHandler = function () {
        $("input").keyup(function (event) {

            // Prevent save if enter press comes from qfqChat search
            if ($(event.target).hasClass('chat-search-input')) {
                return;
            }

            if (this.formImmutableDueToConcurrentAccess) {
                return;
            }
            if (event.which === 13 && this.submitOnEnter()) {
                if (this.isFormChanged()) {
                    // Changed from: save&close to save
                    this.lastButtonPress = "save";
                    n.Log.debug("save click (enter)");
                    this.submit();
                }
                event.preventDefault();
            }
        }.bind(this));
    };


    /**
     *
     * @private
     */
    n.QfqForm.prototype.readFormConfigurationData = function () {
        var $configuredElements = $("#" + this.formId + " [data-hidden],#" + this.formId + " [data-disabled],#" + this.formId + " [data-required]");

        var configurationArray = [];
        $configuredElements.each(function (index, element) {
            try {
                var $element = $(element);
                if (!element.hasAttribute("name")) {
                    n.Log.warning("Element has configuration data, but no name. Skipping");
                    return;
                }

                var configuration = {};
                configuration['form-element'] = $element.attr('name');

                var hiddenVal = $element.data('hidden');
                if (hiddenVal !== undefined) {
                    configuration.hidden = n.Helper.stringToBool(hiddenVal);
                }

                var disabledVal = $element.data('disabled');
                if (disabledVal !== undefined) {
                    configuration.disabled = n.Helper.stringToBool(disabledVal);
                }

                var requiredVal = $element.data("required");
                if (requiredVal !== undefined) {
                    configuration.required = n.Helper.stringToBool(requiredVal);
                }

                configurationArray.push(configuration);
            } catch (e) {
                n.Log.error(e.message);
            }
        });

        return configurationArray;

    };

    /**
     * @public
     * @param bsTabs
     */
    n.QfqForm.prototype.setBsTabs = function (bsTabs) {
        this.bsTabs = bsTabs;
    };

    n.QfqForm.prototype._createError = function (message) {
        var messageButtons = [{
            label: "Ok",
            eventName: 'close'
        }];
        var alert = new n.Alert({ "message": message, "type": "error", modal: true, buttons: messageButtons});
        alert.show();
    };

    /**
     * @private
     */
    n.QfqForm.prototype.fileDeleteSuccessHandler = function (obj) {
        if (!obj.data.status) {
            throw Error("Response on file upload missing status");
        }

        if (obj.data.status === "error") {
            this._createError(obj.data.message);
            return;
        }

        var $button = $(obj.target);
        $button.prop("disabled", true);

        var $buttonParent = $button.parent();
        $buttonParent.addClass('hidden');

        var $inputFile = $buttonParent.siblings('label');
        $inputFile.children(':file').prop("disabled", false);
        $inputFile.removeClass('hidden');
        $inputFile.children(':file').removeClass('hidden');

        $inputFile.children(':file').val("");
        if ($inputFile.children(':file').data('required') == 'required') {
            $inputFile.children(':file').prop("required", true);
        }

        this.form.markChanged();
    };

    /**
     * @private
     */
    n.QfqForm.prototype.fileUploadSuccessHandler = function (obj) {
        if (!obj.data.status) {
            throw Error("Response on file upload missing status");
        }

        if (obj.data.status === "error") {
            //this._createError(obj.data.message);
            var messageButtons = [{
                label: "Ok",
                eventName: 'close'
            }];
            var alert = new n.Alert({ "message": obj.data.message, "type": obj.data.status, modal: true, buttons: messageButtons});
            alert.show();
            return false;
        }

        var $fileInput = $(obj.target);
        $fileInput.prop("disabled", true);
        $fileInput.addClass("hidden");
        $fileInput.parent().addClass("hidden");

        var $deleteContainer = $fileInput.parent().siblings('div.uploaded-file');


        var fileNamesString = obj.target.files[0].name;
        var $fileNameSpan = $deleteContainer.find("span.uploaded-file-name");
        $fileNameSpan.empty().append(fileNamesString);

        var $deleteButton = $deleteContainer.find("button");
        $deleteButton.prop("disabled", false);

        $deleteContainer.removeClass("hidden");
    };

    /**
     *
     * @param $button
     * @param enabled {boolean}
     *
     * @private
     */
    n.QfqForm.prototype.setButtonEnabled = function ($button, enabled) {
        if (!$button) {
            n.Log.error("QfqForm#setButtonEnabled(): no button provided.");
            return;
        }
        if (!enabled) {
            $button.addClass("disabled");
            $button.prop("disabled", true);
        } else {
            $button.removeClass("disabled");
            $button.prop("disabled", false);
        }
    };

    /* Dynamic Update Trigger */
    n.QfqForm.prototype.setupFormUpdateHandler = function () {
        // To support removing or adding elements dynamically first we first remove the event before re-binding
        $('textarea[data-load],input[data-load],select[data-load]').off('change dp.change');
        $('.qfq-datepicker[data-load]').off('dp.hide');

        // QFQ datepicker: trigger dynamic update only when picker closes (not on every selection)
        $('.qfq-datepicker[data-load]').on('dp.hide', this.formUpdateHandler.bind(this));

        // All other inputs: use change (fires on focus lost for text, on picker close for date)
        $('textarea[data-load],input[data-load],select[data-load]').on('change', this.formUpdateHandler.bind(this));
    };

    n.QfqForm.prototype.formUpdateHandler = function () {
        var that = this;
        if (this.formImmutableDueToConcurrentAccess) {
            return;
        }

        // For better dynamic update compatibility (checkboxes). All input elements need to be not disabled for fully serializing by jquery.
        var form = $(this.form.$form[0]);
        // Get all disabled inputs
        var disabled = form.find(':input:disabled').removeAttr('disabled');
        var serializedForm = this.form.serialize();
        // Reset disabled inputs
        disabled.attr('disabled','disabled');

        $.post(this.dataRefreshUrl, serializedForm, "json")
            .fail(n.Helper.showAjaxError)
            .done(function (data) {
                this.handleFormUpdate(data);
            }.bind(that));

    };

    n.QfqForm.prototype.handleFormUpdate = function (data) {
        if (!data.status) {
            throw new Error("Expected 'status' attribute to be present.");
        }

        if (data.status === "error") {
            this._createError("Error while updating form:<br>" +
                (data.message ? data.message : "No reason given"));
            return;
        }

        if (data.status === "success") {
            if (!data['form-update']) {
                throw new Error("'form-update' attribute missing in form update data");
            }


            this.applyFormConfiguration(data['form-update']);
            this.applyElementConfiguration(data['element-update']);
            // Add back this event to elements
            // This is done to Support Dynamically adding or removing elements like checkboxes.
            this.setupFormUpdateHandler();
            return;
        }

        throw new Error("Unexpected status: '" + data.status + "'");
    };

    /**
     * @private
     */
    n.QfqForm.prototype.destroyFormAndSetText = function (text) {
        this.form = null;
        $('#' + this.formId).replaceWith($("<p>").append(text));
        this.eventEmitter.emitEvent('qfqform.destroyed', n.EventEmitter.makePayload(this, null));
    };

    /**
     * @private
     */
    n.QfqForm.prototype.handleSaveClick = function () {

        // "save,force" if sqlValidate() should be ignored, default is "save"
        this.lastButtonPress = this.getSaveButton().attr('data-save-force') || "save" ;

        // Remove attribute
        this.getSaveButton().removeAttr('data-save-force');

        n.Log.debug("save click");
        this.checkHiddenRequired();
        this.getSaveButton().removeClass('btn-info');
        this.getSaveButton().addClass('btn-warning active disabled');
        if (!this.form.saveInProgress) {
            this.submit();
        }

        // Activate history button
        this.getHistoryButton().removeAttr('disabled');
    };

    n.QfqForm.prototype.callSave = function(uri) {
        if(this.isFormChanged()) {
            this.handleSaveClick();
            this.goToAfterSave = uri;
        } else {
            window.location = uri;
            return;
        }
    };

    /**
     * @private
     */
    n.QfqForm.prototype.handleCloseClick = function () {
        this.lastButtonPress = "close";
        if (this.form.getFormChanged()) {
            var alert = new n.Alert({
                message: "Unsaved Changes.",
                type: "warning",
                modal: true,
                buttons: [
                    {label: "Save", eventName: "yes"},
                    {label: "Discard", eventName: "no", focus: true},
                    {label: "Cancel", eventName: "cancel"}
                ]
            });
            var that = this;
            alert.on('alert.yes', function () {
                that.submit();
            });
            alert.on('alert.no', function () {
                that.releaseLock();
                that.eventEmitter.emitEvent('qfqform.close-intentional', n.EventEmitter.makePayload(that, null));

                that.goBack();
            });
            alert.show();
        } else {
            this.goBack();
        }
    };
    /**
     * @private
     */
    n.QfqForm.prototype.handlePreviousNextClick = function (event) {
        // Prevent Link redirect
        event.preventDefault();
        this.lastButtonPress = "close";
        // Extract link form <a>
        var targetLink = event.currentTarget.href;
        if (this.form.getFormChanged()) {
            var alert = new n.Alert({
                message: "Unsaved Changes.",
                type: "warning",
                modal: true,
                buttons: [
                    {label: "Discard Changes", eventName: "no"},
                    {label: "Close", eventName: "cancel"}
                ]
            });
            var that= this;
            alert.on('alert.no', function () {
                that.releaseLock();
                that.eventEmitter.emitEvent('qfqform.close-intentional', n.EventEmitter.makePayload(that, null));

                that.goBack();
            });
            alert.show();
        } else {
            // redirect to link if no form changes where made
            window.location.href = targetLink;
        }
    };

    n.QfqForm.prototype.submit = function (queryParameters) {
        var submitQueryParameters;
        console.log("Save in progress", submitQueryParameters);
        var alert;
        var submitReason;

        if (this.form.validate() !== true) {


            var element = this.form.getFirstNonValidElement();
            if (element.hasAttribute('name') && this.bsTabs) {
                var tabId = this.bsTabs.getContainingTabIdForFormControl(element.getAttribute('name'));
                if (tabId && this.activateFirstRequiredTab) {
                    this.bsTabs.activateTab(tabId);
                }


                var form = document.getElementById(this.formId);
                var inputs = form.elements;

                for (var i = 0; i < inputs.length; i++) {
                    var e = inputs[i];
                    if(!e.willValidate) {
                        continue;
                    }
                    if(!e.checkValidity()) {
                        var updateTabId = this.bsTabs.getContainingTabIdForFormControl(e.getAttribute('name'));
                        if(updateTabId != tabId) {
                            this.bsTabs.addDot(updateTabId);
                        }
                    }
                }
            }

            // Since we might have switched the tab, re-validate to highlight errors
            this.form.$form.validator('update');
            this.form.$form.validator('validate');

            this.form.$form.each(function() {
                if (!$(this).validate) {

                }
            });

            if (!this.skipRequiredCheck) {
                alert = new n.Alert("Form is incomplete.", "warning");
                alert.timeout = 3000;
                alert.show();
                return;
            }
        }

        // First, remove all validation states, in case a previous submit has set a validation state, thus we're not
        // stockpiling them.
        if (!this.skipRequiredCheck) {
            this.clearAllValidationStates();
        }

        submitReason = {
            "submit_reason": this.lastButtonPress === "close" ? "save,close" : this.lastButtonPress
        };

        // Change to "save" for following actions
        if (this.lastButtonPress === "save,force") {
            this.lastButtonPress = "save";
        }

        submitQueryParameters = $.extend({}, queryParameters, submitReason);
        this.form.submitTo(this.submitTo, submitQueryParameters);
        console.log("Submitting with", submitQueryParameters);
        this.form.saveInProgress = true;
    };

    /**
     * @private
     */
    n.QfqForm.prototype.handleNewClick = function (event) {
        event.preventDefault();

        this.lastButtonPress = "new";
        if (this.form.getFormChanged()) {
            var alert = new n.Alert({
                message: "Unsaved Changes.",
                type: "warning",
                modal: true,
                buttons: [
                    {label: "Save", eventName: "yes", focus: true},
                    {label: "Discard", eventName: "no"},
                    {label: "Cancel", eventName: "cancel"}
                ]
            });
            var that = this;
            alert.on('alert.no', function () {
                that.eventEmitter.emitEvent('qfqform.close-intentional', n.EventEmitter.makePayload(that, null));

                var anchorTarget = that.getNewButtonTarget();
                window.location = anchorTarget;
            });
            alert.on('alert.yes', function () {
                that.submit();
            });
            alert.show();
        } else {
            var anchorTarget = this.getNewButtonTarget();
            window.location = anchorTarget;
        }
        n.Log.debug("new click");
    };

    /**
     * @private
     */
    n.QfqForm.prototype.handleDeleteClick = function () {
        this.lastButtonPress = "delete";
        n.Log.debug("delete click");
        var alert = new n.Alert({
            message: "Do you really want to delete the record?",
            type: "warning",
            modal: true,
            buttons: [
                {label: "Yes", eventName: "ok"},
                {label: "No", eventName: "cancel", focus: true}
            ]
        });

        var that = this;
        alert.on('alert.ok', function () {
            $.post(that.appendQueryParametersToUrl(that.deleteUrl, that.getRecordHashMd5AsQueryParameter()))
                .done(that.ajaxDeleteSuccessDispatcher.bind(that))
                .fail(n.Helper.showAjaxError);
        });
        alert.show();
    };

    n.QfqForm.prototype.getRecordHashMd5AsQueryParameter = function () {

        return {
            'recordHashMd5': this.getRecordHashMd5(),
            'tabUniqId': this.getTabUniqId()
        };
    };

    /**
     *
     * @param data
     * @param textStatus
     * @param jqXHR
     *
     * @private
     */
    n.QfqForm.prototype.ajaxDeleteSuccessDispatcher = function (data, textStatus, jqXHR) {
        if (!data.status) {
            throw new Error("No 'status' property 'data'");
        }

        switch (data.status) {
            case "error":
                this.handleLogicDeleteError(data);
                break;
            case "success":
                this.handleDeleteSuccess(data);
                break;
            default:
                throw new Error("Status '" + data.status + "' unknown.");
        }
    };

    /**
     *
     * @param data
     *
     * @private
     */
    n.QfqForm.prototype.handleDeleteSuccess = function (data) {
        this.setButtonEnabled(this.getCloseButton(), false);
        this.setButtonEnabled(this.getDeleteButton(), false);
        this.setButtonEnabled(this.getSaveButton(), false);
        this.setButtonEnabled(this.getNewButton(), false);
        this.destroyFormAndSetText("Record has been deleted!");

        if (!data.redirect || data.redirect === "auto") {
            this.goBack();
            return;
        }

        if (data.redirect === "no") {
            this._createError("redirect=='no' not allowed");
            return;
        }

        if (data.redirect === "url" && data['redirect-url']) {
            window.location = data['redirect-url'];
        }

        if (data.redirect === "url-skip-history" && data['redirect-url']) {
            window.location.replace(data['redirect-url']);
        }
    };

    /**
     *
     * @param data
     *
     * @private
     */
    n.QfqForm.prototype.handleLogicDeleteError = function (data) {
        if (!data.message) {
            throw Error("Status is 'error' but required 'message' attribute is missing.");
        }
        this._createError(data.message);

        this.setButtonEnabled(this.getDeleteButton(), false);
    };

    /**
     * Called when form is saved.
     * Once a file is deleted that was previously saved, the input will be required. This checks if said input is hidden and removes required attribute.
     */
    n.QfqForm.prototype.checkHiddenRequired = function() {
        var $form = $(this.form.$form[0]);
        var $required = $form.find(':input:file:hidden[required]');
        if(!!$required[0]) {
           $required.prop("required", false);
        }
    };

    /**
     * Called when form is changed.
     *
     * @param obj {n.QfqForm}
     *
     * @private
     */
    n.QfqForm.prototype.changeHandler = function (obj) {
        if (this.formImmutableDueToConcurrentAccess) {
            return;
        }
        this.getSaveButton().removeClass("disabled btn-default");
        this.getSaveButton().addClass(this.getSaveButtonAttentionClass());
        this.getSaveButton().removeAttr("disabled");
        this.fireDirtyRequestIfRequired();
    };

    n.QfqForm.prototype.fireDirtyRequestIfRequired = function () {
        if (this.dirtyFired) {
            return;
        }

        if (this.lockRenewalPhase) {
            this.dirty.renew(this.getSip(), this.getRecordHashMd5AsQueryParameter());
        } else {
            this.dirty.notify(this.getSip(), this.getRecordHashMd5AsQueryParameter());
        }
        this.dirtyFired = true;
    };

    /**
     *
     * @param obj {n.QfqForm}
     *
     * @private
     */
    n.QfqForm.prototype.resetHandler = function (obj) {
        this.getSaveButton().removeClass(this.getSaveButtonAttentionClass());
        this.getSaveButton().addClass('btn-default')

        // Only disable button if class enable-save-button not given
        if (!$('#' + QfqNS.escapeJqueryIdSelector(this.formId)).data('enable-save-button')) {
            this.getSaveButton().addClass("disabled");
            this.getSaveButton().attr("disabled", "disabled");
        }
        this.resetLockState();
    };

    n.QfqForm.prototype.deactivateSaveButton = function () {
        this.getSaveButton().addClass("disabled");
        //this.getSaveButton().attr("disabled", "disabled");
        this.getSaveButton().off('click');
        this.getSaveButton().on('click', this.infoLockedButton);
        this.getSaveButton().css("color", "#fff");
    };

    n.QfqForm.prototype.infoLockedButton = function(e) {
        var alert = new n.Alert({
            message: "Please wait until the upload finishes to save this form",
            buttons: [{ label: "Ok", eventName: "ok"}],
            modal: true
        });
        alert.show();
        e.preventDefault();
        return false;
    };

    n.QfqForm.prototype.activateSaveButton = function () {
        this.getSaveButton().off('click');
        this.getSaveButton().removeClass("disabled");
        //this.getSaveButton().removeAttr("disabled");
        this.getSaveButton().css("color", "");
        this.getSaveButton().click(this.handleSaveClick.bind(this));
    };

    n.QfqForm.prototype.getSaveButtonAttentionClass = function () {
        var $saveButton = this.getSaveButton();

        return $saveButton.data('class-on-change') || 'btn-info';
    };

    /**
     *
     * @returns {jQuery|HTMLElement}
     *
     * @private
     */
    n.QfqForm.prototype.getSaveButton = function () {
        return $("#save-button-" + this.formId);
    };

    /**
     *
     * @returns {*|jQuery|HTMLElement}
     *
     * @private
     */
     n.QfqForm.prototype.getHistoryButton = function () {
         return $("#form-history-" + this.formId);
     };

    /**
     *
     * @returns {jQuery|HTMLElement}
     *
     * @private
     */
    n.QfqForm.prototype.getCloseButton = function () {
        return $("#close-button-" + this.formId);
    };

    /**
     *
     * @returns {jQuery|HTMLElement}
     *
     * @private
     */
    n.QfqForm.prototype.getDeleteButton = function () {
        return $("#delete-button-" + this.formId);
    };

    /**
     *
     * @returns {jQuery|HTMLElement}
     *
     * @private
     */
    n.QfqForm.prototype.getNewButton = function () {
        return $("#form-new-button-" + this.formId);
    };



    /**
     * @private
     */
    n.QfqForm.prototype.submitSuccessDispatcher = function (obj) {
        if (!obj.data.status) {
            throw new Error("No 'status' property in 'data'");
        }

        switch (obj.data.status) {
            case "error":
                this.handleLogicSubmitError(obj.target, obj.data);
                break;
            case "success":
                this.handleSubmitSuccess(obj.target, obj.data);
                this.setupFormUpdateHandler();
                break;
            case "conflict":
                this.handleConflict(obj.target, obj.data);
                break;
            case "conflict_allow_force":
                this.handleOverrideableConflict(obj.target, obj.data);
                break;
            default:
                throw new Error("Status '" + obj.data.status + "' unknown.");
        }

    };

    /**
     *
     * @param form
     * @param data
     *
     * @private
     */
    n.QfqForm.prototype.handleLogicSubmitError = function (form, data) {
        if (!data.message) {
            throw Error("Status is 'error' but required 'message' attribute is missing.");
        }

        // Alert with force save option: data.text will be set for sqlValidate()
        if (data.text) {

            var forceButton = (data.force) ? { label: data.force, eventName: 'save-force' } : '';

            var alert = new n.Alert({
                message: data.text,
                type: data.level,
                buttons: [ { label: data.ok, eventName: 'ok' } ],
                modal: data.flagModal,
                timeout: data.timeout
            });

            if (forceButton) alert.buttons.unshift(forceButton);
            alert.on('alert.save-force', function () {
                $("#save-button-" + form.formId).attr('data-save-force', 'save,force');
                $("#save-button-" + form.formId).click();
            }.bind(form.formId));
            alert.show();

        } else {
            this._createError(data.message);
            if (data["field-name"] && this.bsTabs) {
                var tabId = this.bsTabs.getContainingTabIdForFormControl(data["field-name"]);
                if (tabId) {
                    this.bsTabs.activateTab(tabId);
                }

                this.setValidationState(data["field-name"], "error");
                this.setHelpBlockValidationMessage(data["field-name"], data["field-message"]);
            }
        }
    };

    /**
     *
     */
    n.QfqForm.prototype.handleConflict = function (form, data) {
        this.setButtonEnabled(this.getSaveButton(), false);
        this.getSaveButton().removeClass(this.getSaveButtonAttentionClass());
        this.setButtonEnabled(this.getDeleteButton(), false);
        this.formImmutableDueToConcurrentAccess = true;
        this.lockAcquired = false;
        this._createError(data.message);
    };

    n.QfqForm.prototype.handleOverrideableConflict = function (form, data) {
        var that = this;
        var alert = new n.Alert({
            message: data.message + 'Save anyway?',
            type: "warning",
            modal: true,
            buttons: [
                {label: "Yes", eventName: "yes"},
                {label: "No", eventName: "no", focus: true}
            ]
        });
        alert.on('alert.yes', function () {
            if (data.tokenForce) {
                that.submit({
                    tokenForce: data.tokenForce
                });
            } else {
                that.submit();
            }

        });
        alert.show();
    };

    /**
     *
     * @param form
     * @param data
     *
     * @private
     */
    n.QfqForm.prototype.handleSubmitSuccess = function (form, data) {
        n.Log.debug('Reset form state');
        this.getSaveButton().removeClass('btn-warning active disabled');
        this.getSaveButton().addClass('btn-default');
        form.resetFormChanged();
        this.resetLockState();

        switch (this.lastButtonPress) {
            case 'save&close':
                this.goBack();
                break;
            case 'save':
                if (data.message) {
                    var alert = new n.Alert(data.message);
                    alert.timeout = 3000;
                    alert.show();
                }

                // Skip other checks if external Save is called
                if (this.goToAfterSave) {
                    console.log("Called goToAfterSave = " + this.goToAfterSave);
                    window.location = this.goToAfterSave;
                    return;
                }

                // do we have to update the HTML Form?
                if (data['form-update']) {
                    this.applyFormConfiguration(data['form-update']);
                }

                if (data['element-update']) {this.applyElementConfiguration(data['element-update']);
                }

                if (data.redirect === "url" && data['redirect-url']) {
                    window.location = data['redirect-url'];
                    return;
                }

                if (data.redirect === "url-skip-history" && data['redirect-url']) {
                    window.location.replace(data['redirect-url']);
                    return;
                }

                if (data.redirect === "close") {
                    this.goBack();
                    return;
                }

                break;
            case 'close':
                if (!data.redirect || data.redirect === "no") {
                    return;
                }

                if (data.redirect === "auto" || data.redirect === "close") {
                    this.goBack();
                    return;
                }

                if (data.redirect === "url" && data['redirect-url']) {
                    window.location = data['redirect-url'];
                    return;
                }

                if (data.redirect === "url-skip-history" && data['redirect-url']) {
                    window.location.replace(data['redirect-url']);
                    return;
                }

                break;
            case 'new':
                var target = this.getNewButtonTarget();

                window.location.replace(target);
                return;

            default:
                if (data.redirect === "auto") {
                    this.goBack();
                    return;
                }

                if (data.redirect === "url" && data['redirect-url']) {
                    window.location = data['redirect-url'];
                    return;
                }

                if (data.redirect === "url-skip-history" && data['redirect-url']) {
                    window.location.replace(data['redirect-url']);
                    return;
                }

                break;
        }

        if(this.skipRequiredCheck) {
            this.form.$form.validator('update');
            this.form.$form.validator('validate');
        }
    };

    n.QfqForm.prototype.getNewButtonTarget = function () {
        return $('#form-new-button').attr('href');
    };

    n.QfqForm.prototype.getFormGroupByControlName = function (formControlName) {
        console.log("Form Control Name: " + formControlName);
        var $formControl = $("[name='" + formControlName + "']");
        if ($formControl.length === 0) {
            n.Log.debug("QfqForm.setValidationState(): unable to find form control with name '" + formControlName + "'");
            return null;
        }

        var iterator = $formControl[0];
        while (iterator !== null) {
            var $iterator = $(iterator);
            if ($iterator.hasClass('form-group')) {
                return $iterator;
            }

            iterator = iterator.parentElement;
        }

        return null;
    };

    n.QfqForm.prototype.setValidationState = function (formControlName, state) {
        var $formGroup = this.getFormGroupByControlName(formControlName);
        if ($formGroup) {
            $formGroup.addClass("has-" + state);
            $formGroup.addClass("testitest");
        }
    };

    n.QfqForm.prototype.resetValidationState = function (formControlName) {
        var $formGroup = this.getFormGroupByControlName(formControlName).find('input');
        $formGroup.removeClass("has-danger");
        $formGroup.removeClass("has-error");
        $formGroup.removeClass("has-success");
        $formGroup.removeClass("has-danger");
    };


    n.QfqForm.prototype.clearAllValidationStates = function () {
        // Reset any messages/states added by bootstrap-validator.
        this.form.$form.validator('reset');

        // Reset any states added by a call to QfqForm#setValidationState()
        $('.has-warning,.has-error,.has-success,.has-danger').removeClass("has-warning has-error has-success" +
            " has-danger");

        // Remove all messages received from server upon form submit.
        $('[data-qfq=validation-message]').remove();
    };

    /**
     *
     * @param formControlName
     * @param text
     */
    n.QfqForm.prototype.setHelpBlockValidationMessage = function (formControlName, text) {
        /*
         * Why is this method here and not in FormGroup? Adding this particular method to FormGroup is easy, however
         * QfqForm.clearAllValidationStates() would not find its proper place in FormGroup, since FormGroup operates
         * on one element. We would end up having the responsibilities spread across several classes, which would be
         * confusing.
         */
        var $formGroup = this.getFormGroupByControlName(formControlName);
        if (!$formGroup) return;

        var $helpBlockColumn;
        var $formGroupSubDivs = $formGroup.find("div");
        if ($formGroupSubDivs.length < 3) {
            $helpBlockColumn = $("<div>").addClass("col-md-4");
            $formGroup.append($helpBlockColumn);
        } else {
            $helpBlockColumn = $($formGroupSubDivs[2]);
        }

        $helpBlockColumn.append(
            $("<p>")
                .addClass("help-block")
                .attr("data-qfq", "validation-message")
                .append(text)
                .prepend($("<div>", { class: "arrow arrow-up"}))
        );
    };

    /**
     *
     * @param configuration {array} array of objects.
     */
    var typeAheadFlag = false;
    var loading = true;
    n.QfqForm.prototype.applyFormConfiguration = function (configuration) {
        var arrayLength = configuration.length;
        var countElementArray = 0;
        for (var i = 0; i < arrayLength; i++) {
            var configurationItem = configuration[i];
            var formElementName = configurationItem["form-element"];
            if (formElementName === undefined) {
                n.Log.error("configuration lacks 'form-element' attribute. Skipping.");
                continue;
            }
            if(formElementName === 'qfq-form-title') {
                $("." + formElementName).html(configurationItem.value);
            }
            // Insert download button for uploads after Form is saved.
            if (configurationItem["type-file"] && document.querySelector("button[name='delete-" + formElementName + "']") !== null) {
                var downloadButton = configurationItem["html-content"];
                var fileNameSpan = document.querySelector("button[name='delete-" + formElementName + "']").previousElementSibling;
                fileNameSpan.innerHTML = '';
                fileNameSpan.insertAdjacentHTML('afterbegin', downloadButton);
            }
            try {
                var element = n.Element.getElement(formElementName);
                // Cleaner way to set states for tinymce
                // This triggers the event on the unaccesable textarea
                // The tinymce registers a listener on the textarea
                // See helper/tinyMCE.js for details
                if (element.$element !== undefined) {
                    if (configurationItem.value !== undefined) {
                        // Skip ICJ visual inputs - they manage their own values
                        if (element.$element.hasClass('qfq-icj-input')) {
                            // Do nothing - ICJ handles its own values
                        } else if (element.$element.hasClass('qfq-typeahead')) {
                            if (configurationItem.value !== 0) {
                                element.setValue(configurationItem.value);
                            }
                            typeAheadFlag = true;
                        } else if (!element.$element.hasClass('qfq-codemirror')){
                            element.setValue(configurationItem.value);
                        }
                    }

                    if (element.$element.hasClass('qfq-tinymce')) {
                        var tinyMceId = element.$element.attr('id');
                        var tinyMceEditor = tinymce.get(tinyMceId);
                        var hadFocus = tinyMceEditor.hasFocus();
                        var cursorPath = null;

                        // Save cursor position as DOM path before replacing content
                        if (hadFocus) {
                            try {
                                var rng = tinyMceEditor.selection.getRng();
                                var node = rng.startContainer;
                                var body = tinyMceEditor.getBody();
                                var path = [];
                                var current = node;
                                while (current && current !== body) {
                                    var parent = current.parentNode;
                                    path.unshift(Array.prototype.indexOf.call(parent.childNodes, current));
                                    current = parent;
                                }
                                cursorPath = { path: path, offset: rng.startOffset };
                            } catch (e) { /* ignore */ }
                        }

                        tinyMceEditor.setContent(configurationItem.value);

                        // Restore cursor position by following the saved DOM path
                        if (hadFocus && cursorPath) {
                            try {
                                var targetNode = tinyMceEditor.getBody();
                                for (var idx = 0; idx < cursorPath.path.length; idx++) {
                                    if (targetNode.childNodes[cursorPath.path[idx]]) {
                                        targetNode = targetNode.childNodes[cursorPath.path[idx]];
                                    } else {
                                        targetNode = null;
                                        break;
                                    }
                                }
                                if (targetNode) {
                                    var maxOffset = targetNode.nodeType === 3 ? targetNode.length : targetNode.childNodes.length;
                                    var newRng = tinyMceEditor.dom.createRng();
                                    newRng.setStart(targetNode, Math.min(cursorPath.offset, maxOffset));
                                    newRng.collapse(true);
                                    tinyMceEditor.selection.setRng(newRng);
                                    tinyMceEditor.focus();
                                }
                            } catch (e) { /* ignore */ }
                        }

                        element.$element.trigger("blur", [configurationItem]);
                    }

                    if (element.$element.hasClass('qfq-codemirror')) {
                        var cm = document.getElementById(element.$element.attr('id')).nextSibling;
                        var editor = cm.CodeMirror;
                        if (editor !== undefined) {
                            editor.setValue(configurationItem.value);
                        }
                    }


                    if (configurationItem.readonly !== undefined) {
                        // Readonly and disabled is the same in our domain
                        element.setEnabled(!configurationItem.readonly);
                    }

                    if (configurationItem.disabled !== undefined) {
                        // Readonly and disabled is the same in our domain
                        element.setEnabled(!configurationItem.disabled);
                    }

                    if (configurationItem.hidden !== undefined) {
                        element.setHidden(configurationItem.hidden);
                    }

                    if (configurationItem.required !== undefined) {
                        element.setRequired(configuration.required);
                        if (element.$element) {
                            if (element.$element.is("select")) {
                                element.$element.prop('required', configurationItem.required);
                                element.$element.attr('data-required', 'yes');
                            }
                            if (element.$element.is("input[type=hidden]")) {
                                console.log("Update Hidden");
                                element.$element.prop("required", configurationItem.required);
                            }

                        }
                    }
                    countElementArray = 0;
                } else {
                    // checkboxes without being in form groups aren't triggered over dynamic update, we need to handle them separately.
                    // for checkboxes with itemList (multiple) we need the countElementArray to trigger the right box
                    if ($(element).attr('type') !== undefined) {
                        if ($(element).attr('type').toLowerCase() === 'checkbox') {
                            var elementLength = element.length;
                            if (elementLength > 1) {
                                if (elementLength !== countElementArray) {
                                    element = element[countElementArray];
                                    countElementArray++;

                                    if (elementLength === countElementArray) {
                                        countElementArray = 0;
                                    }
                                }
                            }

                            if (configurationItem.value !== undefined) {
                                $(element).prop('checked', configurationItem.value);
                            }
                        }
                    }

                    // Chats needs to be loaded with new data
                    // It refreshes the output
                    if (element.classList !== undefined && element.classList.contains('qfq-chat-window')) {
                        n.Helper.qfqChat.setInputState(element, configurationItem);
                    }
                }

            } catch (e) {
                n.Log.error(e.message);
            }
        }
    };

    n.QfqForm.prototype.applyElementConfiguration = function (configuration) {
        if (!configuration) {
            console.error("No configuration for Element Update found");
            return;
        }

        n.ElementUpdate.updateAll(configuration);

        // Trigger typeahead after dynamic update to show prefetch value, except empty values
        if (typeAheadFlag) {
            $('.tt-input[value!="0"]').blur();
        }
    };

    /**
     * @private
     * @param obj
     */
    n.QfqForm.prototype.startUploadHandler = function (obj) {
        $(obj.target).after(
            $('<i>').addClass('spinner')
        );
        this.deactivateSaveButton();
    };

    /**
     * @private
     * @param obj
     */
    n.QfqForm.prototype.endUploadHandler = function (obj) {
        var $siblings = $(obj.target).siblings();
        $siblings.filter("i").remove();
        this.activateSaveButton();
    };

    /**
     * Retrieve SIP as stored in hidden input field.
     *
     * @returns {string} sip
     */
    n.QfqForm.prototype.getSip = function () {
        return this.getValueOfHiddenInputField('s');
    };

    /**
     * Retrieve recordHashMd5 as stored in hidden input field.
     *
     * @returns {string} sip
     */
    n.QfqForm.prototype.getRecordHashMd5 = function () {
        return this.getValueOfHiddenInputField('recordHashMd5');
    };

    /**
     * Misuse the window.name attribute to set/get a tab uniq identifier.
     * Use millisecond timestamp as identifier: hopefully there are never more than one tab opened per millisecond in a single browser session.
     *
     * @returns {string} tab identifier
     */
    n.QfqForm.prototype.getTabUniqId = function () {

        if (!window.name.toString()) {
            // Misuse window.name as tab uniq identifier. Set window.name if it is empty.
            window.name = Date.now().toString();
        }

        return window.name;
    };

    n.QfqForm.prototype.getValueOfHiddenInputField = function (fieldName) {
        return $('#' + this.formId + ' input[name=' + fieldName + ']').val();
    };

    /**
     * @public
     */
    n.QfqForm.prototype.isFormChanged = function () {
        return this.form.formChanged;
    };

    /**
     * @private
     */
    n.QfqForm.prototype.submitOnEnter = function () {
        return !(!!this.form.$form.data('disable-return-key-submit'));
    };

    n.QfqForm.prototype.appendQueryParametersToUrl = function (url, queryParameterObject) {
        var queryParameterString = $.param(queryParameterObject);
        if (url.indexOf('?') !== -1) {
            return url + "&" + queryParameterString;
        }

        return url + "?" + queryParameterString;
    };

    /**
     * @private
     *
     * Go back in the history, or pop an alert when no history.
     */
    n.QfqForm.prototype.goBack = function () {
        var alert;

        if (window.history.length < 2) {
            alert = new n.Alert(
                {
                    type: "info",
                    message: "Please close the tab/window."
                }
            );

            alert.show();
            return;
        }

        window.history.back();
    };
    /**
     * Set state and add event listener to MultiState Checkbox.
     */
    n.QfqForm.prototype.handleMultiStateCheckBox = function() {
        // Retrieve all elements with the "multiStateCheckbox" class.
        let checkBoxDivs = document.getElementsByClassName("multiStateCheckbox");
        if (!checkBoxDivs.length) return; // Exit early if no checkbox elements are found.

        Array.from(checkBoxDivs).forEach(checkBoxDiv => {
            let checkBoxIcon = checkBoxDiv.querySelector(".multiStateCheckboxIcon");
            let checkBoxLabel = checkBoxDiv.closest("label").querySelector(".multiStateCheckboxSpan");
            let checkBoxInput = checkBoxDiv.closest("label").querySelector("input");

            // Parse the options provided in the data-options attribute.
            let options = JSON.parse(checkBoxDiv.getAttribute('data-options'));
            let currentIndex = 0;

            // Initialize the input value and icon style based on the first option.
            checkBoxInput.value = options[currentIndex].value;
            checkBoxIcon.style.color = options[currentIndex].iconColor;

            // Set the border color based on the first option's color.
            if (options[currentIndex].color === 'white'){
                checkBoxDiv.style.borderColor = "#ccc";
            } else {
                checkBoxDiv.style.borderColor = options[currentIndex].color || "#2196f3";
            }

            // If the checkbox is disabled, adjust appearance and exit early.
            if (checkBoxDiv.hasAttribute("disabled")) {
                checkBoxDiv.style.opacity = "0.5";
                checkBoxDiv.style.cursor = "not-allowed";
                return;
            }

            // Add a click event listener to cycle through available options.
            checkBoxDiv.addEventListener("click", () => {
                // Increment the index, cycling back to 0 if it exceeds the options length.
                currentIndex = (currentIndex + 1) % options.length;

                // icon class and color.
                checkBoxIcon.className = `fas ${options[currentIndex].icon}`;
                checkBoxIcon.style.color = options[currentIndex].iconColor;

                // background color and border color.
                checkBoxDiv.style.backgroundColor = options[currentIndex].color || "#2196f3";
                if (options[currentIndex].color === 'white') {
                    checkBoxDiv.style.borderColor = "#ccc";
                } else {
                    checkBoxDiv.style.borderColor = options[currentIndex].color || "#2196f3";
                }

                // Update data selected value attribute.
                checkBoxDiv.setAttribute("data-selected-value", options[currentIndex].value);
                // Update the displayed label.
                checkBoxLabel.innerText = options[currentIndex].label;

                // Set the value of the hidden input field and trigger a change event.
                $(checkBoxInput).val(options[currentIndex].value).trigger('change');

                this.form.markChanged();
            });
        });
    };



    n.QfqForm.prototype.getNextButton = function () {
        return $("#form-button-next");
    };

    n.QfqForm.prototype.getButtonPrevious = function () {
        return $("#form-button-previous");
    };

})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */
/* global console */
/* @depend QfqEvents.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     *
     * @param settings
     * @constructor
     *
     * @name QfqNS.QfqPage
     */
    n.QfqPage = function (settings) {
        console.log("Creating QFQPage", settings);
        this.qfqForm = {};
        this.settings = $.extend(
            {
                tabsId: "qfqTabs",
                formId: "qfqForm",
                submitTo: "typo3conf/ext/qfq/Classes/Api/save.php",
                deleteUrl: "typo3conf/ext/qfq/Classes/Api/delete.php",
                refreshUrl: "typo3conf/ext/qfq/Classes/Api/load.php",
                fileUploadTo: "typo3conf/ext/qfq/Classes/Api/upload.php",
                fileDeleteUrl: "typo3conf/ext/qfq/Classes/Api/filedelete.php",
                typeAheadUrl: "typo3conf/ext/qfq/Classes/Api/typeahead.php",
                dirtyUrl: "typo3conf/ext/qfq/Classes/Api/dirty.php",
                pageState: new n.PageState()
            }, settings
        );

        n.Log.level = settings.logLevel;

        this.intentionalClose = false;
        this.downloadLinkClicked = false;

        try {
            this.bsTabs = new n.BSTabs(this.settings.tabsId);

            var storedFormInfos = [];

            // get current state from session storage
            if(sessionStorage.getItem("formInfos") !== null) {
                storedFormInfos = JSON.parse(sessionStorage.getItem("formInfos"));
            }

            var currentForm = this.bsTabs.currentFormName;
            var currentRecordId = this.bsTabs.currentRecordId;
            var activeLastPill = this.bsTabs.currentActiveLastPill;

            var actualIndex = -1;
            var indexNr = 0;
            if(storedFormInfos.length !== 0){
                if(storedFormInfos[0] !== ''){
                    storedFormInfos.forEach(function callback(element) {
                        if(element === currentForm && storedFormInfos[indexNr+2] === currentRecordId) {
                            actualIndex = indexNr;
                        }
                        indexNr++;
                    });
                }
            }

            var currentState = this.settings.pageState.getPageState();

            // load from sessionStorage or from path given hash if not empty
            if(activeLastPill === "true") {
                if (actualIndex !== -1 && location.hash === "") {
                    currentState = storedFormInfos[actualIndex + 1];
                }
            }

            if (currentState !== "") {
                this.bsTabs.activateTab(currentState);
                n.PageTitle.setSubTitle(this.bsTabs.getTabName(currentState));
            } else {
                this.settings.pageState.setPageState(this.bsTabs.getCurrentTab(), n.PageTitle.get());
            }

            this.bsTabs.on('bootstrap.tab.shown', this.tabShowHandler.bind(this));
            this.bsTabs.on('bootstrap.tab.shown', function(e){
                // Initialize FilePond in the newly active tab content
                setTimeout(() => {
                    // Get the ID of the tab content
                    const tabContentId = e.target.currentTab;
                    const tabContent = document.querySelector(`#${tabContentId}`);
                    n.Helper.initializeFilePondInContainer(tabContent, n.form);
                }, 100);
            });

            this.settings.pageState.on('pagestate.state.popped', this.popStateHandler.bind(this));
        } catch (e) {
            n.Log.message(e.message);
            this.bsTabs = null;
        }

        try {
            this.qfqForm = new n.QfqForm(
                this.settings.formId,
                this.settings.submitTo,
                this.settings.deleteUrl,
                this.settings.refreshUrl,
                this.settings.fileUploadTo,
                this.settings.fileDeleteUrl,
                this.settings.dirtyUrl,
                settings.apiDeleteUrl)
            this.qfqForm.setBsTabs(this.bsTabs);
            this.qfqForm.on('qfqform.destroyed', this.destroyFormHandler.bind(this));

            var that = this;
            this.qfqForm.on('qfqform.close-intentional', function () {
                that.intentionalClose = true;
            });

            window.addEventListener("beforeunload", this.beforeUnloadHandler.bind(this));
            document.addEventListener('click', (function (that) {
                return function (e) {
                    var link = e.target.closest('a[href]');
                    if (link && (link.href.includes('download.php') || link.href.includes('/d.php') )) {
                        that.downloadLinkClicked = true;
                    }
                };
            })(this));
            // We have to use 'pagehide'. 'unload' is too late and the ajax request is lost.
            window.addEventListener("pagehide", (function (that) {
                return function () {
                    document.activeElement.blur();
                    that.qfqForm.releaseLock(true);
                };
            })(this));
            this.recordList = new n.QfqRecordList(settings.apiDeleteUrl);
        } catch (e) {
            n.Log.error(e.message);
            this.qfqForm = null;
        }

        var page = this;
        // Initialize Fabric to access form events
        try {
            console.log("Form ID: ", this.settings.formId)
            console.log("Looking for Fabric Element", $("#" + this.settings.formId + " .annotate-graphic"))
            if(!this.settings.formId) {
                throw new Error("QFQPage missing formId")
            }

            $("#" + this.settings.formId + " .annotate-graphic").each(function() {
                var qfqFabric = new QfqNS.Fabric();
                qfqFabric.initialize($(this), page);
            });

            $("#" + this.settings.formId + " .annotate-text").each(function() {
                var codeCorrection = new QfqNS.CodeCorrection();
                codeCorrection.initialize($(this), page);
            });

            /* initaliaze image-adjust */
            const imageEditors = document.querySelectorAll(".qfq-image-adjust")
            console.log("Image Adjust Elements", imageEditors)
            imageEditors.forEach(editor => {
                console.log("Current Editor", editor)
                const imageAdjust = new ImageAdjust()
                imageAdjust.initialize(editor, page, n)
            })
            
        } catch (e) {
            n.Log.error(e.message);
        }

        QfqNS.TypeAhead.install(this.settings.typeAheadUrl);
        QfqNS.AiAssist.install();
        QfqNS.AiChat.install();
        QfqNS.CharacterCount.initialize();
        //n.initializeDatetimepicker(false);
    };

    /**
     * @private
     * 
     * Releaselock has to be handled at the pagehide event, not here
     */
    n.QfqPage.prototype.beforeUnloadHandler = function (event) {
        var message = "\0/";
        n.Log.debug("beforeUnloadHandler()");

        if (this.downloadLinkClicked) {
            this.downloadLinkClicked = false;
            return;
        }

        if (this.qfqForm.isFormChanged() && !this.intentionalClose) {
            n.Log.debug("Changes detected - not regular close");
            document.activeElement.blur();
            this.qfqForm.releaseLock(true);
            event.returnValue = message;
            return message;
        }
    };

    /**
     * @private
     */
    n.QfqPage.prototype.destroyFormHandler = function (obj) {
        this.settings.qfqForm = null;
        $('#' + this.settings.tabsId).remove();
    };

    n.QfqPage.prototype.tabShowHandler = function (obj) {
        // tabShowHandler will be called every time the tab will be shown, regardless of whether or not this happens
        // because of BSTabs.activateTab() or user interaction.
        //
        // Therefore, we have to make sure, that tabShowHandler() does not save the page state while we're restoring
        // a previous state, i.e. we're called because of the popStateHandler() below.
        if (this.settings.pageState.inPoppingHandler) {
            n.Log.debug("Prematurely terminating QfqPage.tabShowHandler(): called due to page state" +
                " restoration.");
            return;
        }
        var currentTabId = obj.target.getCurrentTab();
        n.Log.debug('Saving state: ' + currentTabId);

        // Implementation save current state in session storage
        var storedFormInfos = [];

        if(sessionStorage.getItem("formInfos") !== null){
            storedFormInfos = JSON.parse(sessionStorage.getItem("formInfos"));
        }

        var currentForm = obj.target.currentFormName;
        var currentRecordId = obj.target.currentRecordId;
        var activeLastPill = obj.target.currentActiveLastPill;

        var actualIndex = -1;
        var indexNr = 0;
        if(activeLastPill === "true") {
            if (storedFormInfos.length !== 0) {
                if (storedFormInfos[0] !== '') {
                    storedFormInfos.forEach(function callback(element) {
                        if (element === currentForm && storedFormInfos[indexNr + 2] === currentRecordId) {
                            actualIndex = indexNr;
                        }
                        indexNr++;
                    });
                }
            }

            // fill sessionStorage, there are 3 ways for filling the sessionStorage: 1.If empty - first time filling, 2.If there is anything - add it to them, 3
            // 1.If array from storage is empty - fill it first time
            if (storedFormInfos.length === 0) {
                storedFormInfos[0] = currentForm;
                storedFormInfos[1] = currentTabId;
                storedFormInfos[2] = currentRecordId;

                // 2.If there is anything in storage but not the actual opened forms - add this new information to the existing array
            } else if (actualIndex === -1) {
                storedFormInfos[indexNr] = currentForm;
                storedFormInfos[indexNr + 1] = currentTabId;
                storedFormInfos[indexNr + 2] = currentRecordId;

                // 3.If actual openend form is included in sessionStorage - only change the array values of the existing informations
            } else {
                storedFormInfos[actualIndex] = currentForm;
                storedFormInfos[actualIndex + 1] = currentTabId;
                storedFormInfos[actualIndex + 2] = currentRecordId;
            }

            // Set sessionStorage with customized array
            sessionStorage.setItem("formInfos", JSON.stringify(storedFormInfos));
        }
        n.PageTitle.setSubTitle(obj.target.getTabName(currentTabId));
        this.settings.pageState.setPageState(currentTabId, n.PageTitle.get());
    };

    n.QfqPage.prototype.popStateHandler = function (obj) {
        this.bsTabs.activateTab(obj.target.getPageState());
        n.PageTitle.set(obj.target.getPageData());
    };

})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */
/* global $ */
/* global console */

var QfqNS = QfqNS || {};

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
(function (n) {
    'use strict';

    /**
     *
     * @param deleteUrl
     * @param element
     * @constructor
     *
     * @name QfqNS.QfqRecordList
     */
    n.QfqRecordList = function (deleteUrl, element = false) {
        console.log("initialized with this url", deleteUrl);
        this.deleteUrl = deleteUrl;
        this.deleteButtonClass = 'record-delete';
        this.recordClass = 'record';
        this.sipDataAttribute = 'sip';

        // Default handling if not specific element is passed
        if (!element) {
            this.connectClickHandler();
        }
    };

    /**
     * @private
     */
    n.QfqRecordList.prototype.connectClickHandler = function (element = false) {
        var boundClickHandler = this.clickHandler.bind(this);

        // For dynamic loaded elements, handle each of them separately
        if (element) {
            element.on('click', boundClickHandler);
        } else {
            // Default event handler for all delete buttons after page load
            $("." + this.deleteButtonClass).on('click', boundClickHandler);
        }
    };

    n.QfqRecordList.prototype.handleDeleteButtonClick = function (event) {
        var $eventTarget = $(event.delegateTarget);
        var $recordElement = this.getRecordElement(event.target);

        if ($recordElement.length !== 1) {
            throw new Error($recordElement.length + ' match(es) found for record class');
        }

        var sip = $eventTarget.data(this.sipDataAttribute);

        if (!sip) {
            throw new Error('No `sip` on delete button');
        }


        var alert = new n.Alert({
            message: "Do you really want to delete the record?",
            type: "warning",
            modal: true,
            buttons: [
                {label: "Yes", eventName: "ok"},
                {label: "No", eventName: "cancel", focus: true}
            ]
        });
        var that = this;
        alert.on('alert.ok', function () {
            $.post(that.deleteUrl + "?s=" + sip)
                .done(that.ajaxDeleteSuccessDispatcher.bind(that, $recordElement))
                .fail(n.Helper.showAjaxError);
        });
        alert.show();
    };

    /**
     *
     * @param $recordElement
     * @param data
     * @param textStatus
     * @param jqXHR
     *
     * @private
     */
    n.QfqRecordList.prototype.ajaxDeleteSuccessDispatcher = function ($recordElement, data, textStatus, jqXHR) {
        if (!data.status) {
            throw new Error("No 'status' property 'data'");
        }

        switch (data.status) {
            case "error":
                this.handleLogicDeleteError(data);
                break;
            case "success":
                this.handleDeleteSuccess($recordElement, data);
                break;
            default:
                throw new Error("Status '" + data.status + "' unknown.");
        }
    };

    n.QfqRecordList.prototype.handleDeleteSuccess = function ($recordElement, data) {
        if (data.redirect && data.redirect === "url" && data['redirect-url']) {
            window.location = data['redirect-url'];
            return;
        }
        if (data.redirect && data.redirect === "no") {
            var alert = new n.Alert("redirect=='no' not allowed", "error");
            alert.show();
        }

        var info = new n.Alert("Record successfully deleted", "info");
        info.timeout = 1500;
        info.show();
        $recordElement.fadeOut(function () {
            $recordElement.remove();
        });
    };

    n.QfqRecordList.prototype.getRecordElement = function (element) {
        return $(element).closest('.' + this.recordClass);
    };

    /**
     *
     * @param data
     *
     * @private
     */
    n.QfqRecordList.prototype.handleLogicDeleteError = function (data) {
        if (!data.message) {
            throw Error("Status is 'error' but required 'message' attribute is missing.");
        }
        var alert = new n.Alert(data.message, "error");
        alert.show();
    };

    n.QfqRecordList.prototype.clickHandler = function (event) {
        // Check if the event is triggered by the "Enter" key
        var enterKeyTriggered = event.originalEvent.detail === 0;
        // Check if the delete button is focused
        var isButtonFocused = $(event.target).is(":focus");
        // If the event is triggered by the "Enter" key and the delete button has the specific class, stop the event
        if (enterKeyTriggered && !isButtonFocused && $(event.target).hasClass(this.deleteButtonClass)) {
            event.preventDefault();
            event.stopPropagation();
            return;
        }
        // If the event is not stopped, call the handleDeleteButtonClick method
        this.handleDeleteButtonClick(event);
    };


})(QfqNS);
/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    /**
     * Highlights Syntax with provided JSON
     *
     */
    n.SyntaxHighlighter = function () {
        this.highlightInstructions = {};
        this.uri = "";
        this.importDone = false;
        this.waitingForEnd = false;
        this.multiLineClass = "";
        this.line = '';
    };

    n.SyntaxHighlighter.prototype.importInstructions = function(json, callbackFn) {
        var that = this;
        console.log("Import instructions: " + json);
        $.getJSON(json, function(data) {
            that.highlightInstructions = data;
            that.importDone = true;
            if (callbackFn && typeof(callbackFn) === "function") {
                console.log("callback found");
                callbackFn();
            }
        });
    };

    n.SyntaxHighlighter.prototype.setLanguageUri = function(uri) {
        this.uri = uri;
    };


    n.SyntaxHighlighter.prototype.highlightLine = function(line) {
        this.line = line;
        if(!this.waitingForEnd) {
            if (this._multiLineHighlight()) {
                return this.line;
            }
            this._wordHighlight();
        } else {
            if (this._multiLineHighlight()) {
                return this.line;
            } else {
                this.line = this.wrapLine(this.multiLineClass, this.line);
                return this.line;
            }
        }
        return this.line;
    };

    n.SyntaxHighlighter.prototype._wordHighlight = function() {
        for (var i = 0; i < this.highlightInstructions.singleWord.length; i++) {
            var word = this.highlightInstructions.singleWord[i];
            var regex = new RegExp(word.regex, "g");
            var wrapClass = this.highlightInstructions.classes[word.styleId].name;
            this.line = this.wrapMatch(wrapClass, regex, this.line);
        }
    };

    n.SyntaxHighlighter.prototype._multiLineHighlight = function() {
        for (var i = 0; i < this.highlightInstructions.multiLine.length; i++) {
            var multiLine = this.highlightInstructions.multiLine[i];
            var regex = {};
            if (this.waitingForEnd) {
                regex = new RegExp(multiLine.end,"g");
            } else {
                regex = new RegExp(multiLine.start,"g");
            }

            if (regex.test(this.line)) {
                if(this.waitingForEnd) {
                    this.line = this.endWrap(this.multiLineClass, regex, this.line);
                    this.waitingForEnd = false;
                    this.multiLineClass = "";
                } else {
                    this.multiLineClass = this.highlightInstructions.classes[multiLine.styleId].name;
                    this.line = this.startWrap(this.multiLineClass, regex, this.line);
                    this.waitingForEnd = true;
                }
                return true;
            }
        }
        return false;
    };

    n.SyntaxHighlighter.prototype.wrapLine = function(spanClass, text) {
        var line = "<span class=\"" + spanClass + "\">" + text + "</span>";
        return line;
    };

    n.SyntaxHighlighter.prototype.startWrap = function(spanClass, regex, line) {
        var newLine = line.replace(regex, "<span class=\"" + spanClass + "\">$1$2</span>");
        return newLine;
    };

    n.SyntaxHighlighter.prototype.endWrap = function(spanClass, regex, line) {
        var newLine = line.replace(regex, "<span class=\"" + spanClass + "\">$1</span>$2");
        return newLine;
    };

    n.SyntaxHighlighter.prototype.wrapMatch = function (spanClass, regex, line) {
        var newLine = line.replace(regex, "$1<span class=\"" + spanClass + "\">$2</span>$3");
        return newLine;
    };

})(QfqNS);
/**
 * @author Elias Villiger <elias.villiger@uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend QfqEvents.js */

var QfqNS = QfqNS || {};
(function (n) {

    n.TablesorterController = function () {
        if (window.hasOwnProperty('tablesorterMockApi')) {
            this.tablesorterApiUrl = window.tablesorterMockApi;
        }
        $.tablesorter.themes.bootstrap.table = "";
    };

    // table: jquery selector of table!
    n.TablesorterController.prototype.setup = function (table, uniqueIdentifier) {
        var hasFilter = $(table).hasClass('tablesorter-filter');
        var hasPager = $(table).hasClass('tablesorter-pager');
        var hasColumnSelector = $(table).hasClass('tablesorter-column-selector');
        var hasViewSaver = $(table)[0].hasAttribute("data-tablesorter-view");
        let hasClearMe = $(table).hasClass('clear-filter')
        let hasCounter = $(table).hasClass('qfq-rowCounter');


        if (hasCounter) {
            updateRowCounter($(table)[0]);
            $(table).on("sortEnd filterEnd", function () {
                updateRowCounter(this);
            });
        }

        updateFilterColor($(table)[0]);
        $(table).on("filterEnd", function () {
            updateFilterColor(this);
        });

        // Get base url from table attribute. Needed for newer typo3 versions.
        if (!window.hasOwnProperty('tablesorterMockApi')) {
            this.tablesorterApiUrl = $(table).data("tablesorter-base-url") + 'typo3conf/ext/qfq/Classes/Api/setting.php';
        }

        var tablesorterConfig = $(table).data("tablesorterConfig");
        if (!tablesorterConfig) { // revert to default
            tablesorterConfig = {
                theme: "bootstrap",
                widthFixed: false,
                headerTemplate: "{content} {icon}",
                dateFormat: "ddmmyyyy",
                textExtraction: function(node) {
                    // Only for text-inputs, selects and textareas
                    var $input = $(node).find('input[type="text"], input[type="number"], select, textarea');
                    if ($input.length) {
                        return $input.val() || '';
                    }
                    // For everything else, (include radio/checkbox with Labels) sort text only in radio etc
                    return $(node).text();
                },
                widgets: ["uitheme", "filter", "saveSort", "columnSelector", "output"],
                widgetOptions: {
                    filter_columnFilters: hasFilter, // turn filters on/off with true/false
                    filter_reset: ".reset",
                    filter_cssFilter: "form-control",
                    filter_saveFilters: true,
                    columnSelector_mediaquery: false,
                    output_delivery: "download",
                    output_saveFileName: "tableExport.csv",
                    output_separator: ";"
                }
            };
        }

        $(table).tablesorter(tablesorterConfig);

        // Prevent buttons/links in table headers from triggering sort
        $(table).find('th .btn, th button, th a.btn').addClass('tablesorter-noSort');

        var tablesorterMenuWrapper;
        if (hasColumnSelector || hasViewSaver) {
            tablesorterMenuWrapper = this._doTablesorterMenuWrapper(table);
        }
        if (hasViewSaver) {
            this._doViewSaver(table, tablesorterMenuWrapper);
        }
        if (hasColumnSelector) {
            this._doColumnSelector(table, tablesorterMenuWrapper, uniqueIdentifier);
        }
        if (hasPager) {
            this._doPager(table, uniqueIdentifier);
        }
        if (hasColumnSelector || hasViewSaver) {
            var width = 10;
            var $elem = tablesorterMenuWrapper.children();
            for (var i = 0; i < $elem.length; i++) {
                child = $elem.children().eq(i);
                width += child.width();
            }
            console.log("element width", width);
            if (width < 10) width = 185;
            console.log("element found", $(table).children("caption").children(".pull-right"));
            $(table).children("caption").children(".pull-right").css("margin-right", width + "px");
        }
        if (hasClearMe) {
            // Add clear me class and styles required to correctly display clear me button.
            let thead = $(table).find('thead');
            thead.find('input').addClass('qfq-clear-me qfq-clear-me-table-sorter');
            thead.find('td').attr('style', 'position: relative;top: auto;');
        }


        // Funktion zur Gruppierung der Buttons
        function groupButtons(UID, tablesorterMenuWrapper) {

            // Selektiere den Column Selector und die Page Selectors
            const columnSelector = $('#qfq-column-selector-' + UID); // Spaltenauswahl-Button
            const viewSaver = tablesorterMenuWrapper.find('.btn-group.qfq-tablesorter-menu-item'); // Page-Selector-Dropdowns


            // Prüfe, ob mindestens eines der Elemente existiert
            if (!columnSelector.length && !viewSaver.length) {
                return;
            }

            const buttonGroup = $('<div>')
                .addClass('btn-group btn-group-sm') // Bootstrap-Klassen für kleinere Button-Gruppen
                .attr('role', 'group') // Rolle für Barrierefreiheit
                .attr('style', 'float: right;');

            // Füge den viewSaver Selector (falls vorhanden) zur Gruppe hinzu
            if (viewSaver.length) {

                viewSaver.css({
                    top: 'unset',
                    right: '46px'
                }).addClass('btn-group-sm');
                viewSaver.children('button').removeClass('form-control');
                buttonGroup.append(viewSaver);
            }

            // Füge den Column Selector (falls vorhanden) zur Gruppe hinzu
            if (columnSelector.length) {

                columnSelector.css({
                    top: 'unset',
                    right: '0px',
                    'border-top-right-radius': '100px',
                    'border-bottom-right-radius': '100px'
                });

                buttonGroup.append(columnSelector);
            }

            // Füge die erstellte Button Group in den Wrapper ein
            tablesorterMenuWrapper.append(buttonGroup);
        }
        // Rufe die Funktion auf, nachdem alle anderen Anpassungen fertig sind
        if (hasColumnSelector || hasViewSaver) {
            groupButtons(uniqueIdentifier, tablesorterMenuWrapper);
        }

        // Function to add a dynamic row count to the table
        function updateRowCounter(tableElement) {
            // Get the table header row
            let theadRow = tableElement.querySelector("thead tr");
            // Get the table body element
            let tbody = tableElement.querySelector("tbody");
            // Select only visible rows within tbody since table filter only hides the elements
            let visibleRows = $(tbody).find("tr:visible");

            // Add the column header if it does not exist
            if (!theadRow.querySelector(".row-number")) {
                let th = document.createElement("th");
                th.innerText = "#";
                th.classList.add("row-number", "sorter-false", "filter-false");
                theadRow.insertBefore(th, theadRow.firstChild);
            }

            // Loop over all visible columns
            visibleRows.each(function(index) {
                let firstCell = this.querySelector(".row-number-cell");
                if (!firstCell) {
                    firstCell = document.createElement("td");
                    firstCell.classList.add("row-number-cell");
                    this.insertBefore(firstCell, this.firstChild);
                }
                firstCell.innerText = index + 1;
            });
        }

        // Function to update border color of corresponding <th> when filter is set/removed
        function updateFilterColor(table) {
            let filters = $(table).find('.tablesorter-filter-row input, .tablesorter-filter-row select');

            // Loop through filter inputs
            filters.each(function() {

                if($(this).val() !== '') {
                    $(this).addClass('tablesorter-filter-input');
                } else {
                    $(this).removeClass('tablesorter-filter-input');
                }
            })
        }
    };


    n.TablesorterController.prototype.setTableView = function (table, newView, changedSelect) {
        // Guard: Check if table and tablesorter config exist
        if (!table || !table[0] || !table[0].config) {
            console.warn('TablesorterController.setTableView: Table or tablesorter config not initialized');
            return;
        }

        if (newView.hasOwnProperty('columnSelection') && $.tablesorter.hasWidget(table, 'columnSelector')) {
            table.trigger('refreshColumnSelector', [newView.columnSelection]);
        }
        if (newView.hasOwnProperty('filters') && $.tablesorter.hasWidget(table, 'filter')) {
            // correct filter array length if shorter than no. of columns
            var filters = $.tablesorter.getFilters(table);
            if (!filters) {
                return;
            }
            var columns = filters.length;
            var len = newView.filters.length;

            // Handle situation with saved views and empty subrecord or later changed database column count.
            if (len > columns) {
                return;
            }

            var arrayAppend = Array.apply(null, Array(columns - len)).map(function () {
                return "";
            });
            var filtersPadded = newView.filters.concat(arrayAppend);
            table.trigger('search', [filtersPadded]);
        }

        // Get and set last used view if its same from last time, otherwise use the one from setting API if view changed
        var config = table[0].config;
        if (newView.hasOwnProperty('sortList') && config.sortList) {
            if (config.sortList.length !== 0 && !changedSelect) {
                table.trigger('sorton', [config.sortList]);
            } else {
                table.trigger('sorton', [newView.sortList]);
            }
        }
    };

    n.TablesorterController.prototype.getTableView = function (table) {
        var view = {};

        // Guard: Check if table and tablesorter config exist
        if (!table || !table[0] || !table[0].config) {
            console.warn('TablesorterController.getTableView: Table or tablesorter config not initialized');
            return view;
        }

        var config = table[0].config;
        if ($.tablesorter.hasWidget(table, 'columnSelector') && config.selector && config.selector.states) {
            view.columnSelection = config.selector.states.map(function (e, i) {
                return e ? i : false;
            }).filter(function (e) {
                return e !== false;
            });
        }
        if ($.tablesorter.hasWidget(table, 'filter')) {
            view.filters = $.tablesorter.getFilters(table) || [];
        }
        view.sortList = config.sortList || [];
        return view;
    };

    n.TablesorterController.prototype._doTablesorterMenuWrapper = function (table) {
        // in forms we need some distance to the top
        var addClass = '';
        if ($(table).find("caption").length > 0) addClass += ' qfq-no-margin-top';
        if ($(table).prev().is("h1,h2,h3")) addClass += ' qfq-only-top';
        var tablesorterMenuWrap = '<div class="qfq-tablesorter-menu-wrapper' + addClass + '"></div>';

        return $(tablesorterMenuWrap).insertBefore($(table));
    };

    n.TablesorterController.prototype._doViewSaver = function (table, tablesorterMenuWrapper) {
        var tableViews = JSON.parse(table.attr("data-tablesorter-view"));
        var that = this;
        // decode views from base64 (sql injection prevention)
        tableViews.forEach(function (view) {
            view.view = JSON.parse(atob(view.view));
        });

        // add 'Clear' public view if not exists
        if (!tableViews.some(function (v) {
            return v.name === 'Clear' && v.public;
        })) {
            setDefault('Clear', true);
        }

        // set default view
        function setDefault(name, publicBool) {
            var filters = $.tablesorter.getFilters(table);
            var columnCount = filters ? filters.length : 0;
            var allColumns = Array(columnCount).fill(0).map(function (e, i) {
                return i;
            });
            var view = {
                name: name,
                public: publicBool,
                view: {columnSelection: allColumns, filters: [], sortList: []}
            };
            tableViews.push(view);
        }

        var lastSelect = '';
        var changedSelect = false;

        // create view select dropdown
        var options = '';
        tableViews.forEach(function (view) {
            options += '<option class="qfq-fontawesome" value="' + (view.public ? 'public:' : 'private:') + view.name + '" >' +
                (view.public ? '&#xf0c0; ' : '&nbsp;&#xf007;&nbsp;&nbsp;') + view.name + '</option>';
        });
        var viewSelectorHtml = '<select class="form-control qfq-view-editor qfq-fontawesome qfq-tablesorter-menu-item" style="right: 80px; width: unset; border-top-right-radius: 0px !important; border-bottom-right-radius: 0px !important; height: 30px !important; top:0px !important;">' +
            '<option disabled selected value>Table view</option>' + options + '</select>';
        var select = $(viewSelectorHtml).appendTo(tablesorterMenuWrapper);
        var tableId = table.attr("data-tablesorter-id");
        select.change(function () {
            var viewFromSelect = that._parseViewSelectValue($(this).val());
            var view = tableViews.find(function (v) {
                return v.name === viewFromSelect.name && v.public === viewFromSelect.public;
            });

            // check for changed select (used dropdown)
            var actualSelect = $(this).val();
            if (actualSelect !== lastSelect) {
                changedSelect = true;
            }
            lastSelect = actualSelect;

            that.setTableView(table, view.view, changedSelect);
            that._updateTablesorterUrlHash(table, $(this).val());

            // save view choice in local storage
            localStorage.setItem('tablesorterView_' + tableId, $(this).val());
        });

        // select view on page load: first priority from url hash, second priority localstorage, third private view named 'Default', fourth is public view 'Clear'
        // Default (third priority) + Clear (fourth priority)
        var publicDefaultExists = tableViews.some(function (v) {
            return v.name === 'Default' && v.public;
        });
        var setValue = publicDefaultExists ? "public:Default" : "public:Clear";

        // local storage (second priority)
        var localStorageView = localStorage.getItem('tablesorterView_' + tableId);
        if (localStorageView !== 'undefined' && select.children('option[value="' + localStorageView + '"]').length > 0) {
            setValue = localStorageView;
        }

        // url hash (first priority)
        var hashParameters = this._getTablesorterUrlHash();
        var value = hashParameters[tableId];
        if (typeof value !== 'undefined' && select.children('option[value="' + value + '"]').length > 0) {
            setValue = value;
        }

        // apply view change and get last view
        lastSelect = setValue;
        select.val(setValue).change();

        // create edit view dropdown
        var viewDropdownHtml = '<div class="btn-group qfq-tablesorter-menu-item" style="right: 53px;">' +
            '<button type="button" class="btn btn-default btn-group form-control qfq-view-editor dropdown-toggle" data-toggle="dropdown" style="border-radius: 0 0 0 0;" >' +
            '<i class="fa fa-pencil-alt"></i>' +
            '</button>' +
            '<ul class="dropdown-menu pull-right" role="menu">' +
            '<li><a href="#" data-save-private-view>Save Personal View</a></li>' +
            '<li><a href="#" data-save-public-view>Save Group View</a></li>' +
            '<li><a href="#" data-delete-view>Delete View</a></li>' +
            '</ul>' +
            '</div>';
        var viewDropdown = $(viewDropdownHtml).appendTo(tablesorterMenuWrapper);

        var SavePrivateViewButton = viewDropdown.find('[data-save-private-view]');
        SavePrivateViewButton.click(function () {
            var viewFromSelect = that._parseViewSelectValue(select.val());
            that._saveTableViewPrompt(table, viewFromSelect.name, false);
        });

        var SavePublicViewButton = viewDropdown.find('[data-save-public-view]');
        SavePublicViewButton.click(function () {
            var viewFromSelect = that._parseViewSelectValue(select.val());
            that._saveTableViewPrompt(table, viewFromSelect.name, true);
        });

        var DeleteViewButton = viewDropdown.find('[data-delete-view]');
        DeleteViewButton.click(function () {
            var viewFromSelect = that._parseViewSelectValue(select.val());
            viewFromSelect.mode = 'delete';
            var sip = table.attr("data-tablesorter-sip");
            $.post(that.tablesorterApiUrl + "?s=" + sip, viewFromSelect, function (response) {
                location.reload(true);
            }, 'json').fail(function (xhr, status, error) {
                that._alert('Error while trying to save view:<br>' + JSON.parse(xhr.responseText).message);
            });
        });


    };

    n.TablesorterController.prototype._doColumnSelector = function (table, tablesorterMenuWrapper, uniqueIdentifier) {
        var columnSelectorId = "qfq-column-selector-" + uniqueIdentifier;
        var columnSelectorTargetId = "qfq-column-selector-target-" + uniqueIdentifier;
        var columnSelectorHtml = '<button id="' + columnSelectorId + '" class="btn btn-default qfq-tablesorter-menu-item qfq-column-selector" ' +
            'type="button">' +
            '<span class="dropdown-text"><i class="fa fa-columns"></i></span>' +
            '<span class="caret"></span></button>' +
            '<div class="hidden"><div id="' + columnSelectorTargetId + '" class="qfq-column-selector-target"> </div></div>';
        $(columnSelectorHtml).appendTo(tablesorterMenuWrapper);
        $.tablesorter.columnSelector.attachTo($(table), '#' + columnSelectorTargetId);
        $('#' + columnSelectorId).popover({
            placement: 'left',
            container: 'body',
            html: true, // required if content has HTML
            content: $('#' + columnSelectorTargetId)
        });

        table.on('columnUpdate', function () {
            var config = table[0].config;

            if ($.tablesorter.hasWidget(table, 'filter')) {
                var visibleColumns = config.selector.states;
                var filters = $.tablesorter.getFilters(table);

                // Clear filters for hidden columns
                for (var i = 0; i < visibleColumns.length; i++) {
                    if (!visibleColumns[i]) {
                        filters[i] = '';
                    }
                }

                // Apply / Trigger updated filters
                table.trigger('search', [filters]);
            }
        });
    };

    n.TablesorterController.prototype._doPager = function (table, uniqueIdentifier) {
        var pagerId = "qfq-pager-" + uniqueIdentifier;
        var pagerHtml = `
        <div id="${pagerId}" class="qfq-tablesorter-group qfq-tablesorter-group-bottom">
            <div class="btn-group " role="group" >
                <!-- Erster Button mit abgerundeten Ecken -->
                <button type="button" class="btn btn-default first" style="border-top-left-radius: 100px; border-bottom-left-radius: 100px; padding: 4px;">
                    <span class="glyphicon glyphicon-step-backward"></span>
                </button>
                <!-- Innerer Button -->
                <button type="button" class="btn btn-default prev" style="border-radius: 0; padding: 4px;">
                    <span class="glyphicon glyphicon-backward" style="padding-right: 3px"></span>
                </button>
            </div>
            <span class="pagedisplay qfq-tablesorter-font" style="border-color: #d6d6d6; border-width: 1px 0px 1px 0px; border-top-style: solid; border-bottom-style: solid;  color: #555; padding-top: 6px; "></span>
            <div class="page-controll" style="width: unset; display: inline;">
                <select class="form-control input-sm pagesize qfq-tablesorter-font" title="Select page size" style="display: inline; width: unset;  border-radius: 0px 0px 0px 0px !important; padding-left: 2px;">
                    <option selected="selected" value="10">10</option>
                    <option value="25">25</option>
                    <option value="50">50</option>
                    <option value="100">100</option>
                    <option value="all">All Rows</option>
                </select><select class="form-control input-sm pagenum qfq-tablesorter-font" title="Select page number" style="display: inline; width: unset; border-radius: 0px 0px 0px 0px !important; padding-left: 2px;">
                </select>
            </div>
            <div class="btn-group" role="group">
                <!-- Innerer Button -->
                <button type="button" class="btn btn-default next" style="border-radius: 0; padding: 4px;">
                    <span class="glyphicon glyphicon-forward" style="padding-left: 3px"></span>
                </button>
                <!-- Letzter Button mit abgerundeten Ecken -->
                <button type="button" class="btn btn-default last" style="border-top-right-radius: 100px; border-bottom-right-radius: 100px; padding: 4px;">
                    <span class="glyphicon glyphicon-step-forward"></span>
                </button>
            </div>
        </div>
    `;
        $(pagerHtml).insertAfter($(table));
        $(table).tablesorterPager({
            container: $("#" + pagerId),
            cssGoto: ".pagenum",
            removeRows: false,
            output: '{startRow} - {endRow} / {filteredRows}'
        });
    };


    n.TablesorterController.prototype._setTablesorterUrlHash = function (parameters) {
        var hash = '';
        for (var key in parameters) {
            // remember last pill delivers own hash which has undefined value. Catch it correctly.
            if (parameters[key] === undefined) {
                hash += ',' + key;
            } else {
                hash += ',' + key + '=' + parameters[key];
            }
        }
        window.location.replace("#" + hash.substr(1));
    };

    n.TablesorterController.prototype._getTablesorterUrlHash = function () {
        var parameterList = window.location.hash.substr(1).split(',');
        var parameters = {};
        parameterList.forEach(function (par) {
            var keyValue = par.split(/=(.+)/);
            parameters[keyValue[0]] = keyValue[1];
        });
        delete parameters[""];
        return parameters;
    };

    n.TablesorterController.prototype._updateTablesorterUrlHash = function (table, value) {
        var tableId = table.attr("data-tablesorter-id");
        var hashParameters = this._getTablesorterUrlHash();
        hashParameters[tableId] = value;
        this._setTablesorterUrlHash(hashParameters);
    };

    n.TablesorterController.prototype._saveTableViewPrompt = function (table, viewNamePreset, isPublicView) {
        var tableId = table.attr("data-tablesorter-id");
        var viewName = prompt("Please enter a name for the view. If it already exists it will be overwritten.", viewNamePreset !== null ? viewNamePreset : "");

        // check if given view name is valid
        if (viewName === "") {
            this._alert("View not saved. Name is empty.");
            return;
        }
        if (viewName === null) {
            return;
        }
        var view = {name: viewName, public: isPublicView, tableId: tableId, view: this.getTableView(table)};

        // check if there are filters set on hidden columns.
        if (view.view.filters.some(function (f, i) {
            return f !== '' && !view.view.columnSelection.includes(i);
        })) {
            if (!confirm('There are filters set on hidden columns. Would you like to save anyway?')) {
                return;
            }
        }
        var that = this;
        var sip = table.attr("data-tablesorter-sip");
        view.view = btoa(JSON.stringify(view.view)); // encode view to base64 to prevent sql injections
        $.post(this.tablesorterApiUrl + "?s=" + sip, view, function (response) {
            that._updateTablesorterUrlHash(table, (view.public ? 'public:' : 'private:') + view.name);
            location.reload(true);
        }, 'json').fail(function (xhr, status, error) {
            that._alert('Error while trying to save view:<br>' + JSON.parse(xhr.responseText).message);
        });
    };

    n.TablesorterController.prototype._parseViewSelectValue = function (value) {
        var splitValue = value.split(/:(.+)/);
        return {name: splitValue[1], public: splitValue[0] === 'public'};
    };

    n.TablesorterController.prototype._alert = function (alertMessage) {
        var messageButtons = [{
            label: "Ok",
            eventName: 'close'
        }];
        var alert = new n.Alert({"message": alertMessage, "type": "error", modal: true, buttons: messageButtons});
        alert.show();
    };

})(QfqNS);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */
/* global console */
/* global Bloodhound */
/* global Math */

/* @depend Utils.js */

var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    n.TypeAhead = {};

    /**
     * Coerce corejs-typeahead into our use-case.
     *
     * @param typeahead_endpoint the endpoint to be called
     * @constructor
     */
    n.TypeAhead.install = function (typeahead_endpoint) {

        $('.qfq-typeahead:not(.tt-hint,.tt-input,.typeahead-installed)').each(function () {
            var bloodhoundConfiguration;

            var $element = $(this);

            if (typeahead_endpoint === null) {
                typeahead_endpoint = n.TypeAhead.getTypeAheadUrl($element);
            }

            // bloodhound is used to get the remote data (suggestions)
            bloodhoundConfiguration = {
                // We need to be notified on success, so we need a promise
                initialize: false,
                datumTokenizer: Bloodhound.tokenizers.obj.whitespace('key', 'value'),
                queryTokenizer: Bloodhound.tokenizers.whitespace,
                identify: function (obj) {
                    return obj.key.toString();
                },
                remote: {
                    url: n.TypeAhead.makeUrl(typeahead_endpoint, $element),
                    wildcard: '%QUERY'
                }
            };

            var url = n.TypeAhead.makeUrl(typeahead_endpoint, $element);
            url = url.replace('%QUERY', '');
            console.log(url);
            // Seems to trigger an empty query - why?
            //$.getJSON(url, {}, console.log);  // API by hand

            // initialize typeahead (either with or without tags)
            if ($element.data('typeahead-tags')) {
                n.TypeAhead.installWithTags($element, bloodhoundConfiguration);
            } else {
                n.TypeAhead.installWithoutTags(typeahead_endpoint, $element, bloodhoundConfiguration);
            }

            $element.addClass('typeahead-installed');
        });

    };

    n.TypeAhead.installWithTags = function ($element, bloodhoundConfiguration) {

        // initialize bloodhound (typeahead suggestion engine)
        var suggestions = new Bloodhound(bloodhoundConfiguration);
        suggestions.initialize();

        // create actual input field
        var $inputField = $('<input/>', {
            type: 'text',
            class: $element.attr('class')
        });
        $element.after($inputField);

        let inputContainer = $(`#` + $element.attr('id') + '-i');
        if (!inputContainer.length) {
            console.warn('Input container not found:', inputContainer);
        }

        // Function to sync `disabled`, `readonly`, and `background` styles
        function syncAttributes() {
            // Ensure the latest state of `disabled` is synced
            if ($element.is('[disabled]') || inputContainer.is('[disabled]')) {
                $inputField.attr('disabled', true);
            } else {
                $inputField.removeAttr('disabled');
            }

            // Ensure the latest state of `readonly` is synced
            if ($element.is('[readonly]') || inputContainer.is('[readonly]')) {
                $inputField.attr('readonly', true);
            } else {
                $inputField.removeAttr('readonly');
            }

            // Ensure the background style is set correctly
            if ($element.is('[disabled]') || $element.is('[readonly]') || inputContainer.is('[disabled]') || inputContainer.is('[readonly]')) {
                $inputField.css('background', '');
                $inputField.css('background-color', '');
                $inputField.css('background-color', '#eee !important');
                $inputField.css('cursor', 'not-allowed')
            } else {
                $inputField.css('background-color', '');
                $inputField.css('cursor', '')
            }
        }

        // Ensure attributes are applied correctly before observing changes
        syncAttributes();

        // Set up a MutationObserver to handle dynamic changes
        const observer = new MutationObserver(function (mutationsList) {
            let attributeChanged = false;
            for (const mutation of mutationsList) {
                if (mutation.attributeName === 'disabled' || mutation.attributeName === 'readonly') {
                    attributeChanged = true; // Detect relevant attribute changes
                }
            }

            if (attributeChanged) {
                syncAttributes(); // Sync
            }
        });

        // Observe changes on $element
        // Optionally observe `inputContainer` if it exists
        observer.observe($element[0], { attributes: true });
        if (inputContainer.length) {
            observer.observe(inputContainer[0], { attributes: true });
        }

        // prevent form submit when enter key is pressed
        $inputField.off('keyup');
        $inputField.on('keypress keyup', function (e) {
            var code = e.keyCode || e.which;
            if (code === 13) {
                e.preventDefault();
                return false;
            }
        });

        $element.off('keyup change');
        $element.on('keypress keyup change', function (e) {
            var code = e.keyCode || e.which;
            if (code === 13) {
                e.preventDefault();
                return false;
            }
        });


        // list to keep tracks of existing tags and those added during the current session
        // expected JSON format: [{value: "Alaska", key: "AK"}, {value: "Alabama", key: "AL"}]
        var existingTags = $element.val() !== '' ? JSON.parse($element.val()) : [];

        // list of current typeahead suggestions
        var typeaheadList = existingTags.slice();

        // get list of possible keys a user can press to push a tag (list of keycodes)
        var delimiters = $element.data('typeahead-tag-delimiters');
        delimiters = delimiters !== undefined ? delimiters : [9, 13, 44];

        // validator function for pedantic mode
        var pedanticValidator = function (tag) {
            // check if tag is in typeahead suggestions
            var tagLookup = typeaheadList.filter(function (t) {return t.value.toLowerCase() === tag.toLowerCase();})[0];
            return tagLookup !== undefined;
        };

        // initialize tagsManager
        var tagApi = $inputField.tagsManager({
            deleteTagsOnBackspace: false,
            hiddenTagListName: '',
            tagClass: 'qfq-typeahead-tag',
            delimiters: delimiters,
            validator: !!$element.data('typeahead-pedantic') ? pedanticValidator : null,
        });

        // when tag is pushed, look up key and add it to existingTags
        tagApi.bind('tm:pushed', function (e, tag) {
            if(tag === "") return;
            var tagLookup = typeaheadList.filter(function (t) {return t.value.toLowerCase() === tag.toLowerCase();})[0];
            if (undefined === tagLookup) {
                existingTags.push({key: 0, value: tag});
            } else {
                existingTags.push({key: tagLookup.key, value: tagLookup.value});
            }

            const $lastTag = $element.parent().find('.tm-tag').last();

            // Add class to inner <span> with the value
            $lastTag.addClass('badge');

            // Only change where the tag get inserted if the parent is a 'input-group' (extraButtonInfo/Lock/Password)
            const parent = $element.parent(); // Get the parent of $element
            if (parent.hasClass('input-group')){
                const tagElement = parent.find('.tm-tag').last(); // Get the last added tag
                tagElement.detach().insertBefore(parent); // Move the tag before the parent
            }
        });

        // when the hidden input field changes, overwrite value with tag object list
        tagApi.bind('tm:hiddenUpdate', function (e, tags) {
            var tagObjects = $.map(tags, function (t) {
                return existingTags.filter(function (tt) {return tt.value === t;})[0];
            });
            $element.val(JSON.stringify(tagObjects));
        });

        // if value of hidden field is changed externally, update tagManager
        $element.on('qfqChange', function () {
            tagApi.tagsManager('disableHiddenUpdate', true);
            existingTags = $element.val() !== '' ? JSON.parse($element.val()) : [];
            tagApi.tagsManager('empty');
            $.each(existingTags, function (i, tag) {
               tagApi.tagsManager('pushTag', tag.value);
            });
            tagApi.tagsManager('disableHiddenUpdate', false);
        });

        // add existing tags
        tagApi.tagsManager('disableHiddenUpdate', true);
        $.each(existingTags, function (i, tag) {
            tagApi.tagsManager('pushTag', tag.value);
        });
        tagApi.tagsManager('disableHiddenUpdate', false);

        console.log(JSON.parse(JSON.stringify(n.TypeAhead.getMinLength($element))));

        function suggestionsWithDefaults(q, sync, async) {
            if (q === '') {
                sync(suggestions.index.all().slice(0, 2)); // slice(start,end)
                // suggestions.search('', sync, async);
            }
            else {
                suggestions.search(q, sync, async);
            }
        }
        $inputField.data('bloodhound', suggestions);

        // add typahead
        $inputField.typeahead({
                // options
                hint: n.TypeAhead.getHint($element),
                highlight: n.TypeAhead.getHighlight($element),
                minLength: n.TypeAhead.getMinLength($element)
            }, {
                display: 'value',
                source: suggestionsWithDefaults,
                limit: n.TypeAhead.getLimit($element),
                templates: {
                    suggestion: function (obj) {
                        return "<div>" + n.TypeAhead.htmlEncode(obj.value) + "</div>";
                    },
                    // No message if field is not set to pedantic.
                    notFound: (function ($_) {
                        return function (obj) {
                            if (!!$element.data('typeahead-pedantic'))
                                return "<div>'" + n.TypeAhead.htmlEncode(obj.query) + "' not found";
                        };
                    })($inputField)
            }
        });

        // directly add tag when clicked on in typahead menu
        $inputField.bind('typeahead:selected', function (event, sugg) {
            tagApi.tagsManager("pushTag", sugg.value);
        });

        // update typahead list when typahead changes
        $inputField.bind('typeahead:render', function (event, sugg) {
            typeaheadList.length = 0;
            typeaheadList.push.apply(typeaheadList, sugg);
        });
    };

    n.TypeAhead.installWithoutTags = function (typeahead_endpoint, $element, bloodhoundConfiguration) {
        var $shadowElement;

        // Prefetch the value that is already in the field
        if ($element.val() !== '') {
            bloodhoundConfiguration.prefetch = {};
            bloodhoundConfiguration.prefetch.url = n.TypeAhead.makePrefetchUrl(typeahead_endpoint, $element.val(), $element);
            // Disable cache, we expect only a few entries. Caching gives sometimes strange behavior.
            bloodhoundConfiguration.prefetch.cache = false;
        }

        // create a shadow element with the same value. This seems to be important for the pedantic mode. (?)
        $shadowElement = n.TypeAhead.makeShadowElement($element);

        // prefetch data
        var suggestions = new Bloodhound(bloodhoundConfiguration);
        var promise = suggestions.initialize();

        // use shadow element to back fill field value, if it is in the fetched suggestions (why?)
        promise.done((function ($element, suggestions) {
            return function () {
                n.TypeAhead.fillTypeAheadFromShadowElement($element, suggestions);
            };
        })($element, suggestions));

        $element.typeahead({
                // options
                hint: n.TypeAhead.getHint($element),
                highlight: n.TypeAhead.getHighlight($element),
                minLength: n.TypeAhead.getMinLength($element)
            },
            {
                // dataset
                display: 'value',
                source: suggestions,
                limit: n.TypeAhead.getLimit($element),
                templates: {
                    suggestion: function (obj) {
                        return "<div>" + n.TypeAhead.htmlEncode(obj.value) + "</div>";
                    },
                    // No message if field is not set to pedantic.
                    notFound: (function ($_) {
                        return function (obj) {
                            if (!!$_.data('typeahead-pedantic'))
                                return "<div>'" + n.TypeAhead.htmlEncode(obj.query) + "' not found";
                        };
                    })($element)
                }
            });

        $element.css('background-color', '')
        // bind select and autocomplete events
        $element.bind('typeahead:select typeahead:autocomplete', function (event, suggestion) {
            var $shadowElement = n.TypeAhead.getShadowElement($(event.delegateTarget));
            $shadowElement.val(suggestion.key);
        });

        // bind change event
        if (!!$element.data('typeahead-pedantic')) {
            // Typeahead pedantic: Only allow suggested inputs
            $element.bind('typeahead:change', n.TypeAhead.makePedanticHandler(suggestions));
            $element.on('keydown', (function (suggestions) {
                return function (event) {
                    if (event.which === 13) {
                        n.TypeAhead.makePedanticHandler(suggestions)(event);
                    }
                };
            })(suggestions));
            // The pedantic handler will test if the shadow element has a value set (the KEY). If not, the
            // typeahead element is cleared. Thus we have to guarantee that no value exists in the shadow
            // element the instant the user starts typing since we don't know the outcome of the search.
            //
            // If we don't clear the shadow element the instant the user starts typing, and simply let the
            // `typeahead:select` or `typeahead:autocomplete` handler set the selected value, the
            // user might do following steps and end up in an inconsistent state:
            //
            //  1. Use typeahead to select/autocomplete a suggestion
            //  2. delete the suggestion
            //  3. enter a random string
            //  4. submit form
            //
            // This would leave a stale value in the shadow element (from step 1.), and the pedantic handler
            // would not clear the typeahead element, giving the impression the value in the typeahead element will be submitted.
            $element.on('input', (function ($shadowElement) {
                return function () {
                    $shadowElement.val('');
                };
            })($shadowElement));
        } else {
            $element.bind('typeahead:change', function (event, suggestion) {
                var $shadowElement = n.TypeAhead.getShadowElement($(event.delegateTarget));
                // If none pendatic, suggestion key might not exist, so use suggestion instead.
                $shadowElement.val(suggestion.key || suggestion);
            });
        }
    };


    n.TypeAhead.makePedanticHandler = function (bloodhound) {
        return function (event) {
            var $typeAhead = $(event.delegateTarget);
            var $shadowElement = n.TypeAhead.getShadowElement($typeAhead);
            if ($shadowElement.val() === '') {
                $typeAhead.typeahead('val', '');

                // This triggers after saving and causes unnecessary dialog window (do you really want to leave...)
                //$typeAhead.closest('form').change();
            }
        };
    };

    n.TypeAhead.makeUrl = function (endpoint, element) {
        return endpoint + "?_ta_query=%QUERY" + "&sip=" + n.TypeAhead.getSip(element);
    };
    n.TypeAhead.makePrefetchUrl = function (endpoint, prefetchKey, element) {
        return endpoint + "?_ta_prefetch=" + encodeURIComponent(prefetchKey) + "&sip=" + n.TypeAhead.getSip(element);
    };

    n.TypeAhead.getLimit = function ($element) {
        return $element.data('typeahead-limit');
    };

    n.TypeAhead.getSip = function ($element) {
        return $element.data('typeahead-sip');
    };

    n.TypeAhead.getName = function ($element) {
        return $element.attr('name');
    };

    n.TypeAhead.getValue = function ($element) {
        return $element.val();
    };

    n.TypeAhead.getMinLength = function ($element) {
        return $element.data('typeahead-minlength') !== undefined ? $element.data('typeahead-minlength') : 2;
    };

    n.TypeAhead.getHighlight = function ($element) {
        return $element.data('typeahead-highlight') || true;
    };

    n.TypeAhead.getHint = function ($element) {
        return $element.data('typeahead-hint') || true;
    };

    n.TypeAhead.htmlEncode = function (value) {
        return $('<div/>').text(value).html();
    };

    n.TypeAhead.makeShadowElement = function ($element) {
        var $parent, inputName, inputValue, uniqueId, $shadowElement;

        $parent = $element.parent();
        inputName = $element.attr('name');
        $element.removeAttr('name');

        inputValue = $element.val();

        $shadowElement = $('<input>')
            .attr('type', 'hidden')
            .attr('name', inputName)
            .val(inputValue);

        $element.data('shadow-element', $shadowElement);

        $parent.append($shadowElement);

        return $shadowElement;
    };

    n.TypeAhead.getShadowElement = function ($element) {
        return $element.data('shadow-element');
    };

    n.TypeAhead.fillTypeAheadFromShadowElement = function ($element, bloodhound) {
        var results;
        var $shadowElement = n.TypeAhead.getShadowElement($element);
        var key = $shadowElement.val();
        if (key === '') {
            return;
        }

        results = bloodhound.get(key);
        if (results.length === 0) {
            return;
        }
        $element.typeahead('val', results[0].value);
    };

    n.TypeAhead.getTypeAheadUrl = function ($element) {
        return $element.data('typeahead-url');
    };
})(QfqNS);

// fix for safari to make the right input field clickable. Safari doesn't get the right sequence of z-index from the two typeahead input fields which are overlaid.
// z-index can not be set in qfq because the input field is generated by typeahead.bundle.min.js, changes are needed to do at the end of DOM after everything is loaded.
$(window).on("load",function() {
    $(document).ready(function(){
        $(".tt-input").each(function(){
            if($(this).css("z-index") !== "auto"){
                $(this).prev().css("z-index",1);
            }
        });
    });
});
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    n.escapeJqueryIdSelector = function (idSelector) {
        return idSelector.replace(/(:|\.)/, "\\$1");
    };

    n.initializeQmore = function () {
        var more = '[...]';
        var less = '[<<]';

        $('span.qfq-more-text').each(function () {
            // Check if a sibling button with class "qmore" already exists.
            if ($(this).siblings('button.qmore').length === 0) {
                var moreButtonHtml = '<button class="btn btn-link qmore" type="button" style="outline-width: 0px;">' + more + '</button>';
                var moreButton = $(moreButtonHtml).insertAfter($(this));
                moreButton.click(function () {
                    var moreText = $(this).siblings('span.qfq-more-text');
                    if ($(this).text() === more) {
                        $(this).text(less);
                        moreText.show();
                    } else {
                        $(this).text(more);
                        moreText.hide();
                    }
                });
            }
        });
    };

    /** Decode a Base64 Encoded file name that was Encoded by QFQ
     *
     * @param uniqueFileName
     * @returns {*|string}
     */
    n.decodeUniqueFileName = function (uniqueFileName) {
        // Separate the directory from the file name (if any)
        let dir = '';
        let file = uniqueFileName;
        if (uniqueFileName.indexOf('/') !== -1) {
            const parts = uniqueFileName.split('/');
            file = parts.pop();
            dir = parts.join('/');
        }

        // If the file does not contain the "_B64" marker.
        if (file.indexOf('_B64') === -1) {
            // return it as-is.
            return uniqueFileName;
        }


        //   ^(.*?)        -> Optionally capture any prefix ending with an underscore.
        //   _B64\d*_      -> Match "_B64" followed by optional digits and an underscore.
        //   (.+?)         -> Base64 encoded portion.
        //   (\.[^.]+)?$   -> file extension.
        const regex = /^(.*?)_B64\d*_(.+?)(\.[^.]+)?$/;
        const matches = file.match(regex);
        if (!matches) {
            // Pattern didn't match; it was not encoded by QFQ return original.
            return uniqueFileName;
        }


        // matches[1] = optional prefix, matches[2] = encoded file name part, matches[3] = extension (if any)
        let prefix = matches[1] || '';
        let encodedPart = matches[2];
        let extension = matches[3] || '';

        // Reverse replacement: replace all '-' with '/' and '_' with '='.
        encodedPart = encodedPart.replace(/-/g, '/');
        encodedPart = encodedPart.replace(/_/g, '=');

        // Decode the Base64 encoded portion.
        let decodedBaseName;
        try {
            decodedBaseName = atob(encodedPart);
        } catch (e) {
            // Decoding failed return original name.
            console.error("Base64 decoding error:", e);
            return uniqueFileName;
        }

        // Reassemble the decoded file name.
        return prefix ? (prefix + '_' + decodedBaseName + extension) : (decodedBaseName + extension);
    }

})(QfqNS);
window.MathJax = {
    tex: {
        inlineMath: [['$', '$'], ['\\(', '\\)']]
    },
    svg: {
        fontCache: 'global'
    }
};
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    /**
     *
     * @param jqHXR
     * @param textStatus
     * @param errorThrown
     *
     * @function QfqNS.Helper.showAjaxError
     */
    n.showAjaxError = function (jqHXR, textStatus, errorThrown) {
        var alert = new QfqNS.Alert("Error:<br> " +
            errorThrown, "error");
        alert.show();
    };

    /**
     *
     * @param string
     * @returns {*}
     *
     * @function QfqNS.Helper.stringBool
     */
    n.stringToBool = function (string) {
        if (typeof string !== "string") {
            return string;
        }
        var lowerCase = string.toLowerCase().trim();

        switch (lowerCase) {
            case "1":
            case "yes":
            case "y":
            case "t":
            case "true":
            case "enabled":
            case "enable":
                return true;
            case "0":
            case "no":
            case "n":
            case "f":
            case "false":
            case "disabled":
            case "disable":
                return false;
            default:
                return false;
        }
    };
})(QfqNS.Helper);
/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */

var QfqNS = QfqNS || {};

/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    let activeTooltips = new Map(); // Tracks tooltips by trigger element
    let pinnedTooltips = new Set(); // Stores pinned tooltips
    let hideTimeout = null; // Delay for hiding tooltips
    let showTimeout = null; // Delay for showing tooltips

    function createTooltipElement(tooltipText) {
        const tooltip = document.createElement('div');
        tooltip.classList.add('tooltip-container');
        tooltip.innerHTML = `
            <div class="tooltip-header">
                <button class="button pin-btn"><i class="fas fa-thumbtack"></i></button>
                <button class="button copy-btn"><i class="fas fa-clipboard"></i></button>
            </div>
            <div class="tooltip-content">${tooltipText.replace(/\n/g, '<br>')}</div>
        `;
        return tooltip;
    }

    function showTooltip(trigger, tooltipText) {
        if (activeTooltips.has(trigger)) {
            const { tooltip, popperInstance } = activeTooltips.get(trigger);
            tooltip.style.display = 'block';
            popperInstance.update();
            return tooltip;
        }

        const tooltip = createTooltipElement(tooltipText);
        document.body.appendChild(tooltip);

        const popperInstance = Popper.createPopper(trigger, tooltip, {
            placement: 'bottom',
            modifiers: [
                { name: 'preventOverflow', options: { boundary: 'viewport' } },
                { name: 'offset', options: { offset: [0, -3] } },
            ],
        });

        activeTooltips.set(trigger, { tooltip, popperInstance });
        addTooltipEventListeners(tooltip, trigger, popperInstance);


        tooltip.style.display = 'block';
        popperInstance.update();
        return tooltip;
    }

    function addTooltipEventListeners(tooltip, trigger, popperInstance) {
        const copyBtn = tooltip.querySelector('.copy-btn');
        const pinBtn = tooltip.querySelector('.pin-btn');

        copyBtn.addEventListener('click', () => {
            const textContent = tooltip.querySelector('.tooltip-content').innerText;

            if (navigator.clipboard && window.isSecureContext) {
                // Use modern clipboard API
                navigator.clipboard.writeText(textContent)
                    .then(() => showCopySuccess())
                    .catch(err => {
                        console.error('Clipboard API failed:', err);
                        fallbackCopyText(textContent);
                    });
            } else {
                // Use fallback for insecure context or unsupported browser
                fallbackCopyText(textContent);
            }
        });

        function fallbackCopyText(text) {
            const textarea = document.createElement('textarea');
            textarea.value = text;

            // Avoid scrolling to bottom
            textarea.style.position = 'fixed';
            textarea.style.top = '0';
            textarea.style.left = '0';
            textarea.style.opacity = '0';
            document.body.appendChild(textarea);
            textarea.focus();
            textarea.select();

            try {
                const success = document.execCommand('copy');
                if (success) {
                    showCopySuccess();
                } else {
                    console.warn('execCommand copy failed');
                }
            } catch (err) {
                console.error('Fallback copy failed: ', err);
            }

            document.body.removeChild(textarea);
        }

        function showCopySuccess() {
            copyBtn.innerHTML = '<i class="fas fa-check copied"></i>';
            setTimeout(() => {
                copyBtn.innerHTML = '<i class="fas fa-clipboard"></i>';
            }, 1000);
        }



        pinBtn.addEventListener('click', () => {
            if (pinnedTooltips.has(tooltip)) {
                tooltip.classList.remove('pinned');
                pinnedTooltips.delete(tooltip);
                pinBtn.innerHTML = '<i class="fas fa-thumbtack"></i>';
            } else {
                tooltip.classList.add('pinned');
                pinnedTooltips.add(tooltip);
                pinBtn.innerHTML = '<i class="fas fa-thumbtack copied"></i>';
            }
        });

        tooltip.addEventListener('mouseenter', () => {
            clearTimeout(hideTimeout);
        });

        tooltip.addEventListener('mouseleave', () => {
            if (!pinnedTooltips.has(tooltip)) {
                hideTooltipWithDelay(tooltip, popperInstance, trigger);
            }
        });
    }

    function hideTooltipWithDelay(tooltip, popperInstance, trigger) {
        hideTimeout = setTimeout(() => {
            if (!pinnedTooltips.has(tooltip)) {
                tooltip.style.display = 'none';
            }
        }, 200);
    }

    function initializeStickyToolTip() {
        document.querySelectorAll('.tooltip-trigger').forEach(trigger => {
            const tooltipText = trigger.getAttribute('title');
            if (tooltipText) {
                trigger.removeAttribute('title');
            }

            trigger.addEventListener('mouseenter', () => {
                clearTimeout(showTimeout);
                showTimeout = setTimeout(() => {
                    showTooltip(trigger, tooltipText);
                }, 500);
            });

            trigger.addEventListener('mouseleave', () => {
                clearTimeout(showTimeout);
                const tooltipData = activeTooltips.get(trigger);
                if (tooltipData && !pinnedTooltips.has(tooltipData.tooltip)) {
                    hideTooltipWithDelay(tooltipData.tooltip, tooltipData.popperInstance, trigger);
                }
            });
        });
    }

    n.initializeStickyToolTip = initializeStickyToolTip;
})(QfqNS.Helper);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global console */
/* global CodeMirror */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    /**
     * Initializes codemirror. Only `<textarea/>` elements having the class `qfq-codemirror` are initialized.
     *
     * The codemirror configuration has to be provided in the `data-config` attribute as JSON. E.g.
     *
     *      <textarea class="qfq-codemirror" data-config='{ "mode": "qfq", "lineNumbers": true }'></textarea>
     *
     * @function
     */
    var codemirror = function () {
        if (typeof CodeMirror === 'undefined') {
            return;
        }
        // helper to add/remove overlay
        function toggleCmOverlay(cmWrapper, shouldBlock) {
            let $ov = cmWrapper.find('.cm-blocker');
            if (shouldBlock && !$ov.length) {
                $ov = $('<div class="cm-blocker"></div>').css({
                    position:   'absolute',
                    top:        0,
                    left:       0,
                    width:      '100%',
                    height:     '100%',
                    zIndex:     9,
                    background: '#cacaca',
                    cursor:     'not-allowed',
                    opacity:    0.3
                });
                cmWrapper.append($ov);
            } else if (!shouldBlock && $ov.length) {
                $ov.remove();
            }
        }

        $("textarea.qfq-codemirror:not(.cm-extern)").filter(function() {
            return !$(this).next().hasClass("CodeMirror");
        }).each(function () {
            var cmFocusOn = false;
            var oldElement = '';
            var config = {};
            var $this = $(this);
            var height = $this.data('height');
            var width = $this.data('width');
            var configData = $this.data('config');
            if (configData) {
                if (configData instanceof Object) {
                    config = configData;
                } else {
                    QfqNS.Log.warning("'data-config' is invalid: " + configData);
                }
            }
            var cm = CodeMirror.fromTextArea(this, configData);


            // --------------- EXTRA BUTTONS START ----------
            var $textarea = $(this);
            var $btns = $textarea
                .parent('div')
                .find('> .extra-buttons-code-mirror')
                .detach();

            if ($btns.length) {
                $btns.detach();

                // grab the CM wrapper & make it position:relative
                var $cmWrapper = $(cm.getWrapperElement())
                    .css('position','relative');

                // append buttons into wrapper
                $cmWrapper.append(
                    $btns
                        .css({
                            position:       'absolute',
                            top:            '5px',
                            right:          '5px',
                            zIndex:         '10',
                            display:        'flex',
                            justifyContent: 'flex-end',
                            fontSize:       '12px',
                        })
                        .find('button').css({
                        padding:    '2px 4px',
                        fontSize:   '12px',
                        lineHeight: '1',
                        height:     'auto',
                        minWidth:   'auto'
                    }).end()
                );
                var h = $btns.outerHeight(true);
                $cmWrapper
                    .find('.CodeMirror-scroll')
                    .css('padding-top', h + 'px');

                // —— LOCK BUTTON LOGIC ——
                var $lockBtn = $btns
                    .filter('.extraButtonLock')
                    .add($btns.find('.extraButtonLock'));
                if ($lockBtn.length) {
                    // track lock state
                    let isLocked = true;

                    // apply initial lock
                    cm.setOption('readOnly', 'nocursor');
                    toggleCmOverlay($cmWrapper, true);
                    $lockBtn.addClass('locked');

                    // on click
                    $lockBtn.on('click', function() {
                        isLocked = !isLocked;
                        if (isLocked) {
                            cm.setOption('readOnly', 'nocursor');
                            toggleCmOverlay($cmWrapper, true);
                            $lockBtn
                                .removeClass('unlocked')
                                .addClass('locked');
                        } else {
                            cm.setOption('readOnly', false);
                            toggleCmOverlay($cmWrapper, false);
                            $lockBtn
                                .removeClass('locked')
                                .addClass('unlocked');
                        }
                    });
                }
            }
            // --------------- EXTRA BUTTONS END ----------

                // Handle width and height settings
                height = height && parseInt(height) > 0 ? height : 'auto';
                width = width && parseInt(width) > 0 ? width : (width === 0 ? 'auto' : null);

                // Set Home key behavior to move to the start of the visual line
                CodeMirror.keyMap.default.Home = "goLineLeftSmart";

                // Set END key behavior to move to the end of  the visual line
                // CodeMirror.keyMap.default.End = "goLineRightSmart";

                cm.setSize(width, height);

            cm.on('change', (function ($form, $textArea) {
                return function (instance, changeObj) {
                    var actualValue = cm.getValue();
                    if (actualValue !== oldElement && cmFocusOn) {
                        $form.change();
                    }
                };
            })($(this).closest('form'), $this));

                    cm.on('focus', function () {
                        oldElement = $this.val();
                        cmFocusOn = true;
                    });

                    cm.on('blur', function () {
                        var actualValue = cm.getValue();
                        if (actualValue !== oldElement && cmFocusOn) {
                            $this.val(actualValue);
                            $this.trigger('change');
                        }
                        cmFocusOn = false;
                    });

                    // If codemirror has been loaded hidden, refresh once visible
                    $('a[data-toggle="tab"]').on('shown.bs.tab', function() {
                        if(cm.getGutterElement().clientHeight === 0) return;
                        if(cm.getGutterElement().style.getPropertyValue("height")) return;
                        cm.refresh();
                    });

                }
            );

    };

    n.codemirror = codemirror;

})(QfqNS.Helper);

// Separate js to force codemirror for reports in frontend. No Form needed with this.
$(document).ready(function () {
    document.activeElement.blur();
    var externWindow = $(".externWindow");
    var targetEditReportButton = $(".targetEditReport");
    var htmlContent = $('');

    // select all edit buttons from content records and remove onclick attribute to prevent showing content in primary window if clicked. onclick attribute is not removed in php to have the opportunity for old way of editing in front end.
    if (targetEditReportButton !== undefined) {
        for (var i = 0; targetEditReportButton.length > i; i++) {
            var currentTarget = targetEditReportButton[i];
            $(currentTarget).removeAttr("onclick");
        }
    }

    // We prepare the content for extern window and show it. Only if onclick doesn't exist. Compatibility for old way is given this way.
    $(targetEditReportButton).click(function () {
        var baseUrl = $(this).data('base-url');
        if (!$(this).is("[onclick]")) {
            var formContent = $($(this).next()[0].outerHTML);
            showHtmlEditor(formContent, baseUrl);
        }
    });

    //function to show editor window
    function showHtmlEditor(formContent, baseUrl) {
        $(formContent[0]).removeAttr("class");
        $(formContent[0]).removeAttr("style");
        $(formContent[0]).addClass("externWindow");
        var idNameForWindow = $(formContent[0]).attr('id');
        htmlContent = '<!DOCTYPE html>' + $("head").html() + $(formContent)[0].outerHTML;
        newWindow(idNameForWindow);
    }

    // function to create new window with given content for editing
    function newWindow(windowName) {
        var w = window.open('//' + location.host + location.pathname + '?tt-content=' + windowName, windowName, 'width=900,height=700');
        w.document.write(htmlContent);
        w.document.close();
    }

    // Show same content editor with refreshed data again after save. Control it with the given get parameter. First fetch only needed html content again (form) and open it in same window.
    var urlParams = new URLSearchParams(window.location.search);
    var ttContentParam = urlParams.get('tt-content');

    if (ttContentParam !== null && $(targetEditReportButton).next("#" + ttContentParam)[0] !== undefined) {
        var formContent = $($(targetEditReportButton).next("#" + ttContentParam)[0].outerHTML);
        showHtmlEditor(formContent);
    }

    // execute changes(post) and reload page with id of tt-content as get parameter. Staying in same window with new content.
    $(externWindow).submit(function () {
        $.post($(externWindow).attr('action'), $(externWindow).serializeArray())
            .done(function (data) {
                var badge;
                if (data.status === "error") {
                    data.message = data.message.replace(/(<([^>]+)>)/gi, "\n");
                    alert(data.message);
                    badge = '<span style="position:absolute; top:5px; right:5px; background-color: red;" class="badge badge-danger">Failed</span>';
                } else {
                    badge = '<span style="position:absolute; top:5px; right:5px; background-color: green;" class="badge badge-success">Saved</span>';
                }
                $(badge)
                    .insertBefore('.save-message')
                    .delay(3000)
                    .fadeOut(function () {
                        $(this).remove();
                    });
            });
        return false;
    });

    // enable CodeMirror for extern window
    $(externWindow).children(".qfq-codemirror").each(
        function () {
            var config = {};
            var $this = $(this);
            var configData = $this.data('config');
            if (configData) {
                if (configData instanceof Object) {
                    // jQuery takes care of decoding data-config to JavaScript object.
                    config = configData;
                } else {
                    QfqNS.Log.warning("'data-config' is invalid: " + configData);
                }
            }

            // Add viewportMargin to the configuration, makes whole content searchable
            configData.viewportMargin = Infinity;

            var cm = CodeMirror.fromTextArea(this, configData);
            cm.on('change', (function ($form, $textArea) {
                return function (instance, changeObj) {
                    $textArea.val(instance.getValue());
                    $form.change();
                };

            })($(this).closest('form'), $this));
            // This added class is needed to not fire the script more than once
            $(this).addClass("cm-done");
            // For the extern window we use the whole place to show CodeMirror
            $(externWindow).css('height', '100%');
            var heightHeader = parseFloat($(this).prev()[0].offsetHeight / $(externWindow).height ()) * 100;
            var heightMain = 100 - heightHeader;
            $(this).next().css('height', heightMain + '%');

            window.addEventListener('resize', function(e){
                var heightHeader = parseFloat($('.tt-content-bar')[0].offsetHeight / $(externWindow).height()) * 100;
                var heightMain = 100 - heightHeader;
                $('.CodeMirror').css('height', heightMain + '%');
            }, true);
        }
    );
});
/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    n.initColorPicker = function(formReference) {
        const triggers = document.querySelectorAll('.qfq-color-picker');
        if (this.dynamicUpdate) {
            this.form.qfqForm.formUpdateHandler();
        }

        triggers.forEach(el => {
            // Parse the config JSON from data-config
            let config;
            const form = el.closest('form');
            try {
                config = JSON.parse(el.dataset.config);
            } catch (e) {
                console.warn("Invalid data-config on element:", el);
                return;
            }
            const dynamicUpdate = config.dynamicUpdate === 'data-load'

            const pickr = Pickr.create({
                el: el,
                theme: config.theme || 'classic',
                default: config.default || '#000000',
                swatches: config.swatches || [],
                disabled: config.disabled || false,
                appClass: config.appClass || '',
                components: {
                    preview: config.preview !== false,
                    opacity: config.opacity !== false,
                    hue: true,
                    interaction: {
                        hex: config.hex !== false,
                        rgba: config.rgba !== false,
                        input: config.input !== false,
                        clear: config.clear !== false,
                        save: config.save !== false
                    }
                }
            });

            const hiddenInput = document.querySelector(`input[name="${config.dataTarget}"]`);

            pickr.on('save', (color) => {
                form.dispatchEvent(new Event('change', { bubbles: false }))
                pickr.hide();
                let hex = '';
                if (color) {
                    hex = color.toHEXA().toString();
                }
                if (hiddenInput) {
                    hiddenInput.value = hex;
                }
                if (dynamicUpdate) {
                    formReference.qfqForm.formUpdateHandler();
                }
            });
        });
    }
})(QfqNS.Helper);
/**
 * @author Zen Zalapski <zen.zalapski@math.uzh.ch>
 */

/**
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

/**
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    n.storeData = {};
    n.infoData = {};
    n.performanceData = {};
    n.generatedLinkText = '';
    n.logPollingInterval = null;
    n.tabContent = '';
    n.downloadButtonLinks = '';


    /**
     * Initializes the Developer Panel.
     *
     * This function sets up:
     * - DOM references for panel elements and tab content
     * - Backend API endpoints (via SIP injection)
     * - Fetching of initial data: stores, info, and performance
     * - Event listeners for toggling, closing, clearing dirty state, and applying changes
     * - Tab switching behavior with polling control
     *
     * Data is loaded from the backend and rendered via individual tab loaders.
     * Status pills are updated to reflect loading results.
     *
     */
    n.initializeDevPanel = function () {
        // Cache references to key DOM elements
        const toggleDevPanelBtn = document.getElementById('toggle-dev-panel');
        const closeDevPanelBtn = document.getElementById('close-dev-panel');
        const devPanel = document.getElementById('dev-panel');
        const tabs = document.querySelectorAll('.tab-dev-panel');
        n.tabContent = document.getElementById('tab-content-dev-panel');

        // Stop if the dev panel container is not found
        if (!devPanel) return;
        document.body.appendChild(toggleDevPanelBtn);
        document.body.appendChild(devPanel);

        // Extract and parse SIP-based endpoint and button data from the DOM
        n.endPointUrl = devPanel.getAttribute('data-endpoint-url') + '?s=';
        n.apiSips = JSON.parse(devPanel.getAttribute('data-api-sips'));
        n.downloadButtonLinks = JSON.parse(devPanel.getAttribute('data-download-buttons'));
        n.logPollingUrl = n.endPointUrl + n.apiSips.logs;

        // Load Store Data
        const storeUrl = n.endPointUrl + n.apiSips.store;
        fetch(storeUrl, { method: 'GET' })
            .then(r => {
                if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
                const contentType = r.headers.get('content-type');
                if (contentType && contentType.includes('application/json')) return r.json();
                throw new Error('Invalid JSON response');
            })
            .then(response => {
                if (response.status === 'success') {
                    n.storeData = response.data;
                    loadStores();
                    setTabStatus('stores', 'success');
                } else {
                    throw new Error(response.message);
                }
            })
            .catch(e => {
                console.error('Could not Load Store Data:', e);
                setTabStatus('stores', 'error', `Failed to load stores: ${e.message}`);
            });

        // Load Info Data
        const infoUrl = n.endPointUrl + n.apiSips.info;
        fetch(infoUrl, { method: 'GET' })
            .then(r => {
                if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
                const contentType = r.headers.get('content-type');
                if (contentType && contentType.includes('application/json')) return r.json();
                throw new Error('Invalid JSON response');
            })
            .then(response => {
                if (response.status === 'success') {
                    n.infoData = response.data;
                    setTabStatus('info', 'success');
                } else {
                    throw new Error(response.message);
                }
            })
            .catch(e => {
                console.error('Could not load Info Data:', e);
                setTabStatus('info', 'error', 'Failed to load info data');
            });

        // Load Performance Data
        const performanceUrl = n.endPointUrl + n.apiSips.performance;
        fetch(performanceUrl, { method: 'GET' })
            .then(r => {
                if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
                const contentType = r.headers.get('content-type');
                if (contentType && contentType.includes('application/json')) return r.json();
                throw new Error('Invalid JSON response');
            })
            .then(response => {
                if (response.status === 'success') {
                    n.performanceData = response.data;
                    setTabStatus('performance', 'success');
                } else {
                    throw new Error(response.message);
                }
            })
            .catch(e => {
                console.error('Could not load Performance Data:', e);
                setTabStatus('performance', 'error', 'Failed to load performance data');
            });

        // Clear Dirty Table Button
        document.getElementById('clear-dirty-dev-panel').addEventListener('click', async () => {
            const confirmClear = window.confirm('Are you sure you want to clear the dirty table?');
            if (!confirmClear) return;

            const button = document.getElementById('clear-dirty-dev-panel');
            const originalText = button.textContent;
            const clearDirtyUrl = n.endPointUrl + n.apiSips.clearDirty;

            try {
                const response = await fetch(clearDirtyUrl, { method: 'GET' });
                const data = await response.json();

                if (data.status === 'success') {
                    button.textContent = 'Cleared!';
                    button.classList.add('success');
                    button.disabled = true;
                    setTimeout(() => {
                        button.textContent = originalText;
                        button.classList.remove('success');
                        button.disabled = false;
                    }, 1500);
                } else {
                    console.error('Error clearing dirty table:', data.message);
                    button.textContent = 'Failed to Clear!';
                    button.classList.add('fail');
                    button.disabled = true;
                    setTimeout(() => {
                        button.textContent = originalText;
                        button.classList.remove('fail');
                        button.disabled = false;
                    }, 2500);
                }
            } catch (e) {
                console.error('Could not truncate Dirty Table:', e);
                button.textContent = 'API Request failed!';
                button.classList.add('fail');
                button.disabled = true;
                setTimeout(() => {
                    button.textContent = originalText;
                    button.classList.remove('fail');
                    button.disabled = false;
                }, 2500);
            }
        });

        // Apply Changes Button
        document.getElementById('apply-changes-dev-panel').addEventListener('click', () => {
            const confirmApply = window.confirm('Are you sure you want to apply changes made to the Stores?');
            if (!confirmApply) return;

            const applyChangesUrl = n.endPointUrl + n.apiSips.applyChanges;
            fetch(applyChangesUrl, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(n.storeData)
            })
                .then(r => r.json())
                .then(response => {
                    if (response.status === 'success') {
                        window.location.reload();
                    } else {
                        console.error('Error applying changes:', response.message);
                        alert('Failed to apply changes: ' + response.message);
                    }
                })
                .catch(e => {
                    console.error("Can't apply changes:", e);
                });
        });

        // Toggle and close Dev Panel behavior
        toggleDevPanelBtn.addEventListener('click', () => {
            devPanel.style.right = devPanel.style.right === '0px' ? '-600px' : '0px';
        });

        closeDevPanelBtn.addEventListener('click', () => {
            devPanel.style.right = '-600px';
            clearInterval(n.logPollingInterval);
        });

        // Tab switching behavior
        tabs.forEach(tab => {
            tab.addEventListener('click', () => {
                tabs.forEach(t => t.classList.remove('active'));
                tab.classList.add('active');
                activateTab(tab.dataset.tab);
            });
        });
    };



    /**
     * Activates and loads the selected Dev Panel tab by name.
     *
     * This function clears any ongoing polling and then the correct
     * content is loader based on the provided tab name. If the tab name does not match any known tab,
     * it shows a simple placeholder message.
     *
     * @param tabName - The identifier of the tab to activate (e.g., 'stores', 'logs', etc.)
     */
    function activateTab(tabName) {
        // Stop any existing polling when switching tabs
        clearInterval(n.logPollingInterval);

        // Route the tab name to its corresponding loader
        switch (tabName) {
            case 'stores':
                loadStores();
                break;
            case 'logs':
                loadLogs();
                startPolling();
                break
            case 'info':
                loadInfo();
                break
            case 'new-link':
                loadNewLink();
                break
            case 'performance':
                loadPerformance();
                break
            default:
                // Fallback: show a simple placeholder if the tab name is unknown
                n.tabContent.innerHTML = `<p>${tabName.charAt(0).toUpperCase() + tabName.slice(1)} Content</p>`;
                break
        }
    }



    /**
     * Loads and renders the "Stores" tab in the Dev Panel.
     *
     * This function dynamically builds UI blocks for each store defined in `n.storeData`.
     * Each store includes:
     * - A collapsible header
     * - A search bar to filter keys
     * - A table listing all key-value pairs
     * - Input fields (editable unless marked as readOnly)
     *
     * User edits are directly stored in `n.storeData` for later submission.
     *
     */
    function loadStores() {
        // Clear the current content
        n.tabContent.innerHTML = '';

        // Loop over each store in the data
        for (const [storeName, storeValues] of Object.entries(n.storeData)) {
            // Create the main container for this store
            const storeItem = document.createElement('div');
            storeItem.classList.add('store-item');

            // Create and append the store header
            const storeHeader = document.createElement('div');
            storeHeader.classList.add('store-header-dev-panel');
            storeHeader.textContent = storeName;
            storeItem.appendChild(storeHeader);

            // Create the content area that holds search, table, etc.
            const storeContent = document.createElement('div');
            storeContent.classList.add('store-content');

            // Add a search input to filter keys
            const searchBar = document.createElement('input');
            searchBar.type = 'text';
            searchBar.classList.add('search-bar-dev-panel');
            searchBar.style.marginBottom = '5px';
            searchBar.placeholder = 'Search key...';
            storeContent.appendChild(searchBar);

            // Message to show if no matching keys are found
            const noResults = document.createElement('div');
            noResults.classList.add('no-results-dev-panel');
            noResults.textContent = 'No results found';
            storeContent.appendChild(noResults);

            // Table to hold key-value pairs
            const table = document.createElement('table');
            table.classList.add('store-table');

            // Spilt the actual store data and read-only flag
            const { data, readOnly } = storeValues;

            // Build each key-value row
            for (const [key, value] of Object.entries(data)) {
                const row = document.createElement('tr');

                // Key column
                const keyCell = document.createElement('td');
                keyCell.textContent = key;

                // Value column with input
                const valueCell = document.createElement('td');
                valueCell.style.textAlign = 'right';

                const valueInput = document.createElement('input');
                valueInput.type = 'text';
                valueInput.value = value;
                valueInput.disabled = readOnly;

                // Update user changes
                const storeRef = n.storeData[storeName].data;
                valueInput.addEventListener('input', (e) => {
                    storeRef[key] = e.target.value;
                });

                valueCell.appendChild(valueInput);
                row.appendChild(keyCell);
                row.appendChild(valueCell);
                table.appendChild(row);
            }

            // Append all components
            storeContent.appendChild(table);
            storeItem.appendChild(storeContent);
            n.tabContent.appendChild(storeItem);
        }

        // Enable collapsible behavior for each store
        setupExpand('.store-header-dev-panel');

        // Search filtering
        setupSearchFilter();
    }


    /**
     * Loads and renders the "Logs" tab in the Dev Panel.
     *
     * This function creates UI blocks for each type of log (QFQ, SQL, Mail),
     * including:
     * - A collapsible header
     * - A read-only textarea to display log content (populated during polling)
     * - A download link for each log file
     *
     * The textarea is keyed with `data-log-key`, and log content is updated via polling.
     *
     */
    function loadLogs() {
        // Clear previous tab content
        n.tabContent.innerHTML = '';

        // Define available log types and their internal keys
        const logs = [
            { name: "QFQ Log", key: "qfq" },
            { name: "SQL Log", key: "sql" },
            { name: "Mail Log", key: "mail" }
        ];

        // Create and render a log block for each log type
        logs.forEach(({ name, key }) => {
            // Wrapper div
            const logItem = document.createElement('div');
            logItem.classList.add('log-item-dev-panel');

            // Header for the log section (collapsible)
            const logHeader = document.createElement('div');
            logHeader.classList.add('log-header-dev-panel');
            logHeader.textContent = name;
            logItem.appendChild(logHeader);

            // Container for textarea and download link
            const logContent = document.createElement('div');
            logContent.classList.add('log-content-dev-panel');

            // Textarea where log content will be added during polling
            const logTextarea = document.createElement('textarea');
            logTextarea.readOnly = true;
            logTextarea.setAttribute('data-log-key', key);
            logContent.appendChild(logTextarea);

            // Create a download link for the log file
            const downloadLink = document.createElement('a');
            downloadLink.classList.add('download-button-dev-panel');
            downloadLink.textContent = `Download ${name}`;
            downloadLink.href = n.downloadButtonLinks[key] || '#';
            downloadLink.download = '';
            downloadLink.style.display = 'inline-block';

            // If no URL is set, prevent download and log error
            downloadLink.addEventListener('click', (e) => {
                if (!n.downloadButtonLinks[key]) {
                    e.preventDefault();
                    console.error(`No URL found for ${name}`);
                }
            });

            // Append the download link and assemble the component
            logContent.appendChild(downloadLink);
            logItem.appendChild(logContent);
            n.tabContent.appendChild(logItem);
        });

        // Enable collapsible behavior for each log header
        setupExpand('.log-header-dev-panel');
    }


    /**
     * Loads and displays system information in the "Info" tab of the Dev Panel.
     *
     * This function reads data from `n.infoData` (filled during panel initialization)
     * and renders it as a list of key-value pairs, where the key describes the type of information
     * (e.g., PHP version, Webserver) and the value provides the detail.
     *
     */
    function loadInfo() {
        // Clear any existing content
        n.tabContent.innerHTML = '';

        // Create the container that will show all info
        const infoList = document.createElement('div');
        infoList.className = 'info-list-dev-panel';

        // Loop over each key-value pair in the info data
        for (const [key, value] of Object.entries(n.infoData)) {
            const item = document.createElement('div');
            item.innerHTML = `<strong>${key}:</strong> ${value}`;
            infoList.appendChild(item);
        }

        // Insert the populated list into the tab content area
        n.tabContent.appendChild(infoList);
    }


    /**
     * Loads and initializes the "Gen Link" tab in the Dev Panel.
     *
     * This tab provides functionality to:
     * - Input custom link data
     * - Generate a link by sending it to the backend
     * - Display the generated link
     * - Copy the link to clipboard
     *
     *
     * @function loadNewLink
     */
    function loadNewLink() {
        // backend API endpoint for generating the link
        const linkUrl = n.endPointUrl + n.apiSips.link;

        // HTML structure
        n.tabContent.innerHTML = `
        <div class="new-link-container">
            <input type="text" id="link-input" placeholder="Enter link details">
            <button id="generate-link-dev-panel">Generate Link</button>
            <div class="generated-link" id="generated-link-dev-panel">${n.generatedLinkText}</div>
            <button id="copy-link-dev-panel">Copy Link</button>
        </div>
    `;

        // Set up the event listener for generating the link
        document.getElementById('generate-link-dev-panel').addEventListener('click', () => {
            const linkInput = document.getElementById('link-input').value;

            // Send the input data to the backend via POST
            fetch(linkUrl, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ link: linkInput })
            })
                .then(r => r.json())
                .then(response => {
                    if (response.status === 'success') {
                        // Save and display the generated link
                        n.generatedLinkText = response.data.link;
                        document.getElementById('generated-link-dev-panel').textContent = response.data.link;
                        setTabStatus('new-link', 'success');
                    } else {
                        throw new Error(response.message);
                    }
                })
                .catch(e => {
                    console.error("Can't create link:", e);
                    document.getElementById('generated-link-dev-panel').textContent = 'Failed to generate link';
                    setTabStatus('new-link', 'error', `Failed to generate link: ${e.message}`);
                });

            // Show placeholder text immediately while waiting for the response
            n.generatedLinkText = linkInput || 'Generated Link';
            document.getElementById('generated-link-dev-panel').textContent = n.generatedLinkText;
        });

        // Set up the event listener for copying the generated link to the clipboard
        document.getElementById('copy-link-dev-panel').addEventListener('click', () => {
            const textToCopy = document.getElementById('generated-link-dev-panel').textContent;
            const copyBtn = document.getElementById('copy-link-dev-panel');
            const originalText = copyBtn.textContent;

            // Use clipboard API if available
            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(textToCopy).then(() => {
                    copyBtn.textContent = 'Copied!';
                    copyBtn.disabled = true;
                    setTimeout(() => {
                        copyBtn.textContent = originalText;
                        copyBtn.disabled = false;
                    }, 1500);
                }).catch(err => {
                    console.error('Failed to copy: ', err);
                });
            } else {
                // Fallback for older browsers: use a temporary textarea element
                const textArea = document.createElement('textarea');
                textArea.value = textToCopy;
                document.body.appendChild(textArea);
                textArea.select();
                try {
                    const successful = document.execCommand('copy');
                    if (successful) {
                        copyBtn.textContent = 'Copied!';
                        copyBtn.disabled = true;
                        setTimeout(() => {
                            copyBtn.textContent = originalText;
                            copyBtn.disabled = false;
                        }, 1500);
                    } else {
                        console.error('Fallback: Copy command was unsuccessful');
                    }
                } catch (err) {
                    console.error('Fallback: Oops, unable to copy', err);
                }
                document.body.removeChild(textArea);
            }
        });
    }


    /**
     * Sets up toggle functionality for expandable sections in the Dev Panel (e.g., stores or logs).
     *
     * This function attaches a click event listener to all elements matching the provided selector.
     * When clicked, it toggles the visibility of the next sibling element
     *
     */
    function setupExpand(selector) {
        // Select all expandable header elements
        document.querySelectorAll(selector).forEach(header => {
            // Attach click listener to each header
            header.addEventListener('click', () => {
                const content = header.nextElementSibling;
                // Toggle the visibility of the content
                content.style.display = content.style.display === 'block' ? 'none' : 'block';
            });
        });
    }


    /**
     * Initializes search filtering for each store's key list in the Dev Panel.
     *
     * This function attaches an `input` event listener to all elements with the class `.search-bar-dev-panel`.
     * As the user types into a search bar, it filters the table rows based on whether
     * the row's key matches the search input. If no matches are found, a "no results" message is shown.
     *
     */
    function setupSearchFilter() {
        // Select all search bars within the Dev Panel
        document.querySelectorAll('.search-bar-dev-panel').forEach(searchBar => {
            // Attach an input listener to each search bar
            searchBar.addEventListener('input', (e) => {
                const filter = e.target.value.toLowerCase();
                const storeContent = e.target.parentElement;
                const rows = storeContent.querySelectorAll('table tr');
                const noResults = storeContent.querySelector('.no-results-dev-panel');
                let anyVisible = false;

                // Loop through each row to check if the key cell matches the search value
                rows.forEach(row => {
                    const keyCell = row.querySelector('td');
                    if (keyCell) {
                        const text = keyCell.textContent.toLowerCase();
                        const matches = text.includes(filter);
                        row.style.display = matches ? '' : 'none';
                        if (matches) anyVisible = true;
                    }
                });

                // Show "no results" message only if no rows are visible
                noResults.style.display = anyVisible ? 'none' : 'block';
            });
        });
    }


    /**
     * Starts polling for log data every second and updates the textareas in the "Logs" tab.
     *
     * If an error occurs during polling (e.g. network or parsing issue), the interval is cleared
     * and polling is stopped. The status pill for the "Logs" tab is updated to error.
     *
     */
    function startPolling() {
        // Clear any existing polling interval to avoid duplicates
        clearInterval(n.logPollingInterval);

        // Start a new polling interval
        n.logPollingInterval = setInterval(() => {
            fetch(n.logPollingUrl, { method: 'GET' })
                .then(r => r.json())
                .then(response => {
                    const data = response.data;

                    // Update each matching textarea with new log content
                    Object.keys(data).forEach(key => {
                        const textarea = document.querySelector(`[data-log-key="${key}"]`);
                        if (textarea) {
                            textarea.value = data[key];
                        }
                        // Mark the tab status as successfully updated
                        setTabStatus('logs', 'success');
                    });
                })
                .catch(e => {
                    // On error, stop polling and mark tab as errored
                    console.error('Polling Failed: Stopping Log Polling');
                    clearInterval(n.logPollingInterval);
                    setTabStatus('logs', 'error', `Failed to load logs: ${e.message}`);
                });
        }, 1000); // Poll every 1000 ms
    }


    /**
     * Dynamically builds and loads the "Performance" tab content in the Dev Panel.
     *
     * This function reads SQL performance data from `n.performanceData`, which is filled
     * during initialization. It creates a table where each row represents a SQL query and its execution time.
     * The query level and a short preview are shown by default, with the ability to toggle full query text.
     * A visual bar indicates the relative execution time based on the maximum time observed.
     *
     */
    function loadPerformance() {
        n.tabContent.innerHTML = '';

        if (!Array.isArray(n.performanceData) || n.performanceData.length === 0) {
            n.tabContent.innerHTML = '<p>No performance data available.</p>';
            return;
        }

        n.performanceData.forEach((block, blockIndex) => {
            const blockEntries = Object.entries(block);
            if (blockEntries.length === 0) return;

            // Create the header and make it clickable
            const title = document.createElement('h3');
            title.className = 'toggle-block-header';
            title.innerHTML = `<span class="arrow">▼</span> Report ${blockIndex + 1}`;
            title.style.cursor = 'pointer';

            // Create a container to hold the block's table (so we can toggle it)
            const container = document.createElement('div');
            container.className = 'block-container';

            // Table creation
            const table = document.createElement('table');
            table.className = 'performance-table';

            const headerRow = document.createElement('tr');
            ['Query (Level)', 'Time (ms)', 'Relative Time'].forEach(text => {
                const th = document.createElement('th');
                th.textContent = text;
                headerRow.appendChild(th);
            });
            table.appendChild(headerRow);

            const maxTime = Math.max(...blockEntries.map(([, data]) => data.time));

            blockEntries.forEach(([level, { query, time }]) => {
                const row = document.createElement('tr');

                const queryCell = document.createElement('td');
                queryCell.textContent = `${level}: ${query.substring(0, 20)}${query.length > 20 ? '…' : ''}`;
                queryCell.style.cursor = 'pointer';
                queryCell.title = 'Click to show full query';

                const expandRow = document.createElement('tr');
                const expandCell = document.createElement('td');
                expandCell.colSpan = 3;
                expandCell.className = 'expanded-query-cell';
                expandCell.style.display = 'none';
                expandCell.textContent = query;
                expandRow.appendChild(expandCell);

                queryCell.addEventListener('click', () => {
                    const isHidden = expandCell.style.display === 'none';
                    expandCell.style.display = isHidden ? 'table-cell' : 'none';
                    queryCell.title = isHidden ? 'Click to hide full query' : 'Click to show full query';
                });

                const timeCell = document.createElement('td');
                timeCell.textContent = `${time}ms`;

                const barCell = document.createElement('td');
                const bar = document.createElement('div');
                bar.className = 'performance-bar-dev-panel';
                bar.style.width = `${(time / maxTime) * 100}%`;
                barCell.appendChild(bar);

                row.appendChild(queryCell);
                row.appendChild(timeCell);
                row.appendChild(barCell);

                table.appendChild(row);
                table.appendChild(expandRow);
            });

            container.appendChild(table);

            // Handle toggle on click
            title.addEventListener('click', () => {
                const isHidden = container.style.display === 'none';
                container.style.display = isHidden ? 'block' : 'none';

                const arrow = title.querySelector('.arrow');
                arrow.textContent = isHidden ? '▼' : '▶';
            });

            // Append everything
            n.tabContent.appendChild(title);
            n.tabContent.appendChild(container);
        });
    }




    /**
     * Updates the visual status indicator (pill) for a given Dev Panel tab.
     *
     * This function sets the CSS class and tooltip (`title`) of the status pill
     * element with a specific tab, showing whether the tab's
     * data is loading, successfully loaded, or failed to load.
     *
     * @param tabKey - The key of the tab (e.g. 'stores', 'logs', etc.), used to find the pill element by ID.
     * @param status - The status type: 'success', 'loading', or 'error'. Determines pill color.
     * @param title='' - Optional custom tooltip text. If not provided, a default based on `status` is used.
     */
    function setTabStatus(tabKey, status, title = '') {
        // Get the pill element by its dynamic ID
        const pill = document.getElementById(`status-${tabKey}`);
        if (!pill) return;

        // Set the CSS class
        pill.className = `status-pill ${status}`;

        // Set the tooltip text: use provided title or fallback to default by status
        pill.title = title || ({
            success: 'Loaded successfully',
            error: 'Failed to load',
            loading: 'Loading...'
        }[status] || '');
    }

})(QfqNS.Helper)

var QfqNS = QfqNS || {};

QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';
    n.initializeDownloadProcess = async function () {
        // Queue variables to start downloads serially (session lock)
        let queueTasks = [];
        let isSessionLock = false;
        $('a.modalDownload').each(function () {
            $(this).on('click', async function (e) {
                e.preventDefault();

                // Set up variables from the link and its child span
                let link = $(this).attr('href');
                // Generate jobId (easiest way to share jobId between JS and PHP)
                let jobId = 'qfq' + Date.now();
                let dataSpan = $(this).children('span').first();
                let dataText = dataSpan.attr('data-text');
                let dataTitle = dataSpan.attr('data-title');
                // Read downloadMode early so PHP can access it via URL parameter
                let downloadMode = dataSpan.attr('data-download-mode');
                // Build endpoint URLs by appending modalDownload action parameters
                let prepFileLink = link + "&modalDownload=prepFile&jobId=" + jobId;
                let status = link + "&modalDownload=progress&jobId=" + jobId;
                let timing = link + "&modalDownload=timing&downloadMode=" + downloadMode;
                let deleteProcessFile = link + "&modalDownload=deleteFile&jobId=" + jobId;

                try {
                    // Create or reuse the shared toast container (fixed overlay, top-right corner)
                    let toastBox = document.getElementById('downloadToastBox');
                    if (!toastBox) {
                        toastBox = document.createElement("div");
                        toastBox.id = "downloadToastBox";
                        toastBox.className = "toastBox";
                        // Default positioning for the toast container
                        toastBox.style.position = 'fixed';
                        toastBox.style.top = '1px';
                        toastBox.style.right = '13px';
                        toastBox.style.overflow = 'auto';
                        toastBox.style.maxHeight = '350px';
                        toastBox.style.width = '220px';
                        toastBox.style.zIndex = '110';
                        document.body.appendChild(toastBox);
                    }

                    const toastFrame = document.createElement("div");
                    toastFrame.className = "panel panel-default";
                    toastFrame.style.marginBottom = '4px';
                    toastBox.appendChild(toastFrame);

                    const toastTitleBar = document.createElement("div");
                    toastTitleBar.className = 'panel-heading';
                    toastTitleBar.style.padding = '2px 4px';
                    toastTitleBar.style.display = 'flex';
                    toastTitleBar.style.justifyContent = 'space-between';
                    toastTitleBar.style.alignItems = 'flex-start';
                    toastFrame.appendChild(toastTitleBar);

                    const toastTitle = document.createElement("div");
                    toastTitle.textContent = dataTitle;
                    toastTitle.style.overflow = 'hidden';
                    toastTitle.style.wordBreak = 'break-word';
                    toastTitle.style.minWidth = '0';
                    toastTitleBar.appendChild(toastTitle);

                    const button = document.createElement("button");
                    button.className = "btn";
                    button.style.width = "15px";
                    button.style.height = "15px";
                    button.style.padding = "0";
                    button.style.margin = "3px";
                    button.style.display = "flex";
                    button.style.alignItems = "center";
                    button.style.justifyContent = "center";
                    button.style.flexShrink = "0";
                    toastTitleBar.appendChild(button);

                    const icon = document.createElement("span");
                    icon.className = "glyphicon glyphicon-remove";
                    button.appendChild(icon);

                    const panelBody = document.createElement("div");
                    panelBody.className = "panel-body";
                    panelBody.style.padding = '5px';
                    toastFrame.appendChild(panelBody);

                    const toastProgress = document.createElement("div");
                    toastProgress.className = 'progress';
                    toastProgress.role = 'progressbar';
                    toastProgress.ariaLabel = 'Animated striped';
                    toastProgress.style.margin = '0px';
                    toastProgress.style.height = '10px';
                    panelBody.appendChild(toastProgress);

                    const toastProgressBar = document.createElement("div");
                    toastProgressBar.className = 'progress-bar progress-bar-success progress-bar-striped active';
                    toastProgressBar.style.width = '0%';
                    toastProgressBar.setAttribute("role", "progressbar");
                    toastProgressBar.setAttribute("aria-valuenow", "0");
                    toastProgressBar.setAttribute("aria-valuemin", "0");
                    toastProgressBar.setAttribute("aria-valuemax", "100");
                    toastProgress.appendChild(toastProgressBar);

                    const toastStatus = document.createElement("div");
                    panelBody.appendChild(toastStatus);

                    const toastElement = document.createElement("div");
                    if (dataText && dataText !== 'Please wait') {
                        toastElement.textContent = dataText;
                    }
                    panelBody.appendChild(toastElement);

                    // Cancel button: remove from queue if pending, or abort active fetch
                    let controllerRef = {controller: null};
                    button.addEventListener('click', function () {
                        const index = queueTasks.findIndex(t => t.jobId === jobId);
                        if (index !== -1) {
                            // Job is still waiting in queue — remove it without starting
                            queueTasks.splice(index, 1);
                            toastFrame.remove();
                        } else {
                            // Job is already running — abort the active fetch
                            if (controllerRef.controller) {
                                controllerRef.controller.abort();
                            } else {
                                // No active request (error state) — just close the toast
                                toastFrame.remove();
                                if (toastBox.children.length === 0) {
                                    toastBox.remove();
                                }
                            }
                        }
                    });

                    // Add job to the queue with all required references
                    queueTasks.push({
                        jobId: jobId,
                        prepFileLink: prepFileLink,
                        status: status,
                        link: link,
                        toastStatus: toastStatus,
                        toastProgress: toastProgress,
                        toastProgressBar: toastProgressBar,
                        toastFrame: toastFrame,
                        toastBox: toastBox,
                        timing: timing,
                        deleteProcessFile: deleteProcessFile,
                        controllerRef: controllerRef,
                    })

                    if (!isSessionLock) {
                        await processNext();
                    }
                }
                catch (c) {
                    // Display error in console if toast setup fails
                    console.error('Download failed: ' + (c));
                }
            });
        });

        // Process queue serially — required because PHP session is locked per request
        async function processNext() {
            if (queueTasks.length === 0) {
                isSessionLock = false;
                return;
            }
            isSessionLock = true;
            const task = queueTasks.shift();

            // Stop flag for the polling loop; declared before the try so the outer catch
            // can halt polling and keep the queue alive if anything below throws (no deadlock).
            let polling = true;

            try {
                // Fetch estimated generation time to drive progress bar animation
                const timingRes = await fetch(task.timing, {method: 'GET'});
                const timingData = await timingRes.json();

                // Poll to keep session alive and advance progress bar based on estimated timing
                const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
                //Guard: missing/0 timing -> default 3s so step never becomes NaN/Infinity.
                const estimate = Number(timingData.timing) > 0 ? Number(timingData.timing) : 3;
                const step = 100 / estimate;
                const pollFile = async () => {
                    let increase = 0;
                    while (polling) {
                        try {
                            await fetch(task.status, {method: 'GET'});

                            // Advance progress bar by one step, capped at 100%
                            const pct = Math.min(increase + step, 100);
                            task.toastProgressBar.setAttribute("aria-valuenow", String(pct));
                            task.toastProgressBar.style.width = pct + "%";
                            increase += step;

                        } catch (e) {
                            console.error("Polling error: ", e);
                        }
                        await sleep(1000);
                    }
                };
                pollFile().catch(e => console.error(e));

                // Trigger file generation on PHP side and wait for the file path in the JSON response
                const controller = new AbortController();
                task.controllerRef.controller = controller;
                let dataJson;

                try {
                    const response = await fetch(task.prepFileLink, {method: 'GET', signal: controller.signal});
                    dataJson = await response.json();

                    if (dataJson.success === false) {
                        polling = false;
                        task.controllerRef.controller = null;
                        task.toastFrame.style.borderColor = 'orange';
                        task.toastProgressBar.className = 'progress-bar progress-bar-warning';
                        task.toastStatus.textContent = dataJson.error || 'Download failed';
                        // Stop polling, but keep the toast visible so the user can read the error
                        await processNext();
                        return;
                    }

                } catch (e) {
                    // User cancelled the job via the close button — clean up and continue queue
                    if (e.name === 'AbortError') {
                        await fetch(task.deleteProcessFile, {method: 'GET'});
                        polling = false;
                        task.toastFrame.remove();
                        if (task.toastBox.children.length === 0) {
                            task.toastBox.remove();
                        }
                        await processNext();
                        return;
                    }
                    throw e;
                }

                // Stop polling once file generation is complete
                polling = false;

                // Trigger browser download by navigating to the file download URL
                window.location = task.link + "&mode=" + dataJson.mode + "&modalDownload=downloadFile";

                // Always set progress to 100% even if generation was faster than 1 polling interval
                task.toastProgressBar.setAttribute("aria-valuenow", "100");
                task.toastProgressBar.style.width = "100%";

                await fetch(task.deleteProcessFile, {method: 'GET'});

                if (queueTasks.length >= 1) {
                    // More jobs pending — remove this toast immediately and process next
                    task.toastFrame.remove();
                    await processNext().catch(e => console.error(e));
                } else {
                    // Last job — keep toast visible briefly, then remove and finalize
                    setTimeout(async () => {
                        task.toastFrame.remove();
                        if (task.toastBox.children.length === 0) {
                            task.toastBox.remove();
                        }
                        await processNext().catch(e => console.error(e));
                    }, 3000);
                }
            } catch (e) {
                // Any unhandled failure (timing/deleteFile fetch, prepFile non-abort error, ...):
                // stop polling, surface the error on the toast, and keep the queue alive so the
                // session is not permanently locked (isSessionLock would otherwise stay true).
                console.error('Download failed: ', e);
                polling = false;
                task.controllerRef.controller = null;
                task.toastFrame.style.borderColor = 'orange';
                task.toastProgressBar.className = 'progress-bar progress-bar-warning';
                task.toastStatus.textContent = 'Download failed';
                await processNext();
            }
        }
    }
})(QfqNS.Helper);
/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    let copyBtn;

    function initializeEmailSelect() {
        if (!document.getElementById('emailModal')) {
            const modalHtml = `
<div id="emailModal" class="email-custom-modal hidden">
  <div class="email-custom-modal-backdrop"></div>
  <div class="email-custom-modal-content">
    <div class="email-custom-modal-header">
      <span class="email-custom-modal-title">Select Email Recipients</span>
      <button class="email-custom-modal-close" id="emailModalClose">&times;</button>
    </div>
    <div class="email-custom-modal-body">
      <form id="emailSelectorForm">
        <div id="emailCounter" style="text-align: right; margin-bottom: 6px;">Selected: 0</div>
        <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px; margin-left: 1em;">
          <input type="checkbox" id="selectAllEmails" checked title="Select All" style="margin-left: 0.5em;">
          <div id="sortEmails" title="Sort" style="width: 20px; height: 20px; display: flex; justify-content: center; align-items: center; cursor: pointer;">
            <span id="sortArrow">\u25BC</span>
          </div>
          <input type="text" id="emailFilter" placeholder="Filter emails..." class="form-control" style="flex-grow: 1;">
        </div>
        <div id="emailList" style="max-height: 300px; overflow-y: auto;"></div>
        <div style="margin-top: 10px; display: flex; justify-content: flex-end; gap: 10px;">
        <div class="btn-group" role="group"> 
        <button type="button" id="copyEmails" class="btn btn-default">Copy Emails</button>
          <button type="button" id="sendEmail" class="btn btn-primary">Send Email to Selected</button>
        </div>
        </div>
      </form>
    </div>
  </div>
</div>`;
            document.body.insertAdjacentHTML('beforeend', modalHtml);
            bindCopyButtonHandler();
            document.getElementById('emailModalClose').addEventListener('click', hideModal);
            document.querySelector('.email-custom-modal-backdrop').addEventListener('click', hideModal);
        }

        const emailListContainer = document.getElementById('emailList');
        const sendBtn = document.getElementById('sendEmail');
        const filterInput = document.getElementById('emailFilter');
        const selectAllCheckbox = document.getElementById('selectAllEmails');
        const emailCounter = document.getElementById('emailCounter');

        document.querySelectorAll('.email-popup-trigger').forEach(trigger => {
            trigger.addEventListener('click', function (e) {
                e.preventDefault();

                let emails;
                try {
                    emails = JSON.parse(this.getAttribute('data-emails') || '[]');
                } catch (err) {
                    alert('Invalid email data');
                    return;
                }

                const storageKey = 'emailSelection_' + btoa(emails.join(','));
                let emailSelections = {};
                let sortAsc = true;
                const sortArrow = document.getElementById('sortArrow');
                const sortBox = document.getElementById('sortEmails');

                const savedSelections = JSON.parse(localStorage.getItem(storageKey) || '[]');
                emails.forEach(email => {
                    emailSelections[email] = savedSelections.length === 0 || savedSelections.includes(email);
                });

                function bindCheckboxEvents() {
                    emailListContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
                        cb.addEventListener('change', function () {
                            emailSelections[cb.value] = cb.checked;
                            updateCounterAndStorage();
                        });
                    });
                }

                function updateCounterAndStorage() {
                    const selected = Object.keys(emailSelections).filter(email => emailSelections[email]);
                    emailCounter.textContent = 'Selected: ' + selected.length;
                    localStorage.setItem(storageKey, JSON.stringify(selected));
                }

                function renderEmailList() {
                    const filterValue = filterInput.value.toLowerCase();
                    const sorted = [...emails].sort((a, b) =>
                        sortAsc ? a.localeCompare(b) : b.localeCompare(a)
                    );
                    const filtered = sorted.filter(email => email.toLowerCase().includes(filterValue));

                    emailListContainer.innerHTML = '';
                    filtered.forEach((email, i) => {
                        const id = `email_${i}`;
                        const isChecked = emailSelections[email];
                        emailListContainer.insertAdjacentHTML('beforeend', `
                            <div class="checkbox">
                                <label>
                                    <input type="checkbox" id="${id}" value="${email}" ${isChecked ? 'checked' : ''}>
                                    ${email}
                                </label>
                            </div>
                        `);
                    });

                    bindCheckboxEvents();
                    updateCounterAndStorage();
                }

                selectAllCheckbox.addEventListener('change', function () {
                    const checked = this.checked;
                    Object.keys(emailSelections).forEach(email => {
                        emailSelections[email] = checked;
                    });
                    renderEmailList();
                });

                filterInput.value = '';
                selectAllCheckbox.checked = true;
                sortArrow.textContent = '▼';

                filterInput.addEventListener('input', renderEmailList);

                sortBox.addEventListener('click', function () {
                    sortAsc = !sortAsc;
                    sortArrow.textContent = sortAsc ? '▲' : '▼';
                    renderEmailList();
                });

                sendBtn.onclick = function () {
                    const selected = Object.keys(emailSelections).filter(email => emailSelections[email]);
                    if (!selected.length) {
                        flashButtonState(sendBtn, '⚠ None selected', 'Send Email to Selected', 'btn btn-warning');
                        return;
                    }
                    window.location.href = 'mailto:' + selected.join(',');
                    flashButtonState(sendBtn, '✔ Sent', 'Send Email to Selected', 'btn btn-success');
                    hideModal();
                };

                renderEmailList();
                showModal();
            });
        });
    }

    function showModal() {
        document.getElementById('emailModal').classList.remove('hidden');
    }

    function hideModal() {
        document.getElementById('emailModal').classList.add('hidden');
    }

    function bindCopyButtonHandler() {
        copyBtn = document.getElementById('copyEmails');

        if (!copyBtn) return;

        copyBtn.addEventListener('click', () => {
            const checkboxes = document.querySelectorAll('#emailList input[type="checkbox"]');
            const selected = Array.from(checkboxes)
                .filter(cb => cb.checked)
                .map(cb => cb.value);

            if (!selected.length) {
                flashButtonState(copyBtn, '⚠ None selected', 'Copy Emails', 'btn btn-warning');
                return;
            }

            const emailString = selected.join(',');

            if (navigator.clipboard && window.isSecureContext) {
                navigator.clipboard.writeText(emailString)
                    .then(() => {
                        flashButtonState(copyBtn, '✔ Copied!', 'Copy Emails', 'btn btn-success');
                    })
                    .catch(() => {
                        fallbackCopy(emailString);
                    });
            } else {
                fallbackCopy(emailString);
            }
        });
    }

    function fallbackCopy(text) {
        const textArea = document.createElement('textarea');
        textArea.value = text;
        textArea.style.position = 'fixed';
        textArea.style.top = '0';
        textArea.style.left = '0';
        textArea.style.width = '1px';
        textArea.style.height = '1px';
        textArea.style.opacity = '0';

        document.body.appendChild(textArea);
        textArea.focus();
        textArea.select();

        try {
            const successful = document.execCommand('copy');
            flashButtonState(
                copyBtn,
                successful ? '✔ Copied!' : '❌ Failed',
                'Copy Emails',
                successful ? 'btn btn-success' : 'btn btn-danger'
            );
        } catch (err) {
            flashButtonState(copyBtn, '❌ Error', 'Copy Emails', 'btn btn-danger');
            console.error('Fallback copy failed:', err);
        }

        document.body.removeChild(textArea);
    }

    function flashButtonState(button, text, originalText, cssClass, timeout = 2000) {
        const originalClass = button.className;
        button.textContent = text;
        button.className = cssClass;
        setTimeout(() => {
            button.textContent = originalText;
            button.className = originalClass;
        }, timeout);
    }

    n.initializeEmailSelect = initializeEmailSelect;

})(QfqNS.Helper);

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */

var QfqNS = QfqNS || {};

/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    n.filePond = function createFileUpload(inputElement, form) {
        // Retrieve all needed data and configurations
        this.inputElement = inputElement;
        this.form = form;
        this.pond = null;
        this.dynamicUpdate = inputElement.getAttribute('data-load');
        const configData = inputElement.getAttribute('data-config');
        this.configuration = configData ? JSON.parse(configData) : [];
        this.normalizeConfiguration();

        this.saveButton = false;
        if (this.configuration.form) {
            const formId = this.inputElement.closest('form.qfq-form').id;
            const saveButtonId = 'save-button-' + formId;
            this.saveButton = document.querySelector('#' + saveButtonId);

            // No save button for formModeGlobal=readonly
            if(this.saveButton) {
                this.saveButtonIcon = this.saveButton.querySelector('span');
                this.saveButtonLabel = this.saveButton.innerText;
            }
        }

        const apiUrls = inputElement.getAttribute('data-api-urls');
        this.apiUrls = apiUrls ? JSON.parse(apiUrls) : [];

        const sipValues = inputElement.getAttribute('data-sips');
        this.sipValues = sipValues ? JSON.parse(sipValues) : [];

        //Initialize existing preloaded files
        this.filePondFiles = this.getPreloadedFiles(this.inputElement);

        // Initialize flags
        this.lastUploadId = null;
        this.currentFieldId = false;
        this.deletedFileId = true;
        this.lastSipTmp = false;
    };

    n.filePond.prototype.createFilePondObject = function() {
        // Create the FilePond instance
        let lastServerErrorMessage = null;
        const pond = FilePond.create(this.inputElement, {
            allowMultiple: this.configuration.multiUpload,
            allowRemove: this.configuration.deleteOption,
            allowRevert: true,
            maxFileSize: this.configuration.maxFileSize,
            allowFileSizeValidation: this.configuration.activeSizeValidation,
            acceptedFileTypes: this.configuration.accept,
            labelFileProcessingComplete: this.configuration.completeMessage,
            fileValidateTypeDetectType: (source, type) => new Promise((resolve, reject) => {
                // Get file extension
                const extension = source.name.split('.').pop().toLowerCase();
                const mimeType = source.type;

                // Split the comma-separated string and handle it as an array
                let acceptedType = null;
                const acceptedTypes = this.configuration.accept.split(',');

                // Iterate over the accepted types and find a case-insensitive match
                acceptedTypes.forEach(accepted => {
                    if (accepted.trim().toLowerCase() === '.' + extension || accepted.trim().toLowerCase() === mimeType.toLowerCase()) {
                        acceptedType = accepted.trim();
                    }
                });

                // Check if the file extension or mime type is in the allowed extensions list
                if (this.configuration.extensions.map(ext => ext.toLowerCase()).includes(extension)) {
                    resolve(acceptedType); // If yes, use the detected type
                } else if (this.configuration.mimes.includes(mimeType)) {
                        resolve(type); // Direct match found
                    } else {
                        // Check for wildcard MIME type matches if no direct match is found
                        // example image/png split to image/*
                        const mimeTypeBase = mimeType.split('/')[0] + '/*';
                        if (this.configuration.mimes.includes(mimeTypeBase)) {
                            resolve(type); // Wildcard match found
                        } else {
                            reject(type); // No valid MIME type or wildcard match found
                        }
                    }
            }),
            allowFileTypeValidation: this.configuration.activeTypeValidation,
            allowImagePreview: this.configuration.imageEditor,
            allowImageEdit: this.configuration.imageEditor,
            allowDrop: this.configuration.allowUpload,
            allowBrowse: this.configuration.allowUpload,
            allowPaste: this.configuration.allowUpload,
            labelIdle: this.configuration.text,
            maxFiles: this.configuration.maxFiles,
            allowReorder: false,
            imagePreviewMaxHeight: 150,
            styleButtonRemoveItemPosition: 'right',
            credits: false,
            dropValidation: false,
            maxParallelUploads: 1,
            files: this.filePondFiles,
            iconRemove: '<i class="fas fa-trash" style="color: white;"></i>',
            server: {
                process: {
                    url: this.apiUrls.upload + "?s=" + this.sipValues.upload,
                    method: 'POST',
                    withCredentials: false,
                    headers: {},
                    ondata: (formData) => {
                        return this.setOnData(formData);
                    },
                    onload: (response) => {
                        // response is the JSON string returned by the server
                        const res = JSON.parse(response);
                        if (this.lastUploadId === null) {
                            this.lastUploadId = res.groupId;

                        }
                        // Here you can handle the unique file ID as needed
                        // console.log('File uploaded successfully:', res.uniqueFileId);
                        // console.log('Upload Id:', res.groupId);
                        // console.log('sipTmp:', res.sipTmp);
                        this.lastSipTmp = res.sipTmp;

                        return res.uniqueFileId; // Must return the unique file ID to FilePond
                    },
                    onerror: (response) => {
                        // Handle error here
                        console.error('Error during upload:', response);

                        // Get Error Message
                        lastServerErrorMessage = "Upload failed.";
                        try {
                            const parsed = JSON.parse(response);
                            if (parsed && parsed.message) {
                                lastServerErrorMessage = parsed.message;
                            }
                        } catch (e) {
                            console.warn("Could not parse error response:");
                        }
                    }
                },
                revert: (uniqueFileId, load, error) => {
                    this.setRevert(uniqueFileId, load, error);
                },
                remove: (uniqueFileId, load, error) => {
                    this.setRemove(uniqueFileId, load, error);
                },
                load: (source, load, error, progress, abort, headers) => {
                    console.log('loaded');
                }
            },
            onprocessfile: (error, fileItem) => {
                if (error) {
                    console.error('Error processing file:', error);
                    if (error.code === 413) {
                        let alert = new QfqNS.Alert({
                            message: "Upload Failed: Image width and/or height too big.",
                            type: "error",
                            timeout: 5000
                        });
                        alert.show();
                    }
                    if (error.code === 422) {
                        const message = lastServerErrorMessage || "Upload failed.";
                        let alert = new QfqNS.Alert({
                            message: message,
                            type: "error",
                            timeout: 5000
                        });
                        alert.show();
                    }
                    if (error.code === 500) {
                        let alert = new QfqNS.Alert({
                            message: "Upload failed.",
                            type: "error",
                            timeout: 5000
                        });
                        alert.show();
                    }
                    return;
                }
                if (this.dynamicUpdate) {
                    this.form.qfqForm.formUpdateHandler();
                }

                setTimeout(() => {
                    const foundIndicators = this.findFalseIndicators();
                    // Changing style
                    foundIndicators.forEach(indicator => {
                        indicator.style.opacity = 0;
                    });
                }, 1000);
            },
            onremovefile: (error, file) => {
                if (error) {
                    console.error('Error removing file:', error);
                    return;
                }
                if (this.dynamicUpdate) {
                    this.form.qfqForm.formUpdateHandler();
                }
                this.deletedFileId = true; // Reset fileId when a file is removed

                if (this.configuration.fileNote && this.noteInputElement) {
                    this.updateNoteInputState();
                }
            },
            onaddfile: (err, fileItem) => {
                if (err) {
                    console.error('Error adding file:', err);
                    return;
                }


                const file = fileItem.file;
                // Upload is of type image pre check configured max Dimensions (#21337)
                if (file instanceof Blob && file.type && file.type.startsWith('image/')) {
                    // Get Max Dimensions from Config
                    const maxWidth = this.configuration.maxImageWidth;
                    const maxHeight = this.configuration.maxImageHeight;

                    const img = new Image();
                    try {
                        const objectURL = URL.createObjectURL(file);

                        img.onload = () => {

                            if (img.width > maxWidth || img.height > maxHeight) {
                                pond.removeFile(fileItem.id); // Remove the file from FilePond
                                let alert = new QfqNS.Alert({
                                    message: `Image too large. Max: ${maxWidth}x${maxHeight}px, Uploaded: ${img.width}x${img.height}px`,
                                    type: "error",
                                    timeout: 5000
                                });
                                alert.show();
                            }
                            URL.revokeObjectURL(objectURL);

                        };
                        img.onerror = () => {
                            console.error("Could not load image for dimension check.");
                            URL.revokeObjectURL(objectURL);
                        };
                        // Load Image
                        img.src = objectURL;
                    } catch (e) {
                        // When Preloading no information for image check
                    }

                }

                // Wait for the file item to be added to the DOM
                setTimeout(() => {
                    // Access the file item's element using FilePond's internal API
                    const item = pond.getFile(fileItem.id);
                    if (!item || !item.file || !item.id) {
                        console.error('The file item is missing information.');
                        return;
                    }

                    const fileSize = item.file.size;

                    if (!fileSize || isNaN(fileSize)) {
                        // Get file UI element
                        const fileElement = document.querySelector(`#filepond--item-${item.id}`);
                        const sizeInfo = fileElement.querySelector('.filepond--file-info-sub');

                        if (sizeInfo) {
                            sizeInfo.style.display = 'none';
                        }
                    }

                    // If it's a form-element and downloadButton is not given then there is no download button needed.
                    if (this.configuration.allowDownload && (!this.configuration.form || this.configuration.form && this.configuration.downloadButton !== false)) {
                        this.createDownloadButton(fileItem, item);
                    }

                    if (this.configuration.fileNote && this.noteInputElement) {
                        this.updateNoteInputState();
                    }
                }, 100);
            },
            oninit: () => {
                // Change the styling for filePond uploads in form
                const rootElement = pond.element;
                const dropLabel = rootElement.querySelector('.filepond--drop-label');
                const listElement = rootElement.querySelector('.filepond--list');
                if (this.configuration.form) {
                    rootElement.id = this.configuration.formId;
                    if (listElement) {
                        listElement.classList.add('filepond--list-form');
                    }
                }

                if (dropLabel && this.configuration.dropBackground !== undefined) {
                    dropLabel.classList.add('filepond--drop-label-form');
                }

                // Check if the root element has the class 'hidden-input-style'
                if (rootElement.classList.contains('hidden-input') && dropLabel) {
                    dropLabel.style.marginTop = '10px'; // Apply the margin-top style
                }


                if (this.configuration.form && this.configuration.downloadButton === false) {
                    const sizeInfo = rootElement.querySelector('.filepond--file-info-sub');
                    if (sizeInfo !== null) {
                        sizeInfo.style.display = 'none';
                    }
                }
                // Extra Buttons
                if (this.configuration.form) {
                    const filePondFile = rootElement.querySelector('.filepond--file');
                    if (filePondFile) {
                        filePondFile.insertAdjacentHTML(
                            'beforeend',
                            this.configuration.extraButton
                        );
                    }
                }

                if (this.configuration.fileNote && this.noteInputElement) {
                    this.updateNoteInputState();
                }
            }
        });

        this.pond = pond;

        // Append a note field below the FilePond input if enabled
        if (this.configuration.fileNote) {
            const noteWrapper = document.createElement('div');
            noteWrapper.className = 'filepond-note-wrapper';
            noteWrapper.style.display = 'flex';
            noteWrapper.style.alignItems = 'center';
            noteWrapper.style.gap = '10px';
            noteWrapper.style.marginBottom = '7px';

            // Create label
            const noteLabel = document.createElement('label');
            noteLabel.innerText = this.configuration.fileNote;
            noteLabel.style.margin = '0';
            noteLabel.style.minWidth = '100px';
            noteLabel.style.flexShrink = '0';

            // Create input
            const noteInput = document.createElement('input');
            noteInput.type = 'text';
            noteInput.name = this.configuration.fileNoteInputName;
            noteInput.className = 'filepond-note-input';
            noteInput.style.flex = '1';
            noteInput.style.padding = '4px';
            noteInput.style.border = '1px solid #ccc';
            noteInput.style.borderRadius = '4px';
            noteInput.style.boxSizing = 'border-box';
            noteInput.maxLength = 255;

            // Pre-fill value if given
            if (this.configuration.fileNoteText) {
                noteInput.value = this.configuration.fileNoteText;
            }

            noteWrapper.appendChild(noteLabel);
            noteWrapper.appendChild(noteInput);

            // Insert note under FilePond root element
            const pondElement = this.pond.element;
            const parent = pondElement.parentElement;

            parent.insertBefore(noteWrapper, pondElement.nextSibling);

            // access
            this.noteInputElement = noteInput;
            this.noteWrapper = noteWrapper;

            this.updateNoteInputState = () => {
                const fileCount = this.pond.getFiles().length;
                if (!this.noteInputElement) return;
                if (fileCount === 0) {
                    this.noteInputElement.value = '';
                    this.noteInputElement.readOnly = true;
                    this.noteInputElement.classList.add('readonly');
                    this.noteWrapper.style.marginTop = '0'
                } else {
                    this.noteInputElement.readOnly = false;
                    this.noteInputElement.classList.remove('readonly');
                    this.noteWrapper.style.marginTop = '-0.8em'
                }
            };
        }

    };

    // This function will return an array of all processing complete indicators
    // that have a sibling with the revert button processing class.
    n.filePond.prototype.getPreloadedFiles = function () {
        const preloadedData = this.inputElement.getAttribute('data-preloadedFiles');
        const preloadedFiles = preloadedData ? JSON.parse(preloadedData) : [];
        return preloadedFiles.length > 0 ? preloadedFiles.map(file => ({

            source: file.id,
            options: {
                type: 'local',
                file: {
                    name: QfqNS.decodeUniqueFileName(file.pathFileName.split('/').pop()),
                    size: Number(file.fileSize) || 0,
                    type: file.mimeType
                },
                metadata: {
                    poster: file.pathFileName
                }
            }
        })) : [];
    };

    // This function will return an array of all processing complete indicators
    // that have a sibling with the revert button processing class.
    n.filePond.prototype.findFalseIndicators = function () {
        const indicatorsWithRevertSibling = [];

        // Select all processing complete indicators
        const indicators = document.querySelectorAll('.filepond--processing-complete-indicator');

        indicators.forEach(indicator => {
            // Check if the revert button processing class exists as a sibling
            const revertButton = indicator.closest('.filepond--item').querySelector('.filepond--file-action-button.filepond--action-revert-item-processing');
            if (revertButton) {
                indicatorsWithRevertSibling.push(indicator);
            }
        });

        return indicatorsWithRevertSibling;
    };

    n.filePond.prototype.setOnData = function (formData) {
        if (this.lastUploadId) {
            formData.append('groupId', this.lastUploadId);
        }
        // Add your own variables here
        formData.append('pathFileName', this.configuration.pathFileName);
        formData.append('pathDefault', this.configuration.pathDefault);
        formData.append('recordData', this.configuration.recordData);
        if (this.lastUploadId == null) {
            formData.append('groupId', this.configuration.groupId);
        }
        if (this.deletedFileId) {
            formData.append('uploadId', 0);
        } else {
            formData.append('uploadId', this.configuration.uploadId);
        }
        formData.append('table', this.configuration.table);

        // Return the modified FormData object
        return formData;
    };

    n.filePond.prototype.setRevert = function (uniqueFileId, load, error) {
        const formData = new FormData();
        formData.append('uploadId', uniqueFileId);
        formData.append('table', this.configuration.table);

        // The uniqueFileId parameter is the ID returned by the server during the 'process' call
        // This ID can be used to identify and delete the file on the server
        const xhr = new XMLHttpRequest();
        xhr.open('POST', this.apiUrls.upload + `?s=${this.sipValues.delete}`);
        xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xhr.onload = () => {
            if (xhr.status === 200) {
                const response = JSON.parse(xhr.responseText);
                // Update the new fileId from the response, if necessary
                //this.currentFieldId = response.uniqueFileId;
                load();
            } else {
                error('oh no');
            }
        };
        xhr.send(formData);
    };

    n.filePond.prototype.setRemove = function (uniqueFileId, load, error) {
        const formData = new FormData();
        formData.append('uploadId', uniqueFileId);
        formData.append('table', this.configuration.table);

        // The uniqueFileId parameter is the ID returned by the server during the 'process' call
        // This ID can be used to identify and delete the file on the server
        const xhr = new XMLHttpRequest();
        xhr.open('POST', this.apiUrls.upload + `?s=${this.sipValues.delete}`);
        xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xhr.onload = () => {
            if (xhr.status === 200) {
                const response = JSON.parse(xhr.responseText);
                // Update the new fileId from the response, if necessary
                //this.currentFieldId = response.uniqueFileId;
                load();
            } else {
                error('oh no');
            }
        };
        xhr.send(formData);
        this.configuration.downloadButton = '';
    };

    // Trying to load the pictures for preview for preloaded files. Currently not working. Maybe not needed if ImageEditor is implemented.
    n.filePond.prototype.setServerLoad = function (source, load, error, progress, abort, headers) {
        if (this.allowImagePreview) {
            const fetchRequest = new Request(this.apiUrls.download + `?type=preview&fileId=` + source);
            fetch(fetchRequest).then(response => {
                if (response.ok && response.headers.get('Content-Length') > 0) {
                    response.blob().then(blob => {
                        if (blob.size > 0) {
                            load(blob);
                        } else {
                            load('');
                        }
                    });
                } else {
                    load('');
                }
            }).catch(err => {
                // Log the error but don't trigger FilePond's error state
                console.error(err.message);
                load('');
            });
            return {
                abort: () => {
                }
            };
        }

    };

    // Create new downloadButton and append it to existing upload element.
    //
    n.filePond.prototype.createDownloadButton = function (fileItem, item) {
        // Create a download button
        const downloadButton = document.createElement('button');

        // Find the filepond--file-wrapper element for this file item
        const fileElementPanel = this.getFilePondElementPanel(item);

        if (fileElementPanel) {
            let removeButton = fileElementPanel.querySelector('.filepond--file-action-button.filepond--action-remove-item');
            const fileInfo = fileElementPanel.querySelector('.filepond--file-info');

            downloadButton.classList.add('filepond--file-action-button');
            downloadButton.classList.add('filepond--action-download-item');
            downloadButton.type = 'button';
            downloadButton.setAttribute('data-align', 'right');
            downloadButton.style.marginRight = '30px';


            // Create an icon element for the downward arrow
            const icon = document.createElement('i');

            if(this.configuration.glyphicon === ''){
                icon.classList.add('fas', 'fa-arrow-down');
                icon.style.color = 'white';
            } else {
                icon.classList.add('glyphicon', this.configuration.glyphicon);
            }


            // Append the icon to the button
            downloadButton.appendChild(icon);
            // Append download button text if downloadButton config given. Only in case of form-element possible.
            if (this.configuration.downloadButton !== false && this.configuration.downloadButton !== undefined) {
                const downloadButtonText = document.createElement('span');
                downloadButtonText.innerText = this.configuration.downloadButton;
                downloadButton.appendChild(downloadButtonText);
                downloadButton.title = this.configuration.tooltip;
                downloadButton.class = "glyphicon glyphicon-ok"

                downloadButtonText.style.width = 'fit-content';
                downloadButtonText.style.height = 'fit-content';
                downloadButtonText.style.position = 'unset';
                downloadButtonText.style.marginLeft = '5px';
                downloadButtonText.style.marginRight = '5px';
                downloadButtonText.style.clipPath = 'inset(100%)';

                icon.style.marginLeft = '4px';
                downloadButton.style.left = '5px';
                downloadButton.style.marginRight = 'unset';
                downloadButton.style.display = 'flex';
                downloadButton.style.alignItems = 'center';
                downloadButton.style.width = 'auto';
                downloadButton.style.borderRadius = '6px';


                // If download button text was given then don't display file name
                if (downloadButtonText.innerText.length > 0) {
                    fileInfo.style.display = 'none';
                } else {
                    fileInfo.style.paddingLeft = '2em';
                }
            }

            // Append the download button to the filepond--file element
            fileElementPanel.appendChild(downloadButton);
        }

        // Set up the click event listener to trigger the download
        downloadButton.addEventListener('click', () => {
            let sipParameter = this.sipValues.download;
            if (this.lastSipTmp !== false && this.lastSipTmp !== undefined) {
                sipParameter = this.lastSipTmp;
            }

            // Implement the download action here
            const downloadUrl = this.apiUrls.download + `?s=${sipParameter}&sipDownloadKey=${this.configuration.sipDownloadKey}&uploadId=${fileItem.serverId}`;
            window.open(downloadUrl, '_blank');
        });
    };

    // Get fileElement panel to customize.
    //
    n.filePond.prototype.getFilePondElementPanel = function (item) {
        const fileElement = document.querySelector(`#filepond--item-${item.id}`);
        const fileWrapper = fileElement.querySelector(`.filepond--file-wrapper`);
        if (fileWrapper) {
            const fileElementPanel = fileWrapper.querySelector('.filepond--file');
            if (fileElementPanel) {
                return fileElementPanel;
            } else {
                console.error('The filepond--file element was not found.');
            }
        } else {
            console.error('The filepond--file-wrapper element was not found.');
        }
    };

    // Normalize 'null' string values to actual nulls and string 'true' to boolean
    //
    n.filePond.prototype.normalizeConfiguration = function () {
        Object.keys(this.configuration).forEach(key => {
            if (this.configuration[key] === 'null') {
                this.configuration[key] = null;
            } else if (this.configuration[key] === 'true') {
                this.configuration[key] = true;
            } else if (this.configuration[key] === 'false') {
                this.configuration[key] = false;
            }
        });

        if (this.configuration.recordData === undefined) {
            this.configuration.recordData = '';
        }

        this.configuration.activeTypeValidation = this.configuration.accept !== null;
        this.configuration.activeSizeValidation = this.configuration.maxFileSize !== null;

        this.configuration.extensions = this.filterFileTypes(this.configuration.accept, true);
        this.configuration.mimes = this.filterFileTypes(this.configuration.accept);

    };

    n.filePond.prototype.filterFileTypes = function (acceptList, extension = false) {
        const cleanedTypes = acceptList.replace(/\s+/g, '').split(',');
        const mimeTypes = cleanedTypes.filter(type => type.includes('/'));
        const extensions = cleanedTypes.filter(type => !type.includes('/')).map(ext => ext.substring(1));

        if (extension) {
            return extensions;
        } else {
            return mimeTypes;
        }
    }

    n.initializeFilePondInContainer = function (container, form) {
        const inputElements = container.querySelectorAll('input[type="file"].fileupload');
        const inputElementsMulti = container.querySelectorAll('input[type="file"].file-uploadMulti');
        const visibleInputElements = Array.from(inputElements).filter(input => input.offsetParent !== null);

        // visibleInputElements.forEach(inputElement => {
        //     let fileObject = new n.filePond(inputElement);
        //     fileObject.createFilePondObject();


            // Initializes FilePond on each input element event hidden element
        inputElements.forEach(inputElement => {

            if (inputElement.offsetParent === null) {
                // Add class or adjust style for non-visible input elements
                inputElement.classList.add('hidden-input');
            }

                let fileObject = new n.filePond(inputElement, form);
                fileObject.createFilePondObject();


            // Call the form change after file remove
            if (form !== '') {
                fileObject.pond.on('addfile', function (error, file) {

                    // Disable save button from form and change label
                    if (fileObject.saveButton) {
                        if (file.file.size !== undefined && file.file.type !== undefined && file.status === 9) {
                            form.filepondUploadProcessing.push(fileObject);
                            fileObject.saveButton.disabled = true;
                            fileObject.saveButton.classList.remove('btn-info');
                            if (fileObject.saveButtonIcon !== null) {
                                fileObject.saveButtonIcon.classList.remove('glyphicon-ok');
                                fileObject.saveButtonIcon.classList.add('glyphicon-cog');
                            } else {
                                const processLabel = document.createElement('span');
                                processLabel.className = 'tmp-process-btn glyphicon glyphicon-cog';
                                fileObject.saveButton.innerText = '';
                                fileObject.saveButton.appendChild(processLabel);
                            }

                        }
                    }
                });

                fileObject.pond.on('removefile', function (file) {
                    const element = fileObject.pond.element;
                    if (form.filepondUploadProcessing.length === 0) {
                        form.inputAndPasteHandlerCalled = true;
                        form.markChanged(element);
                    }
                });

                fileObject.pond.on('processfile', function (file) {
                    const element = fileObject.pond.element;

                    let index = form.filepondUploadProcessing.indexOf(fileObject);
                    if (index !== -1) {
                        form.filepondUploadProcessing.splice(index, 1);
                    }

                    if (form.filepondUploadProcessing.length === 0) {
                        // reset save button icon after finalized upload
                        if (fileObject.saveButton) {
                            if (fileObject.saveButtonIcon !== null) {
                                fileObject.saveButtonIcon.classList.remove('glyphicon-cog');
                                fileObject.saveButtonIcon.classList.add('glyphicon-ok');
                            } else {
                                fileObject.saveButton.querySelector('.tmp-process-btn').remove();
                                fileObject.saveButton.innerText = fileObject.saveButtonLabel;
                            }

                        }

                        form.inputAndPasteHandlerCalled = true;
                        form.markChanged(element);
                    }
                });
            }
        });

        inputElementsMulti.forEach(inputElement => {

            if (inputElement.offsetParent === null) {
                // Add class or adjust style for non-visible input elements
                inputElement.classList.add('hidden-input');
            }
            let fileObject = new n.filePondFormMulti(inputElement)
            fileObject.createFilePondObjectMulti()

            if (form !== '') {
                fileObject.pond.on('addfile', function (error, file) {

                    // Disable save button from form and change label
                    if (fileObject.saveButton) {
                        if (file.file.size !== undefined && file.file.type !== undefined && file.status === 9) {
                            form.filepondUploadProcessing.push(fileObject);
                            fileObject.saveButton.disabled = true;
                            fileObject.saveButton.classList.remove('btn-info');
                            if (fileObject.saveButtonIcon !== null) {
                                fileObject.saveButtonIcon.classList.remove('glyphicon-ok');
                                fileObject.saveButtonIcon.classList.add('glyphicon-cog');
                            } else {
                                const processLabel = document.createElement('span');
                                processLabel.className = 'tmp-process-btn glyphicon glyphicon-cog';
                                fileObject.saveButton.innerText = '';
                                fileObject.saveButton.appendChild(processLabel);
                            }

                        }
                    }
                });

                fileObject.pond.on('removefile', function (file) {
                    const element = fileObject.pond.element;
                    if (form.filepondUploadProcessing.length === 0) {
                        form.inputAndPasteHandlerCalled = true;
                        form.markChanged(element);
                    }
                });

                fileObject.pond.on('processfile', function (file) {
                    const element = fileObject.pond.element;

                    let index = form.filepondUploadProcessing.indexOf(fileObject);
                    if (index !== -1) {
                        form.filepondUploadProcessing.splice(index, 1);
                    }

                    if (form.filepondUploadProcessing.length === 0) {
                        // reset save button icon after finalized upload
                        if (fileObject.saveButton) {
                            if (fileObject.saveButtonIcon !== null) {
                                fileObject.saveButtonIcon.classList.remove('glyphicon-cog');
                                fileObject.saveButtonIcon.classList.add('glyphicon-ok');
                            } else {
                                fileObject.saveButton.querySelector('.tmp-process-btn').remove();
                                fileObject.saveButton.innerText = fileObject.saveButtonLabel;
                            }

                        }

                        form.inputAndPasteHandlerCalled = true;
                        form.markChanged(element);
                    }
                });
            }


        });
    }

    n.filePondFormMulti = function createFileUploadMulti(inputElement) {
        // Retrieve all needed data and configurations
        this.inputElement = inputElement;
        this.pond = null;

        const configData = inputElement.getAttribute('data-config');
        this.configuration = configData ? JSON.parse(configData) : [];
        this.normalizeConfiguration();

        this.saveButton = false;
        if (this.configuration.form) {
            const formId = this.inputElement.closest('form.qfq-form').id;
            const saveButtonId = 'save-button-' + formId;
            this.saveButton = document.querySelector('#' + saveButtonId);

            // No save button for formModeGlobal=readonly
            if(this.saveButton) {
                this.saveButtonIcon = this.saveButton.querySelector('span');
                this.saveButtonLabel = this.saveButton.innerText;
            }
        }

        const apiUrls = inputElement.getAttribute('data-api-urls');
        this.apiUrls = apiUrls ? JSON.parse(apiUrls) : [];

        const sipValues = inputElement.getAttribute('data-sips');
        this.sipValues = sipValues ? JSON.parse(sipValues) : [];

        //Initialize existing preloaded files
        this.filePondFiles = this.getPreloadedFilesMulti(this.inputElement);

        // Initialize flags
        this.lastUploadId = null;
        this.currentFieldId = false;
        this.deletedFileId = true;
        this.lastSipTmp = false;
    };

    n.filePondFormMulti.prototype.normalizeConfiguration = function () {
        Object.keys(this.configuration).forEach(key => {
            if (this.configuration[key] === 'null') {
                this.configuration[key] = null;
            } else if (this.configuration[key] === 'true') {
                this.configuration[key] = true;
            } else if (this.configuration[key] === 'false') {
                this.configuration[key] = false;
            }
        });

        if (this.configuration.recordData === undefined) {
            this.configuration.recordData = '';
        }

        this.configuration.activeTypeValidation = this.configuration.accept !== null;
        this.configuration.activeSizeValidation = this.configuration.maxFileSize !== null;

        this.configuration.extensions = this.filterFileTypes(this.configuration.accept, true);
        this.configuration.mimes = this.filterFileTypes(this.configuration.accept);

    };

    n.filePondFormMulti.prototype.filterFileTypes = function (acceptList, extension = false) {
        const cleanedTypes = acceptList.replace(/\s+/g, '').split(',');
        const mimeTypes = cleanedTypes.filter(type => type.includes('/'));
        const extensions = cleanedTypes.filter(type => !type.includes('/')).map(ext => ext.substring(1));

        if (extension) {
            return extensions;
        } else {
            return mimeTypes;
        }
    }

    n.filePondFormMulti.prototype.getPreloadedFilesMulti = function (inputElement) {
        const preloadedData = this.inputElement.getAttribute('data-preloadedFiles');
        return preloadedData ? stringToFilePondItemArray(preloadedData) : [];
    }

    const stringToFilePondItemArray = function (str) {

        // Split the string by commas to separate individual objects
        const objectsArray = str.split(",");

        // Trim any extra spaces and split each object by colons
        return objectsArray.map(item => {

            // Remove any leading/trailing whitespace
            const trimmedItem = item.trim();
            // Split the string into parts using ':'
            const parts = trimmedItem.split(":");

            // Map parts to an object with appropriate keys.
            return {
                source: parts[0],
                options: {
                    type: 'local',
                    file: {
                        name: parts[1].split('/').pop(),
                        size: parts[2],
                        mimeType: parts[3]
                    },
                    metadata: {
                        poster: parts[1]
                    }
                },
            };
        });

    }

    n.filePondFormMulti.prototype.createFilePondObjectMulti = function () {
        let lastServerErrorMessage = null;
        const pond = FilePond.create(this.inputElement, {
                allowMultiple: this.configuration.multiUpload,
                allowRemove: this.configuration.deleteOption,
                allowRevert: true,
                maxFileSize: this.configuration.maxFileSize,
                allowFileSizeValidation: this.configuration.activeSizeValidation,
                acceptedFileTypes: this.configuration.accept,
                labelFileProcessingComplete: this.configuration.completeMessage,
                fileValidateTypeDetectType: (source, type) => new Promise((resolve, reject) => {
                    // Get file extension
                    const extension = source.name.split('.').pop().toLowerCase();
                    const mimeType = source.type;

                    // Split the comma-separated string and handle it as an array
                    let acceptedType = null;
                    const acceptedTypes = this.configuration.accept.split(',');

                    // Iterate over the accepted types and find a case-insensitive match
                    acceptedTypes.forEach(accepted => {
                        if (accepted.trim().toLowerCase() === '.' + extension || accepted.trim().toLowerCase() === mimeType.toLowerCase()) {
                            acceptedType = accepted.trim();
                        }
                    });

                    // Check if the file extension or mime type is in the allowed extensions list
                    if (this.configuration.extensions.map(ext => ext.toLowerCase()).includes(extension)) {
                        resolve(acceptedType); // If yes, use the detected type
                    } else if (this.configuration.mimes.includes(mimeType)) {
                        resolve(type); // Direct match found
                    } else {
                        // Check for wildcard MIME type matches if no direct match is found
                        // example image/png split to image/*
                        const mimeTypeBase = mimeType.split('/')[0] + '/*';
                        if (this.configuration.mimes.includes(mimeTypeBase)) {
                            resolve(type); // Wildcard match found
                        } else {
                            reject(type); // No valid MIME type or wildcard match found
                        }
                    }
                }),
                allowFileTypeValidation: this.configuration.activeTypeValidation,
                allowImagePreview: this.configuration.imageEditor,
                allowImageEdit: this.configuration.imageEditor,
                allowDrop: this.configuration.allowUpload,
                allowBrowse: this.configuration.allowUpload,
                allowPaste: this.configuration.allowUpload,
                labelIdle: this.configuration.text,
                maxFiles: this.configuration.maxFiles,
                allowReorder: false,
                imagePreviewMaxHeight: 150,
                styleButtonRemoveItemPosition: 'right',
                credits: false,
                dropValidation: false,
                maxParallelUploads: 1,
                files: this.filePondFiles,
                iconRemove: '<i class="fas fa-trash" style="color: white;"></i>',
                server: {
                    process: {
                        url: this.apiUrls.upload + "?s=" + this.sipValues.upload,
                        method: 'POST',
                        withCredentials: false,
                        headers: {},
                        ondata: (formData) => {
                            return this.setOnData(formData);
                        },
                        onload: (response) => {
                            const res = JSON.parse(response);
                            console.log(res);
                            return res.fileIndex;
                        },
                        onerror: (response) => {
                            // Handle error here
                            console.error('Error during upload:', response);
                            // Get Error Message
                            lastServerErrorMessage = "Upload failed.";
                            try {
                                const parsed = JSON.parse(response);
                                if (parsed && parsed.message) {
                                    lastServerErrorMessage = parsed.message;
                                }
                            } catch (e) {
                                console.warn("Could not parse error response:");
                            }
                        }
                    },
                    revert: (uniqueFileId, load, error) => {
                        this.setRevert(uniqueFileId, load, error);
                    },
                    remove: (uniqueFileId, load, error) => {
                        this.setRemove(uniqueFileId, load, error);
                    },
                    load: (source, load, error, progress, abort, headers) => {
                        console.log('loaded');
                    }
                },
                onprocessfile: (error, fileItem) => {
                    if (error) {
                        console.error('Error processing file:', error);
                        if (error.code === 413) {
                            let alert = new QfqNS.Alert({
                                message: "Upload Failed: Image width and/or height too big.",
                                type: "error",
                                timeout: 5000
                            });
                            alert.show();
                        }
                        if (error.code === 422) {
                            const message = lastServerErrorMessage || "Upload failed.";
                            let alert = new QfqNS.Alert({
                                message: message,
                                type: "error",
                                timeout: 5000
                            });
                            alert.show();
                        }
                        if (error.code === 500) {
                            let alert = new QfqNS.Alert({
                                message: "Upload failed.",
                                type: "error",
                                timeout: 5000
                            });
                            alert.show();
                        }
                        return;
                    }

                    setTimeout(() => {
                        const foundIndicators = this.findFalseIndicators();

                        // Changing style
                        foundIndicators.forEach(indicator => {
                            indicator.style.opacity = 0;
                        });
                    }, 1000);

                },
                onremovefile: (error, file) => {
                    if (error) {
                        console.error('Error removing file:', error);
                        return;
                    }
                    this.deletedFileId = true; // Reset fileId when a file is removed
                },
                onaddfile: (err, fileItem) => {
                    if (err) {
                        console.error('Error adding file:', err);
                        // Wait for the file item to be added to the DOM
                    }

                    const file = fileItem.file;
                    // Upload is of type image pre-check configured max Dimensions (#21337)
                    if (file instanceof Blob && file.type && file.type.startsWith('image/')) {
                        // Get Max Dimensions from Config
                        const maxWidth = this.configuration.maxImageWidth;
                        const maxHeight = this.configuration.maxImageHeight;

                        const img = new Image();
                        try {
                            const objectURL = URL.createObjectURL(file);

                            img.onload = () => {

                                if (img.width > maxWidth || img.height > maxHeight) {
                                    pond.removeFile(fileItem.id); // Remove the file from FilePond
                                    let alert = new QfqNS.Alert({
                                        message: `Image too large. Max: ${maxWidth}x${maxHeight}px, Uploaded: ${img.width}x${img.height}px`,
                                        type: "error",
                                        timeout: 5000
                                    });
                                    alert.show();
                                }
                                URL.revokeObjectURL(objectURL);

                            };
                            img.onerror = () => {
                                console.error("Could not load image for dimension check.");
                                URL.revokeObjectURL(objectURL);
                            };
                            // Load Image
                            img.src = objectURL;
                        } catch (e) {
                            // When Preloading no information for image check
                        }
                    }

                    setTimeout(() => {
                        // Access the file item's element using FilePond's internal API
                        const item = pond.getFile(fileItem.id);
                        if (!item || !item.file || !item.id) {
                            console.error('The file item is missing information.');
                            return;
                        }


                        const fileSize = item.file.size;

                        if (!fileSize || isNaN(fileSize)) {
                            // Get file UI element
                            const fileElement = document.querySelector(`#filepond--item-${item.id}`);
                            const sizeInfo = fileElement.querySelector('.filepond--file-info-sub');

                            if (sizeInfo) {
                                sizeInfo.style.display = 'none';
                            }
                        }

                        // If it's a form-element and downloadButton is not given then there is no download button needed.
                        if (this.configuration.allowDownload && (!this.configuration.form || this.configuration.form && this.configuration.downloadButton !== false)) {
                            this.createDownloadButton(fileItem, item);
                        }
                    }, 200);
                },
                oninit: () => {
                    // Change the styling for filePond uploads in form
                    const rootElement = pond.element;
                    const dropLabel = rootElement.querySelector('.filepond--drop-label');
                    const listElement = rootElement.querySelector('.filepond--list');
                    if (this.configuration.form) {
                        rootElement.id = this.configuration.formId;
                        if (listElement) {
                            listElement.classList.add('filepond--list-form');
                        }
                    }

                    if (dropLabel && this.configuration.dropBackground !== undefined) {
                        dropLabel.classList.add('filepond--drop-label-form');
                    }

                    // Check if the root element has the class 'hidden-input-style'
                    if (rootElement.classList.contains('hidden-input') && dropLabel) {
                        dropLabel.style.marginTop = '10px'; // Apply the margin-top style
                    }


                    if (this.configuration.form && this.configuration.downloadButton === false) {
                        const sizeInfo = rootElement.querySelector('.filepond--file-info-sub');
                        if (sizeInfo !== null) {
                            sizeInfo.style.display = 'none';
                        }
                    }

                    if (rootElement.hasAttribute('data-disabled') && rootElement.getAttribute('data-disabled') === 'disabled') {
                        rootElement.parentElement.style.cursor = 'not-allowed';
                    }
                }
            }
        )

        this.pond = pond;
    }

    n.filePondFormMulti.prototype.setRemove = function (uniqueFileId, load, error) {
        const formData = new FormData();
        formData.append('uploadId', uniqueFileId);
        formData.append('table', this.configuration.table);

        // The uniqueFileId parameter is the ID returned by the server during the 'process' call
        // This ID can be used to identify and delete the file on the server
        const xhr = new XMLHttpRequest();
        xhr.open('POST', this.apiUrls.upload + `?s=${this.sipValues.delete}&fileIndex=${uniqueFileId}`);
        xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xhr.onload = () => {
            if (xhr.status === 200) {
                const response = JSON.parse(xhr.responseText);
                // Update the new fileId from the response, if necessary
                //this.currentFieldId = response.uniqueFileId;
                load();
            } else {
                error('oh no');
            }
        };
        xhr.send(formData);
        this.configuration.downloadButton = '';
    }

    n.filePondFormMulti.prototype.setRevert = function (uniqueFileId, load, error) {
        const formData = new FormData();
        formData.append('uploadId', uniqueFileId);
        formData.append('table', this.configuration.table);

        // The uniqueFileId parameter is the ID returned by the server during the 'process' call
        // This ID can be used to identify and delete the file on the server
        const xhr = new XMLHttpRequest();
        xhr.open('POST', this.apiUrls.upload + `?s=${this.sipValues.delete}&fileIndex=${uniqueFileId}`);
        xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xhr.onload = () => {
            if (xhr.status === 200) {
                const response = JSON.parse(xhr.responseText);
                // Update the new fileId from the response, if necessary
                //this.currentFieldId = response.uniqueFileId;
                load();
            } else {
                error('oh no');
            }
        };
        xhr.send(formData);
    }

    n.filePondFormMulti.prototype.setOnData = function (formData) {
        console.log(formData);

        // Add your own variables here
        formData.append('pathFileName', this.configuration.pathFileName);
        formData.append('pathDefault', this.configuration.pathDefault);
        formData.append('recordData', this.configuration.recordData);
        formData.append('table', this.configuration.table);

        // Return the modified FormData object
        return formData;
    }

    n.filePondFormMulti.prototype.createDownloadButton = function (fileItem, item) {
        // Create a download button
        const downloadButton = document.createElement('button');

        // Find the filepond--file-wrapper element for this file item
        const fileElementPanel = this.getFilePondElementPanel(item);

        if (fileElementPanel) {
            let removeButton = fileElementPanel.querySelector('.filepond--file-action-button.filepond--action-remove-item');
            const fileInfo = fileElementPanel.querySelector('.filepond--file-info');

            downloadButton.classList.add('filepond--file-action-button');
            downloadButton.classList.add('filepond--action-download-item');
            downloadButton.type = 'button';
            downloadButton.setAttribute('data-align', 'right');
            downloadButton.style.marginRight = '30px';

            // Create an icon element for the downward arrow
            const icon = document.createElement('i');

            if(this.configuration.glyphicon === ''){
                icon.classList.add('fas', 'fa-arrow-down');
                icon.style.color = 'white';
            } else {
                icon.classList.add('glyphicon', this.configuration.glyphicon);
            }


            // Append the icon to the button
            downloadButton.appendChild(icon);

            // Append download button text if downloadButton config given. Only in case of form-element possible.
            if (this.configuration.downloadButton !== false && this.configuration.downloadButton !== undefined) {
                const downloadButtonText = document.createElement('span');
                downloadButtonText.innerText = this.configuration.downloadButton;
                downloadButton.appendChild(downloadButtonText);
                downloadButton.title = this.configuration.tooltip;
                downloadButton.class = "glyphicon glyphicon-ok"

                downloadButtonText.style.width = 'fit-content';
                downloadButtonText.style.height = 'fit-content';
                downloadButtonText.style.position = 'unset';
                downloadButtonText.style.marginLeft = '5px';
                downloadButtonText.style.marginRight = '5px';
                downloadButtonText.style.clipPath = 'inset(100%)';

                icon.style.marginLeft = '4px';
                downloadButton.style.left = '5px';
                downloadButton.style.marginRight = 'unset';
                downloadButton.style.display = 'flex';
                downloadButton.style.alignItems = 'center';
                downloadButton.style.width = 'auto';
                downloadButton.style.borderRadius = '6px';

                if (downloadButtonText.innerText.length > 0) {
                    fileInfo.style.display = 'none';
                } else {
                    fileInfo.style.paddingLeft = '2em';
                }
            }

            // Append the download button to the filepond--file element
            fileElementPanel.appendChild(downloadButton);
        }

        // Set up the click event listener to trigger the download
        downloadButton.addEventListener('click', () => {
            let sipParameter = this.sipValues.download;
            if (this.lastSipTmp !== false && this.lastSipTmp !== undefined) {
                sipParameter = this.lastSipTmp;
            }

            // Implement the download action here
            const downloadUrl = this.apiUrls.download + `?s=${sipParameter}&fileIndex=${fileItem.serverId}`;
            window.open(downloadUrl, '_blank');
        });
    };

    n.filePondFormMulti.prototype.getFilePondElementPanel = function (item) {
        const fileElement = document.querySelector(`#filepond--item-${item.id}`);
        const fileWrapper = fileElement.querySelector(`.filepond--file-wrapper`);
        if (fileWrapper) {
            const fileElementPanel = fileWrapper.querySelector('.filepond--file');
            if (fileElementPanel) {
                return fileElementPanel;
            } else {
                console.error('The filepond--file element was not found.');
            }
        } else {
            console.error('The filepond--file-wrapper element was not found.');
        }
    };

    n.filePondFormMulti.prototype.findFalseIndicators = function () {
        const indicatorsWithRevertSibling = [];

        // Select all processing complete indicators
        const indicators = document.querySelectorAll('.filepond--processing-complete-indicator');

        indicators.forEach(indicator => {
            // Check if the revert button processing class exists as a sibling
            const revertButton = indicator.closest('.filepond--item').querySelector('.filepond--file-action-button.filepond--action-revert-item-processing');
            if (revertButton) {
                indicatorsWithRevertSibling.push(indicator);
            }
        });

        return indicatorsWithRevertSibling;
    };

})(QfqNS.Helper);


/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global console */
/* global FullCalendar */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    /**
     * Initializes fullcalendar.js.
     *
     * The Full Calendar configuration has to be provided in the `data-config` attribute as JSON. E.g.
     *
     *      <div class="qfq-calendar" data-config='{ "plugins": [ 'dayGrid' ] }'></textarea>
     *
     * @function
     */

    // Constants
    const CALENDAR_SELECTOR = 'div.qfq-calendar';
    const CONTAINER_SELECTOR = '.qfq-calendar-container';
    const STORAGE_KEY_PREFIX = 'fullcalendar-date-';
    const DEFAULT_POPUP_OPTIONS = 'width=800,height=600,left=100,top=100,noopener,noreferrer';

    /**
     * FullCalendar initialization module
     */
    const CalendarModule = {
        // Store scroll position at module level, initialize with current position
        lastScrollPosition: { x: 0, y: 0 },
        /**
         * Initialize all calendars on the page
         */
        init(preserveScroll = false) {
            if (!this.isFullCalendarLoaded()) {
                console.warn("FullCalendar JavaScript files not loaded.");
                return;
            }

            $(CALENDAR_SELECTOR).each((index, element) => {
                this.initializeCalendar($(element));
            });

            // Restore page scroll position after initialization
            if (preserveScroll) {
                const { x, y } = this.lastScrollPosition;

                requestAnimationFrame(() => {
                    window.scrollTo(x, y);
                    setTimeout(() => {
                        if (window.scrollX !== x || window.scrollY !== y) {
                            window.scrollTo(x, y);
                        }
                    }, 50);
                });
            }
        },

        /**
         * Check if FullCalendar library is loaded
         */
        isFullCalendarLoaded() {
            return typeof FullCalendar !== 'undefined';
        },

        /**
         * Initialize a single calendar element
         */
        initializeCalendar($element) {
            // Destroy existing calendar if already initialized
            if ($element.hasClass('fc')) {
                $element.fullCalendar('destroy');
            }

            const originalConfig = this.parseConfigData($element);
            const config = this.buildConfig($element, originalConfig);
            $element.fullCalendar(config);
        },

        /**
         * Build calendar configuration from element data
         */
        buildConfig($element, originalConfig) {
            const pageId = originalConfig.pageId || 0;

            // Create final config by merging all parts
            const finalConfig = {
                ...originalConfig,
                ...this.getViewConfig(originalConfig, pageId),
                ...this.getSelectionConfig(originalConfig),
                ...this.getTooltipConfig(originalConfig),
                ...this.getEventHandlers(pageId)
            };

            // Remove our internal properties that shouldn't go to FullCalendar
            if ('pageId' in finalConfig) {
                delete finalConfig.pageId;
            }

            return finalConfig;
        },

        /**
         * Parse configuration from data attribute
         */
        parseConfigData($element) {
            const configData = $element.data('config');

            if (!configData) {
                return {};
            }

            if (configData instanceof Object) {
                return configData;
            }

            console.warn(`Invalid 'data-config': ${configData}`);
            return {};
        },

        /**
         * Get view-related configuration
         */
        getViewConfig(config, pageId) {
            const viewConfig = {};

            // Handle localStorage restore for websocket reloads
            if (config.defaultDate === false) {
                const savedDate = this.getSavedDate(pageId);
                if (savedDate) {
                    viewConfig.defaultDate = savedDate;
                }
            }

            return viewConfig;
        },

        /**
         * Get selection-related configuration
         */
        getSelectionConfig(config) {
            const selectionConfig = {};

            // Add selection restriction if specified
            if (config.restrictSelection !== undefined && config.restrictSelection !== false) {
                selectionConfig.selectAllow = (selectInfo) =>
                    selectInfo.start.isSame(selectInfo.end, config.restrictSelection);
            }

            // Add URL forwarding for selections
            if (config.urlForward !== undefined && config.urlForwardMode !== undefined) {
                selectionConfig.select = (start, end) =>
                    this.handleSelection(start, end, config);
            }
            return selectionConfig;
        },

        /**
         * Get tooltip-related configuration
         */
        getTooltipConfig(config) {
            const tooltipConfig = {};

            // Add tooltip if description is specified
            tooltipConfig.eventRender = function(event, element) {
                // Check extendedProps if you're storing description there
                let description = event.description || (event.extendedProps && event.extendedProps.description);

                if (description && description !== false) {
                    $(element).tooltip({
                        title: description,
                        placement: 'top',
                        trigger: 'hover',
                        container: 'body'
                    });
                }
            };

            return tooltipConfig;
        },

        /**
         * Get event handlers
         */
        getEventHandlers(pageId) {
            return {
                viewRender: (view) => {
                    // For month view, save the date representing the month, not view.start
                    if (view.name === 'month') {
                        // Get the 15th of the month being displayed
                        const monthDate = view.start.clone().add(15, 'days');
                        this.saveDate(pageId, monthDate);
                    } else {
                        this.saveDate(pageId, view.start);
                    }
                }
            };
        },

        /**
         * Handle calendar selection with URL forwarding
         */
        handleSelection(start, end, config) {
            const formatDate = (date) => date.format('YYYY-MM-DD HH:mm:ss');
            const isNewWindow = config.urlForwardMode === 'new';

            // Adjust end date for day-level selections (subtract 1 day)
            // This is necessary because FullCalendar treats end as exclusive
            const adjustedEnd = this.isAllDaySelection(start, end) ?
                end.clone().subtract(1, 'day') : end;

            let url = this.buildSelectionUrl(config.urlForward, {
                start: formatDate(start),
                end: formatDate(adjustedEnd)
            });

            if (config.customParams !== undefined && config.customParams.trim() !== '') {
                url += `&${config.customParams}`;
            }

            if (isNewWindow) {
                window.open(url, '_blank', DEFAULT_POPUP_OPTIONS);
            } else {
                window.location.href = url;
            }
        },

        /**
         * Check if selection is all-day (day-level selection)
         */
        isAllDaySelection(start, end) {
            // Check if both times are at midnight (00:00:00)
            return start.format('HH:mm:ss') === '00:00:00' &&
                end.format('HH:mm:ss') === '00:00:00';
        },

        /**
         * Build URL with parameters
         */
        buildSelectionUrl(url, params) {
            const queryParams = Object.entries(params)
                .filter(([_, value]) => value)
                .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
                .join('&');

            return `${url}&${queryParams}`;
        },

        /**
         * Save calendar date to localStorage
         */
        saveDate(pageId, date) {
            const key = STORAGE_KEY_PREFIX + pageId;
            localStorage.setItem(key, date.format('YYYY-MM-DD'));
        },

        /**
         * Get saved date from localStorage
         */
        getSavedDate(pageId) {
            const key = STORAGE_KEY_PREFIX + pageId;
            return localStorage.getItem(key);
        },

        /**
         * Sets up a debounced scroll listener to continuously track scroll position.
         */
        setupScrollListener() {
            let scrollTimeout;
            window.addEventListener('scroll', () => {
                clearTimeout(scrollTimeout);
                scrollTimeout = setTimeout(() => {
                    this.lastScrollPosition = {
                        x: window.scrollX || window.pageXOffset || 0,
                        y: window.scrollY || window.pageYOffset || 0
                    };
                }, 150);
            }, { passive: true });
        },

        /**
         * Setup MutationObserver for dynamic content
         */
        setupObserver() {
            const observer = new MutationObserver((mutations) => {
                if (this.shouldReinitialize(mutations)) {
                    this.init(true);
                }
            });

            // Observe all calendar containers
            document.querySelectorAll(CONTAINER_SELECTOR).forEach(container => {
                observer.observe(container, {
                    childList: true,
                    subtree: true
                });
            });
        },

        /**
         * Check if mutations contain new calendar elements
         */
        shouldReinitialize(mutations) {
            return mutations.some(mutation => {
                if (mutation.type !== 'childList') return false;

                return Array.from(mutation.addedNodes).some(node => {
                    if (node.nodeType !== 1) return false;

                    const $node = $(node);
                    return $node.is(CALENDAR_SELECTOR) ||
                        $node.find(CALENDAR_SELECTOR).length > 0;
                });
            });
        }
    };

    // Initialize on DOM ready
    document.addEventListener('DOMContentLoaded', () => {
        CalendarModule.setupScrollListener();
        CalendarModule.setupObserver();
    });

    // Export to namespace
    n.calendar = (preserveScroll = false) => CalendarModule.init(preserveScroll);

})(QfqNS.Helper);

/**
 * @author Zhoujie li <zhoujie.li@math.uzh.ch>
 * @Date: 13/05/2024
 */

/* global $ */
/* global tinymce */
/* global CodeMirror */

/**
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
var multiFormRowCounter = 0;  // Initialize a counter to keep track of added rows

/**
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    // Event listener for DOMContentLoaded to ensure the DOM is fully loaded before executing the script
    document.addEventListener('DOMContentLoaded', function () {


        // Function to add a new row
        function addRow() {
            const validator = $('form').data('bs.validator');
            const dummyRow = document.querySelector('.table-multi-form tr.dummy-row');
            if (!dummyRow) {
                return; // Exit if no dummy row found
            }

            // Clear any existing TinyMCE instances in the dummy row
            clearTinyMCEInstances(dummyRow);
            // Clear any existing CodeMirror instances in the dummy row
            clearCodeMirrorInstances(dummyRow);

            // Clone the dummy row and update its class
            const newRow = dummyRow.cloneNode(true);
            newRow.classList.remove('dummy-row');

            // Clone Hidden Input Elements (#21312)
            const hiddenInputs = document.querySelectorAll('input[type="hidden"][name$="-0-0"]');
            hiddenInputs.forEach(input => {
                const clone = input.cloneNode(true);
                if (newRow) {
                    newRow.insertBefore(clone, newRow.firstChild);
                }
            });

            // Class record is used for the form validation selector
            newRow.classList.add('record');
            updateFormElementNames(newRow); // Update the form element names with new indices
            multiFormRowCounter++;

            // Append the new row to the table
            const table = document.querySelector('.table-multi-form tbody');
            if (table) {
                table.appendChild(newRow);
            }

            // Initialize form element listeners and CodeMirror for the new row
            initializeFormElementListeners(newRow);
            initializeCodeMirrorForRow(newRow);
            // Initialize TinyMCE for new row using the same config as the rest of QFQ
            QfqNS.Helper.tinyMce();
            attachTinyMCERowListener(newRow);
            // Update the validator so required fields get handled correctly
            validator.update();
        }

        function attachTinyMCERowListener(row) {
            const textareas = row.querySelectorAll('textarea.qfq-tinymce');
            textareas.forEach((textarea) => {
                const editor = tinymce.get(textarea.id);
                if (editor) {
                    editor.on('input', () => setProcessRowCheckbox(row));
                } else {
                    // Editor not yet initialized (existing rows on page load):
                    // wait for TinyMCE to add it, then attach the listener.
                    const handler = function (e) {
                        if (e.editor.id === textarea.id) {
                            e.editor.on('input', () => setProcessRowCheckbox(row));
                            tinymce.off('AddEditor', handler);
                        }
                    };
                    tinymce.on('AddEditor', handler);
                }
            });
        }

        // Function to update the names of form elements in a row
        function updateFormElementNames(row) {
            const formElements = row.querySelectorAll('input, select, textarea');
            formElements.forEach((formElement) => {
                if (formElement.name) {
                    const parts = formElement.name.split('-');
                    if (parts && parts.length === 3) {
                        const newIndex = multiFormRowCounter; // parseInt(parts[2]) + multiFormRowCounter;
                        formElement.name = parts[0] + '-' + parts[1] + '-' + newIndex;
                    }
                }

                // Update the id if the element has the class qfq-tinymce
                if (formElement.id && formElement.classList.contains('qfq-tinymce')) {
                    const idParts = formElement.id.split('-');
                    if (idParts.length > 1) {
                        const lastPart = idParts[idParts.length - 1];
                        // Ensure the last part is a number
                        const lastIndex = parseInt(lastPart);
                        idParts[idParts.length - 1] = lastIndex + multiFormRowCounter + 1;
                        // Add '-tinymce' to the id
                        formElement.id = idParts.join('-')
                    }
                }
            });
        }

        // Function to initialize CodeMirror for a given row
        function initializeCodeMirrorForRow(row) {
            const textareas = row.querySelectorAll('textarea.qfq-codemirror');
            textareas.forEach((textarea) => {
                if (typeof CodeMirror !== 'undefined') {
                    const configData = textarea.dataset.config ? JSON.parse(textarea.dataset.config) : {};
                    const cm = CodeMirror.fromTextArea(textarea, configData);
                    let height = textarea.dataset.height;
                    let width = textarea.dataset.width;

                    // Ensure height and width are valid
                    height = height && parseInt(height) > 0 ? height : 'auto';
                    width = width && parseInt(width) > 0 ? width : (width === 0 ? 'auto' : null);

                    cm.setSize(width, height);

                    // Add change event listener to update the hidden textarea and check the checkbox
                    cm.on('change', function () {
                        textarea.value = cm.getValue();
                        setProcessRowCheckbox(row);
                    });
                }
            });
        }

        // Function to clear existing CodeMirror instances in a row
        function clearCodeMirrorInstances(row) {
            const codeMirrorDivs = row.querySelectorAll('.CodeMirror');
            codeMirrorDivs.forEach(div => {
                const cmInstance = div.CodeMirror;
                if (cmInstance) {
                    cmInstance.toTextArea();
                    cmInstance.setValue(''); // Clear the CodeMirror content
                }
                div.remove();
            });
        }

        // Function to delete a row
        function deleteRow(event) {
            if (event.target.classList.contains('deleteRowBtn') || event.target.closest('.deleteRowBtn')) {
                const rowDelete = event.target.closest('tr');
                const validator = $('form').data('bs.validator');
                if (rowDelete) {
                    // Update the validator so required fields get handled correctly
                    validator.update();
                    rowDelete.remove();
                }
            }
        }

        // Function to initialize existing rows on page load
        function initializeExistingRows() {
            const existingRows = document.querySelectorAll('.table-multi-form tbody tr');
            existingRows.forEach(row => {
                updateFormElementNames(row);
                initializeFormElementListeners(row);
                initializeCodeMirrorForRow(row); // Initialize CodeMirror for existing rows
                attachTinyMCERowListener(row);   // Bridge TinyMCE edits to the row's process-row checkbox
                multiFormRowCounter++;
            });
        }

        function setProcessRowCheckbox (row) {
            //const checkbox = row.querySelector('input[type="checkbox"]');
            const checkbox = row.querySelector('input[name^="_processRow-"]');
            if (checkbox ) {
                checkbox.checked = true;
            }
        }

        // Function to initialize form element listeners in a row
        function initializeFormElementListeners(row) {
            const formElements = row.querySelectorAll('input:not([name^="_processRow-"]), select, textarea');
            formElements.forEach((formElement) => {
                formElement.addEventListener('input', () => setProcessRowCheckbox(row));
            });
        }

        // Function to clear existing TinyMCE instances in a row
        function clearTinyMCEInstances(row) {
            const textareas = row.querySelectorAll('textarea.qfq-tinymce');
            textareas.forEach((textarea) => {
                const editor = tinymce.get(textarea.id);
                if (editor) {
                    editor.remove();
                }
            });
        }

        // Add event listeners for delete row and checkbox change events on the table body
        const tableBody = document.querySelector('.table-multi-form tbody');
        if (tableBody) {
            tableBody.addEventListener('click', deleteRow);
        }

        // Add event listener to the add row button
        let addRowBtn = document.querySelector('button.addRowButton[id^="addRowBtn-qfq-form"]');
        if (addRowBtn) {
            addRowBtn.addEventListener('click', addRow);
        }

        // Initialize existing rows on page load only if the table has the class 'table-multi-form'
        if (document.querySelector('.table-multi-form')) {
            initializeExistingRows();
        }
    });
})(QfqNS.Helper);

/**
 * @author Enis Nuredini <enis.nuredini@math.uzh.ch>
 */

/* global console */
/* global qfqChat */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    let qfqChat = function (chatWindowsElements) {
        let chatInstances = [];
        if (chatWindowsElements !== undefined) {
            for (let i = 0; i < chatWindowsElements.length; i++) {
                if (chatWindowsElements[i].dataset.chatInitialized) continue;
                chatWindowsElements[i].dataset.chatInitialized = 'true';

                let chatInstance = new ChatWindow(chatWindowsElements[i]);
                chatInstances.push(chatInstance);

                if (!chatInstance.chatLoaded) {
                    chatInstance.getMessagesFromServer(chatInstance, 'first');
                }
            }
        }
        return chatInstances;
    }

    class TooltipManager {
        constructor(toolbarObject) {
            this.chatType = toolbarObject.chatType;
            if (this.chatType === 'thread') {
                this.chat = {};
                this.chat.chatMessages = toolbarObject.chatObject.element;
                this.chat.topBtnClicked = toolbarObject.chatObject.topBtnClicked;
                this.chat.isLoadingMessages = null;
                this.chat.openThread = false;
                this.chat.topBtnClicked = false;
                this.chat.connection = toolbarObject.chatObject.connection;
                this.chat.loadToolbarApi = toolbarObject.chatObject.loadToolbarApi;
                this.chat.typeAheadSip = toolbarObject.chatObject.typeAheadSip;
                this.chat.typeAheadUrl = toolbarObject.chatObject.typeAheadUrl;
                this.chat.messageId = toolbarObject.chatObject.messageId;
                this.chat.chatToolbar = null;
                this.chat.firstThreadContainer = null;
            } else {
                this.chat = toolbarObject.chatObject;
            }

            this.toolbarObject = toolbarObject;
            this.messageId = toolbarObject.messageId;
            this.toolbar = toolbarObject.toolbar;
            this.tagAddApiUrl = toolbarObject.config.tagAddApiUrl;
            this.tagDelApiUrl = toolbarObject.config.tagDelApiUrl;
            this.doneApiUrl = toolbarObject.config.doneApiUrl;
            this.tooltips = [];
            this.topBtn = null;
            this.initTooltips();
        }

        initTooltips() {
            const buttons = this.toolbar.querySelectorAll('.chat-toolbar-users-tag-more, .chat-toolbar-my-tag, .chat-toolbar-tag-btn, .chat-toolbar-users-done-more, .chat-toolbar-done-btn');
            buttons.forEach(button => {
                const tooltip = button.nextElementSibling;
                if (tooltip !== null && tooltip.classList.contains('chat-toolbar-popup')) {
                    const popperInstance = this.createPopperInstance(button, tooltip); // Create a Popper instance for each tooltip
                    button.dataset.isTooltipShown = 'false';
                    button.addEventListener('click', (event) => {
                        this.toggleTooltip(tooltip, popperInstance, button);
                        event.stopPropagation();
                    });
                    this.tooltips.push({ button, tooltip, popperInstance });
                }
            });

            const topBtn = this.toolbar.querySelector('.chat-toolbar-top-btn');
            if (topBtn) {
                topBtn.addEventListener('click', (event) => {
                    this.hideAllTooltipsExcept(null);

                    this.chat.topBtnClicked = true;

                    if (this.chatType !== 'thread') {
                        this.chat.loadNextMessages(this.chat);
                    }

                    this.chat.chatMessages.scrollTo({ top: 0, behavior: 'smooth' });
                    this.checkOverflow();

                });
                this.topBtn = topBtn;
            }

            const addTagBtn = this.toolbar.querySelector('.chat-toolbar-tag-btn');
            if (addTagBtn) {
                addTagBtn.addEventListener('click', (event) => {
                    let typeaheadInput = addTagBtn.nextSibling.querySelector('.tt-input');
                    typeaheadInput.focus();

                    typeaheadInput.addEventListener('keydown', function(event) {
                       if (event.key === 'Enter' || event.keyCode === 13){
                           let typeaheadInputBtn = typeaheadInput.parentElement.nextSibling;
                           typeaheadInputBtn.click();
                       }
                    });
                });
            }

            const tagDeleteBtn = this.toolbar.querySelectorAll('.chat-toolbar-tag-popup-delete');
            tagDeleteBtn.forEach(button => {
                if (button) {
                    button.addEventListener('click', (event) => {
                        this.hideAllTooltipsExcept(null);

                        let tagId = button.getAttribute('data-tag-id');
                        let deleteUrl = this.tagDelApiUrl + '&tagValue=' + encodeURIComponent(tagId) + '&cId=' + encodeURIComponent(this.messageId);
                        this.sendRequest(deleteUrl, function(error, response) {
                            if (error) {
                                console.log('Error deleting tag:', error);
                            } else {
                                let config = response['chat-update'].toolbarConfig;
                                let chatRoom = response['chat-update'].chatRoom;
                                this.toolbarObject.initToolbar(config);
                                this.initTooltips();
                                this.checkOverflow();
                                // Live notification published server-side by Chat::setTag — no client publish needed.
                            }
                        }.bind(this));
                    });
                }
            });

            const typeaheadInputBtn = this.toolbar.querySelector('.chat-toolbar-typeahead-btn');
            if (typeaheadInputBtn) {
                typeaheadInputBtn.addEventListener('mousedown', function(event) {
                    event.preventDefault();
                });

                typeaheadInputBtn.addEventListener('click', (event) => {
                    let typeaheadInput = event.target.closest('.chat-toolbar-typeahead').querySelector('.tt-input');
                    let inputId = event.target.closest('.chat-toolbar-typeahead').querySelector('.chat-toolbar-typeahead > input');

                    let tagValue = typeaheadInput.value;

                    // Get id from typeahead if it was selected from user
                    if (inputId !== null) {
                        let typeaheadValue = inputId.value;

                        if (!isNaN(typeaheadValue) && typeaheadValue.trim() !== '') {
                            // typeaheadValue is numeric
                            tagValue = typeaheadValue;
                        }
                    }

                    if (tagValue) {
                        let addTagUrl = this.tagAddApiUrl + '&tagValue=' + encodeURIComponent(tagValue) + '&cId=' + encodeURIComponent(this.messageId);
                        this.sendRequest(addTagUrl, function(error, response) {
                            if (error) {
                                console.log('Error adding tag:', error);
                            } else {
                                if (response['chat-update'] === undefined || response['chat-update'].length === 0) {
                                    return;
                                }

                                let config = response['chat-update'].toolbarConfig;
                                let chatRoom = response['chat-update'].chatRoom;
                                this.toolbarObject.initToolbar(config);
                                this.initTooltips();
                                this.checkOverflow();
                                typeaheadInput.value = '';
                                // Live notification published server-side by Chat::setTag — no client publish needed.
                            }
                        }.bind(this));
                    }
                    this.hideAllTooltipsExcept(null);
                });
            }

            const doneBtn = this.toolbar.querySelector('.chat-toolbar-done-btn');
            if (doneBtn) {
                doneBtn.addEventListener('click', (event) => {
                    this.hideAllTooltipsExcept(null);

                    let tagId = doneBtn.getAttribute('data-tag-id');
                    let doneApiUrl = this.doneApiUrl + '&tagValue=' + encodeURIComponent(tagId) + '&cId=' + encodeURIComponent(this.messageId);
                    this.sendRequest(doneApiUrl, function(error, response) {
                        if (error) {
                            console.log('Error switching done:', error);
                        } else {
                            let result = response['chat-update'].result;
                            let chatRoom = response['chat-update'].chatRoom;

                            if (result === 'noCiD or missing tagValue') {
                                console.log('cId is missing');
                                return;
                            }

                            if (result !== 'deleted') {
                                this.toggleDoneBtn(doneBtn, result);
                            } else {
                                this.toggleDoneBtn(doneBtn, null,true);
                            }
                            // Live notification published server-side by Chat::setDone — no client publish needed.
                        }
                    }.bind(this));
                });

                this.doneBtn = doneBtn;
            }

            this.chat.chatMessages.addEventListener('scroll', () => {
                if (this.chat.openThread) {
                    return;
                }
                if (this.chat.topBtnClicked) {
                    if (this.chat.chatMessages.scrollTop === 0) {
                        this.chat.topBtnClicked = false;
                    }
                    this.checkOverflow();
                    return;
                }
                this.checkScrollPosition();
                this.checkOverflow();
            });

            // Initialize typeahead for tag input
            QfqNS.TypeAhead.install(this.chat.typeAheadUrl);
        }

        toggleDoneBtn(doneBtn, result, active = false) {
            if (!active) {
                doneBtn.setAttribute('data-tag-id', result);
                doneBtn.title = 'Marked as done';
                doneBtn.firstChild.style.setProperty('color', 'black');
                doneBtn.classList.add('chat-toolbar-done-btn-success');
                doneBtn.blur();
            } else {
                doneBtn.setAttribute('data-tag-id', 'false');
                doneBtn.title = 'Undone';
                doneBtn.firstChild.style.setProperty('color', 'black');
                doneBtn.classList.remove('chat-toolbar-done-btn-success');
            }
        }

        // Load next 10 previous messages if scroll on top is reached
        checkScrollPosition() {
            // Check if the user has scrolled to the top
            if (this.chat.chatMessages.scrollTop === 0) {
                if (this.chat.isLoadingMessages) {
                    return;
                }

                if (this.chat.isLoadingMessages !== null) {
                    this.chat.isLoadingMessages = true;
                }

                let oldScrollHeight = this.chat.chatMessages.scrollHeight;

                if (this.chat.isLoadingMessages !== null) {
                    this.chat.loadNextMessages(this.chat);
                }

                setTimeout(() => {
                    let newScrollHeight = this.chat.chatMessages.scrollHeight;
                    this.chat.chatMessages.scrollTop += (newScrollHeight - oldScrollHeight);
                    if (this.chat.isLoadingMessages !== null) {
                        this.chat.isLoadingMessages = false;
                    }
                }, 100);
            }
        }

        // Hide or show the top button
        checkOverflow() {
            setTimeout(() => {
                let nextSibling = this.topBtn.nextSibling;
                if (this.chat.chatMessages.scrollTop > 5) {
                    if (nextSibling !== null) {
                        nextSibling.classList.remove('chat-toolbar-first-element');
                    }
                    this.topBtn.classList.add('chat-toolbar-first-element');
                    this.topBtn.style.display = 'block';
                } else if (this.chat.flagMoreRecords) {
                    if (nextSibling !== null) {
                        nextSibling.classList.remove('chat-toolbar-first-element');
                    }
                    this.topBtn.classList.add('chat-toolbar-first-element');
                    this.topBtn.style.display = 'block';
                } else {
                    if (nextSibling !== null) {
                        nextSibling.classList.add('chat-toolbar-first-element');
                        this.topBtn.classList.remove('chat-toolbar-first-element');
                    }
                    this.topBtn.style.display = 'none';
                }
            }, 200);
        }

        toggleToolbarDisplay() {
            if (this.toolbar.style.visibility === 'visible' || this.toolbar.style.visibility === '') {
                this.toolbar.style.visibility = 'hidden';
            } else {
                this.toolbar.style.visibility = 'visible';
            }
        }

        sendRequest(url, callback) {
            let xhr = new XMLHttpRequest();
            xhr.open("POST", url, true);
            xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");

            xhr.onload = function() {
                if (xhr.status === 200) {
                    let response = JSON.parse(xhr.responseText);
                    callback(null, response);
                } else {
                    console.log('Error in chat saving.');
                    callback(new Error('Error in chat saving'));
                }
            };

            xhr.onerror = function() {
                console.log('Request failed.');
                callback(new Error('Request failed'));
            };

            xhr.send();
        }

        toggleTooltip(tooltip, popperInstance, button) {
            let isShown = button.dataset.isTooltipShown === 'true';
            if (isShown) {
                this.hide(tooltip, popperInstance);
            } else {
                this.hideAllTooltipsExcept(button);
                this.show(tooltip, popperInstance);
            }
            button.dataset.isTooltipShown = !isShown;
        }

        hideAllTooltipsExcept(currentButton) {
            this.tooltips.forEach(({ button, tooltip, popperInstance }) => {
                if (button !== currentButton) {
                    this.hide(tooltip, popperInstance);
                    button.dataset.isTooltipShown = 'false';
                }
            });
        }

        /* Start popup js functions*/
        createPopperInstance(button, tooltip) {
            return Popper.createPopper(button, tooltip, {
                placement: 'bottom',
                modifiers: [
                    {
                        name: 'offset',
                        options: {
                            offset: [0, 8],
                        },
                    },
                ],
            });
        }

        show(tooltip, popperInstance) {
            tooltip.setAttribute('data-show', '');
            popperInstance.setOptions((options) =>
                Object.assign({}, options, {
                    modifiers: options.modifiers.concat([{ name: 'eventListeners', enabled: true }])
                })
            );
            popperInstance.update();
        }

        hide(tooltip, popperInstance) {
            tooltip.removeAttribute('data-show');

            // Clean all input field inside a tooltip -> tag typeahead
            const inputs = tooltip.querySelectorAll('input');
            inputs.forEach(input => {
                input.value = '';
            });

            popperInstance.setOptions((options) =>
                Object.assign({}, options, {
                    modifiers: options.modifiers.concat([{ name: 'eventListeners', enabled: false }])
                })
            );
        }
    }

    class ChatToolbar {
        constructor(chatObject, config, chatType = 'regular') {
            this.chatObject = chatObject;
            if (chatType === 'thread') {
                this.parentElement = chatObject.element;
            } else {
                this.parentElement = chatObject.chatMessages;
            }
            this.chatType = chatType;
            this.config = config;
            this.messageId = config.messageId;

            // In case of first room messsage
            if (this.messageId === 0 && chatType === 'regular') {
                this.messageId = chatObject.messageId;
            }
            this.toolbar = this.createToolbar();
            this.initToolbar(this.config);
            this.toolbarManager = new TooltipManager(this);

            // Close all open popup divs after clicking somewhere in window
            window.addEventListener('click', (event) => {
                if (!this.toolbar.contains(event.target)) {
                    this.toolbarManager.hideAllTooltipsExcept(null);
                }
            });
        }

        createToolbar() {
            let toolbar = this.parentElement.querySelector(':scope > .chat-toolbar');
            if (!toolbar) {
                toolbar = document.createElement('div');
                toolbar.className = 'chat-toolbar';
                if (this.chatType === 'thread') {
                    toolbar.classList.add('chat-toolbar-thread');
                }
                this.parentElement.insertBefore(toolbar, this.parentElement.firstChild);

                let nextSibling = toolbar.nextSibling;
                let clearFloat = document.createElement('div');
                clearFloat.className = 'clearfix';

                if (nextSibling && nextSibling.classList.contains('chat-thread-open-btn')) {
                    nextSibling.after(clearFloat);
                } else {
                    toolbar.after(clearFloat);
                }
            }
            return toolbar;
        }

        initToolbar(config) {
            this.toolbar.innerHTML = '';

            // Create all toolbar elements
            const elements = this.createToolbarElements(config);

            // Filter out popup divs.
            let btnElements = elements.filter(el => el.classList.contains('btn') || el.classList.contains('chat-toolbar-users-tag'));
            btnElements.forEach((el, index) => {
                // Add classes for first and last elements. Skip the first one > top btn. Is dynamically handled.
                if (index === 1) el.classList.add('chat-toolbar-first-element');
                if (index === btnElements.length - 1) el.classList.add('chat-toolbar-last-element');
            });

            // Append elements to the toolbar in order
            elements.forEach((el, index) => {
                this.addElement(el);
            });
        }


        createToolbarElements(config) {
            const elements = [];

            // If optionTag is set to 'my', filter out tags not created by the user
            if (config.optionTag === 'my') {
                let tagsArray = Array.isArray(config.tags) ? config.tags : [config.tags];

                config.cleanedTags = tagsArray.filter(tag =>
                    (tag.pIdCreator == config.pIdCreator || tag.username === config.username) &&
                    tag.pIdCreator !== '' && tag.username !== ''
                );
            } else {
                config.cleanedTags = config.tags;
            }

            // Add top btn if configured
            if (config.topBtn) {
                elements.push(this.createTopBtn());
            }

            // Show more tags button
            if (config.optionTag !== 'off' && config.cleanedTags.length > 3) {
                // Get all array elements from starting index 4.
                let restTags = config.cleanedTags.slice(3);

                let moreTagsObject = this.createMoreTagsButton(restTags, config.pIdCreator);
                elements.push(moreTagsObject.button);
                elements.push(moreTagsObject.popup);
            }

            // Add up to 3 tags
            if (config.optionTag !== 'off') {
                config.cleanedTags.slice(0, 3).forEach(tag => {
                    let tagObject = this.createTag(tag, config.pIdCreator, config.username);

                    elements.push(tagObject.button);
                    if (tagObject.popup !== null) {
                        elements.push(tagObject.popup);
                    }
                });
            }

            // Add tag btn typeahead
            if (config.optionTag !== 'off') {
                let tagBtnObject = this.createAddTagButton();
                elements.push(tagBtnObject.button);
                elements.push(tagBtnObject.popup);
            }

            // Show more done button
            if (config.optionTagDone === 'all' && config.usersDone.length > 3) {
                // Get all array elements from starting index 4.
                let restDones = config.usersDone.slice(3);

                let moreDonesObject = this.createMoreDonesButton(restDones);
                elements.push(moreDonesObject.button);
                elements.push(moreDonesObject.popup);
            }

            // Add up to 3 users done
            if (config.optionTagDone === 'all') {
                let usersDoneArray = Array.isArray(config.usersDone) ? config.usersDone : [config.usersDone];
                usersDoneArray.slice(0, 3).forEach(userDone => {
                    elements.push(this.createUserDoneDiv(userDone));
                });
            }

            if (config.optionTagDone !== 'off') {
                elements.push(this.createDoneButton(config.activeDone));
            }

            return elements;
        }

        addElement(element) {
            this.toolbar.appendChild(element);
        }

        removeElement(selector) {
            const element = this.toolbar.querySelector(selector);
            if (element) {
                this.toolbar.removeChild(element);
            }
        }

        createTopBtn() {
            const button = document.createElement('button');
            button.className = 'chat-toolbar-top-btn btn btn-default';
            button.style.display = 'none';

            let iconElement = document.createElement('i');
            iconElement.className = 'fas fa-arrow-up';
            iconElement.style.setProperty('color', '#000000');
            button.appendChild(iconElement);

            return button;
        }

        createMoreTagsButton(usersTag, pIdCreator) {
            let moreTagsObject = {};

            const button = document.createElement('button');
            button.className = 'chat-toolbar-users-tag-more btn btn-default';
            button.setAttribute("aria-describedby", "tooltip");

            var iconElement = document.createElement('i');
            iconElement.className = 'fas fa-ellipsis-v';
            iconElement.style.setProperty('color', '#000000');
            button.appendChild(iconElement);

            const tagsContainer = document.createElement('div');
            tagsContainer.innerHTML = 'Tags:<hr>';
            usersTag.forEach(userTag => {
                if (userTag.pIdCreator == pIdCreator) {
                    const tagDeleteBtn = this.createDeleteButton(userTag.id, userTag.username + ': ' + userTag.value);
                    tagDeleteBtn.className = tagDeleteBtn.className + ' btn-block';
                    tagDeleteBtn.setAttribute('data-tag-id', userTag.id);
                    tagsContainer.appendChild(tagDeleteBtn);
                } else {
                    const tagText = document.createElement('div');
                    tagText.innerHTML = userTag.username + ': ' + userTag.value;
                    tagsContainer.appendChild(tagText);
                }
            });

            let popupDiv = this.createPopupElement(tagsContainer.outerHTML);

            moreTagsObject.button = button;
            moreTagsObject.popup = popupDiv;

            return moreTagsObject;
        }

        createTag(tag, pIdCreator, username) {
            let tagObject = {};
            let popupDivDelete = null;

            const div = document.createElement('div');
            div.className = 'chat-toolbar-users-tag';
            div.textContent = tag.value.length > 5 ? tag.value.substring(0, 5) + '.' : tag.value;

            if (tag.pIdCreator == pIdCreator) {
                div.title = tag.value;
                div.className = div.className + ' chat-toolbar-my-tag btn alert-info';
                div.setAttribute('data-tag-id', tag.id);

                popupDivDelete = this.createPopupElement('', 'tag', tag.id);
            } else {
                div.title = tag.username + ': ' + tag.value;
            }

            tagObject.button = div;
            tagObject.popup = popupDivDelete;

            return tagObject;
        }

        createAddTagButton() {
            let tagBtnObject = {};
            let popupTypeahead = null;

            const button = document.createElement('button');
            button.className = 'chat-toolbar-tag-btn btn btn-default';
            // ... set up event listener for pop up
            var iconElement = document.createElement('i');
            iconElement.className = 'fas fa-tag';
            iconElement.style.setProperty('color', '#000000');
            button.appendChild(iconElement);

            popupTypeahead = this.createPopupElement('', 'typeahead');

            tagBtnObject.button = button;
            tagBtnObject.popup = popupTypeahead;

            return tagBtnObject;
        }

        createMoreDonesButton(usersDone) {
            let moreDonesObject = {};

            const button = document.createElement('button');
            button.className = 'chat-toolbar-users-done-more btn btn-default';
            button.setAttribute("aria-describedby", "tooltip");

            var iconElement = document.createElement('i');
            iconElement.className = 'fas fa-ellipsis-v';
            iconElement.style.setProperty('color', '#000000');
            button.appendChild(iconElement);

            let usersDoneContent = 'Done:<hr>';
            usersDone.forEach(userDone => {
                let userDoneHtml = userDone.username + '<br>';
                usersDoneContent += userDoneHtml;
            });

            let popupDiv = this.createPopupElement(usersDoneContent);

            moreDonesObject.button = button;
            moreDonesObject.popup = popupDiv;

            return moreDonesObject;
        }

        createPopupElement(content, type = 'text', userTagId = 0) {
            let popupDiv = document.createElement('div');
            popupDiv.className = "chat-toolbar-popup";
            popupDiv.setAttribute("role", "tooltip");

            switch (type) {
                case 'tag':
                    // Create delete button for single tag
                    const button = this.createDeleteButton(userTagId, content);
                    popupDiv.appendChild(button);
                    break;
                case 'typeahead':
                    const divContainer = document.createElement('div');
                    divContainer.className = 'chat-toolbar-typeahead';
                    const typeaheadInput = document.createElement('input');
                    typeaheadInput.style.color = 'black';
                    typeaheadInput.className = 'qfq-typeahead';
                    typeaheadInput.setAttribute('data-typeahead-sip', this.chatObject.typeAheadSip);
                    typeaheadInput.setAttribute('data-typeahead-limit', '10');
                    typeaheadInput.setAttribute('data-typeahead-min-length', '1');

                    const confirmBtn = document.createElement('button');
                    confirmBtn.className = 'chat-toolbar-typeahead-btn btn btn-default';

                    var iconElement = document.createElement('i');
                    iconElement.className = 'fas fa-check';
                    confirmBtn.appendChild(iconElement);

                    divContainer.appendChild(typeaheadInput);
                    divContainer.appendChild(confirmBtn);
                    popupDiv.appendChild(divContainer);
                    break;
                default:
                    popupDiv.innerHTML = content;
            }

            let arrowDiv = document.createElement("div");
            arrowDiv.className = "chat-toolbar-popup-arrow";
            arrowDiv.setAttribute("data-popper-arrow", "");
            popupDiv.appendChild(arrowDiv);

            return popupDiv;
        }

        createDeleteButton(userTagId, textContent = '') {
            const button = document.createElement('button');
            button.className = 'chat-toolbar-tag-popup-delete chat-toolbar-popup-delete-block btn btn-default';
            button.setAttribute('data-tag-id', userTagId);

            if(textContent !== '') {
                button.textContent = textContent;
                // how to add one space between the text content and following child iconElement?
                const space = document.createTextNode(' ');
                button.appendChild(space);
            }

            var iconElement = document.createElement('i');
            iconElement.className = 'far fa-trash-alt';
            button.appendChild(iconElement);

            return button;
        }

        createUserDoneDiv(userDone) {
            const div = document.createElement('div');
            div.className = 'chat-toolbar-users-done chat-toolbar-done-btn-success';
            div.title = userDone.username;
            div.textContent = userDone.username.substring(0, 3) + '.';
            return div;
        }

        createDoneButton(activeDone) {
            const button = document.createElement('button');
            button.className = 'chat-toolbar-done-btn btn btn-default';
            button.setAttribute('data-tag-id', activeDone);

            var iconElement = document.createElement('i');
            iconElement.className = 'fas fa-check';

            if (activeDone) {
                button.title = 'Marked as done';
                iconElement.style.setProperty('color', 'black');
                button.classList.add('chat-toolbar-done-btn-success');
            } else {
                button.title = 'Undone';
                iconElement.style.setProperty('color', 'black');
            }

            button.appendChild(iconElement);
            return button;
        }

        getConfig(messageId, chatRoom = false) {
            let apiRefreshUrl = this.chatObject.loadToolbarApi + '&messageId=' + encodeURIComponent(messageId) + '&chat_room=' + encodeURIComponent(chatRoom);
            this.toolbarManager.sendRequest(apiRefreshUrl, function(error, response) {
                if (error) {
                    console.log('Error fetching toolbar config:', error);
                } else {
                    let config = response['chat-update'];
                    this.initToolbar(config);
                    this.toolbarManager.initTooltips();
                }
            }.bind(this));
        }
    }

    /**
     *
     */
    class ChatWindow {
        constructor(chatWindowElement) {
            this.chatWindow = chatWindowElement;
            this.elementName = this.chatWindow.parentNode.name;
            this.chatSearch = this.chatWindow.querySelector(".chat-search");
            this.searchInput = this.chatWindow.querySelector(".chat-search-input");
            this.searchBtn = this.chatWindow.querySelector(".chat-search-btn");
            this.chatMessages = this.chatWindow.querySelector(".chat-messages");
            this.topBtn = this.chatWindow.querySelector(".chat-top-symbol");
            this.activateSearchBtn = this.chatWindow.querySelector(".chat-search-activate");
            this.chatSpinner = this.chatMessages.querySelector(".chat-loader-container");
            this.chatInputContainer = this.chatWindow.nextElementSibling;
            this.chatInput = this.chatInputContainer.querySelector(".chat-input-field")
            this.chatSendBtn = this.chatInputContainer.querySelector(".chat-submit-button")
            this.chatConfig = this.chatWindow.getAttribute("data-chat-config");

            // All relevant sip urls
            this.websocketUrl = this.chatWindow.getAttribute("data-websocket-url");
            this.messengerToken = this.chatWindow.getAttribute("data-messenger-token") || '';
            this.messengerChannel = this.chatWindow.getAttribute("data-messenger-channel") || '';
            this.messengerGroup = this.chatWindow.getAttribute("data-messenger-group") || 'qfq';
            this.messenger = null;
            this.loadApi = this.chatWindow.getAttribute("data-load-api");
            this.saveApi = this.chatWindow.getAttribute("data-save-api");
            this.loadToolbarApi = this.chatWindow.getAttribute("data-toolbar-load-api");
            this.typeAheadUrl = this.chatWindow.getAttribute("data-typeahead-url");
            this.typeAheadSip = this.chatWindow.getAttribute("data-typeahead-sip");

            this.chatContainer = {};
            this.threadContainer = {};
            this.markedThreads = [];
            this.currentSearchIndex = 0;
            this.searchResults = [];
            this.lastSearchTerm = '';
            this.chatRefresh = false;
            this.chatLoaded = false;
            this.flagMoreRecords = false;
            this.topBtnClicked = false;
            this.isLoadingMessages = false;
            this.openThread = false;
            this.lastThreadBtn = null;
            this.currentThread = null;
            this.firstThreadContainer = null;
            this.messageId = null;
            this.resetDoneOnNewMessage = false;

            this.init();
        }

        init() {
            this.searchBtn.addEventListener("click", (event) => {
                event.preventDefault();
                this.handleSearch();
            });

            this.searchInput.addEventListener("keyup", (event) => {
                if (event.keyCode !== 13 && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
                    event.preventDefault();
                    event.stopPropagation();
                    this.handleSearch();
                }
            });

            this.activateSearchBtn.addEventListener('click', () => {

                if (this.chatSearch.style.display === 'block') {
                    // If visible, start the hiding process
                    this.searchInput.value = '';
                    this.handleSearch();
                    this.chatSearch.style.opacity = '0';
                    this.chatSearch.style.display = 'none';
                    this.handleThreadBtnMarker('remove');
                } else {
                    // If hidden, show the div
                    this.chatSearch.style.display = 'block';
                    this.chatSearch.style.opacity = '1';
                }
            });

            document.addEventListener("visibilitychange", function() {
                if (this.chatRefresh) {
                    this.scrollToBottom();
                    this.chatRefresh = false;
                }
            }.bind(this));

            window.addEventListener("beforeunload", () => {
                if (this.messenger) {
                    this.messenger.close();
                }
            });

            this.chatSendBtn.addEventListener('click', () => {
                let that = this;
                qfqChat.submit(that);
            });

            this.chatMessages.addEventListener('click', function(event) {
                let threadButton = event.target.closest('.chat-thread-btn');
                let plusButton = event.target.closest('.chat-thread-open-btn');
                let button = threadButton;

                if (button === null) {
                    button = plusButton;
                }

                if (button) {
                    event.stopPropagation();

                    this.lastThreadBtn = button;

                    // Get the parent chat-container of the clicked button
                    let chatContainer = button.closest('.chat-container');
                    let threadId = '';

                    if (chatContainer === null) {
                        chatContainer = button.closest('.chat-thread-container');
                        threadId = chatContainer.getAttribute('data-thread-id');
                    } else {
                        threadId = chatContainer.getAttribute('data-message-id');
                    }

                    if (plusButton === null) {
                        button.classList.toggle('chat-thread-btn-clicked');
                    }

                    if (!this.openThread) {
                        this.openThread = true;
                        this.chatInput.setAttribute('data-thread-id', threadId);
                        this.scrollPosition = this.chatMessages.scrollTop;
                        this.currentThread = chatContainer;
                        if (plusButton !== null) {
                            this.chatMessages.classList.toggle('chat-messages-no-overflow');
                            chatContainer.classList.toggle('chat-thread-open');
                            plusButton.firstElementChild.className = 'fas fa-minus';
                        }

                        this.toolbar.toolbarManager.toggleToolbarDisplay();
                    } else {
                        this.chatInput.removeAttribute('data-thread-id');
                        this.openThread = false;
                        if (plusButton !== null) {
                            this.chatMessages.classList.toggle('chat-messages-no-overflow');
                            chatContainer.classList.toggle('chat-thread-open');
                            chatContainer.scrollTop = 0;
                            plusButton.firstElementChild.className = 'fas fa-plus';
                            plusButton.blur()
                        }
                        this.currentThread = null;
                        this.toolbar.toolbarManager.toggleToolbarDisplay();
                    }

                    // Toggle mute on all chat containers except the parent
                    this.chatMessages.querySelectorAll('.chat-container, .chat-thread-container').forEach(function(container) {
                        if (container !== chatContainer) {
                            if (container.classList.contains('chat-thread-container')) {
                                // Hide 'chat-thread-container' elements
                                container.classList.toggle('chat-container-muted');
                            }
                            // Check if the element is a 'chat-container' and its parent is NOT 'chat-thread-container'
                            else if (container.classList.contains('chat-container') && !container.parentElement.classList.contains('chat-thread-container')) {
                                // Hide 'chat-container' elements that are not inside a 'chat-thread-container'
                                container.classList.toggle('chat-container-muted');
                            }
                        }
                    });

                    if (!this.openThread) {
                        this.chatMessages.scrollTop = this.scrollPosition;
                    }
                }
            }.bind(this));

            this.scrollToBottom();

            // Build up nchan-messages connection. The upstream Messenger
            // handles heartbeat + auto-reconnect; we only subscribe to the
            // chat channel and dispatch incoming 'ping' / 'toolbar-ping'
            // notifications into the existing refresh handlers.
            const Nchan = window.NchanMessenger || null;
            if (Nchan && this.websocketUrl && this.messengerChannel && this.messengerToken) {
                let urlBase;
                try {
                    // UrlFactory internally converts https→wss / http→ws.
                    // Pass https/http, not wss/ws, to avoid double-conversion
                    // (wss→ws would cause mixed-content DOMException).
                    let httpUrl = this.websocketUrl
                        .replace(/^wss:/, 'https:')
                        .replace(/^ws:/, 'http:');
                    if (!httpUrl.endsWith('/')) httpUrl += '/';
                    urlBase = new URL(httpUrl);
                } catch (e) {
                    console.error('qfqChat: invalid data-websocket-url', this.websocketUrl, e);
                    return;
                }

                const channels = [this.messengerChannel];
                const urlFactory = new Nchan.UrlFactory(urlBase, this.messengerGroup, {
                    pub: [],        // Chat is listener-only; publishing is done server-side
                    sub: channels
                });
                this.messenger = new Nchan.Messenger(urlFactory, { logger: console });
                this.messenger._channels.sub = [...channels];
                this.messenger._channels.pub = [...channels];
                this.messenger.tokens.add(this.messengerToken);

                this.messenger.subscribe({
                    handleMessage: (msg) => {
                        // Ignore messages that arrive before the initial chat
                        // load is complete — nchan re-delivers the last buffered
                        // message on subscribe, which would trigger a premature
                        // ping-refresh on chatContainers that aren't set up yet.
                        if (!this.chatLoaded) return;

                        const name = msg && msg.name;
                        const payload = (msg && msg.payload) || {};
                        if (name === 'ping') {
                            this.getMessagesFromServer(this, 'ping');

                            if (this.resetDoneOnNewMessage) {
                                this.refreshToolbar(payload.cId, payload.chatRoom);
                            }

                            // Set flag if users tab is not active.
                            if (document.visibilityState !== 'visible') {
                                this.chatRefresh = true;
                            }
                        } else if (name === 'toolbar-ping') {
                            this.refreshToolbar(payload.cId, payload.chatRoom);
                        }
                    }
                }, channels);

                this.messenger.open().then(
                    () => console.log('qfqChat: connected to channel ' + this.messengerChannel),
                    (err) => {
                        // AbortError is expected on page reload — the old page's
                        // beforeunload handler closes the messenger which rejects
                        // the pending open() promise. Auto-reconnect handles it.
                        if (err && err.name === 'AbortError') return;
                        console.error('qfqChat: connection failed:', err);
                    }
                );
            } else if (!this.websocketUrl) {
                console.log('No messenger base URL found. Set websocketUrl in QFQ config if needed.');
            } else if (!this.messengerToken) {
                console.warn('qfqChat: no data-messenger-token on chat window — live updates disabled');
            }
        }

        refreshToolbar(cId, chatRoom = false) {
            if (this.toolbar.messageId == cId && chatRoom) {
                this.toolbar.getConfig(cId, chatRoom);
                this.toolbar.toolbarManager.checkOverflow();
            } else if (this.threadContainer[cId] !== undefined) {
                this.threadContainer[cId].toolbar.getConfig(cId, chatRoom);
                this.threadContainer[cId].toolbar.toolbarManager.checkOverflow();
            }
        }

        // Scroll to the bottom of chat window.
        scrollToBottom() {
            this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
        }

        // Handle search with given criteria.
        handleSearch() {
            let filter = this.searchInput.value;

            if (filter !== this.lastSearchTerm) {
                this.performSearch(filter);
                this.lastSearchTerm = filter;
            }

            // Reset if empty search
            if (filter === '') {
                this.resetFilter(filter);
                this.handleThreadBtnMarker('remove');
            }

            this.scrollToCurrentResult();
        }

        // Execute the search logic.
        performSearch(filter) {
            // Update this.searchResults based on the search
            // Reset this.currentSearchIndex to 0
            var messages = this.chatMessages.querySelectorAll(".chat-message-content");

            function escapeRegExp(string) {
                return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            }

            // Store original HTML content if not done already
            messages.forEach(content => {
                if (!content.getAttribute('data-original-html')) {
                    content.setAttribute('data-original-html', content.innerHTML);
                }
            });

            if (!filter) {
                messages.forEach(content => {
                    content.innerHTML = content.getAttribute('data-original-html');
                });
                return;
            }

            this.handleThreadBtnMarker('remove');

            var searchRegEx = new RegExp(escapeRegExp(filter), "gi");

            // Reset only if the search term has changed
            if (filter !== this.lastSearchTerm) {
                this.resetFilter(filter);
            }

            messages.forEach(function(content) {
                content.innerHTML = content.getAttribute('data-original-html');

                let messageText = content.querySelector(".chat-message-text");
                if (!messageText) {
                    // If .message-text does not exist, create it and move the content into it
                    messageText = document.createElement("div");
                    messageText.className = "chat-message-text";
                    while (content.firstChild && content.firstChild !== content.querySelector('.chat-time')) {
                        messageText.appendChild(content.firstChild);
                    }
                    content.prepend(messageText);
                }

                let originalHTML = messageText.innerHTML;
                let matchFound = false;
                messageText.innerHTML = originalHTML.replace(searchRegEx, function(match) {
                    matchFound = true;
                    this.searchResults.push({ element: content, match: match });
                    return `<span class="chat-highlight">${match}</span>`;
                }.bind(this));

                this.handleThreadBtnMarker('set', matchFound, content);
            }, this);
        }

        // Scroll to currently searched result
        scrollToCurrentResult() {
            if (this.searchResults.length > 0 && this.searchResults[this.currentSearchIndex]) {
                const selectedElement = this.searchResults[this.currentSearchIndex].element;
                const chatMessagesRect = this.chatMessages.getBoundingClientRect();
                const selectedElementRect = selectedElement.getBoundingClientRect();

                // Calculate relative position of the element inside the chatMessages container
                const relativeTop = selectedElementRect.top - chatMessagesRect.top;
                const targetScrollTop = this.chatMessages.scrollTop + relativeTop - (this.chatMessages.clientHeight / 2);

                // Use scrollTo with smooth behavior
                this.chatMessages.scrollTo({ top: targetScrollTop, behavior: 'smooth' });

                this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length;
            }

            this.updateSearchInfo();
        }

        // Load the next 10 messages if scrolling is on top and there exists more records.
        loadNextMessages(that) {
            if (this.chatMessages.scrollTop === 0 && this.flagMoreRecords) {
                this.getMessagesFromServer(that, 'refresh');
            }
        }

        updateSearchInfo() {
            const infoElement = this.chatWindow.querySelector(".chat-search-info");
            if (this.searchResults.length > 0) {
                const currentIndex = this.currentSearchIndex === 0 ? this.searchResults.length : this.currentSearchIndex;
                infoElement.textContent = ` ${currentIndex}/${this.searchResults.length}`;
            } else {
                infoElement.textContent = '';
            }
        }

        // Reset the search filter input
        resetFilter(filter) {
            this.currentSearchIndex = 0;
            this.searchResults = [];
            this.lastSearchTerm = filter;
        }

        // Set up the html for the individual message bubble.
        createNewMessagePlain(key, message, actualChatState, dbColumnNames) {
            let chatConfig = {};
            let bubbleClass = 'chat-left-bubble';
            let username = '';
            let threadBtn = null;

            if (message[dbColumnNames.pIdCreator] == actualChatState.pIdCreator) {
                bubbleClass = 'chat-right-bubble alert-info';
            } else {
                username = message[dbColumnNames.username];
                if (this.thread) {
                    threadBtn = document.createElement('button');
                    threadBtn.className = 'chat-thread-btn';
                    let iconElement = document.createElement('i');
                    iconElement.className = 'fa fa-comment';
                    threadBtn.appendChild(iconElement);
                }
            }

            let timestamp = new Date(message[dbColumnNames.created]);
            let formattedMessageDate = timestamp.toLocaleDateString('de-DE', {
                day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
            });

            let currentTimestamp = new Date();
            let currentDate = currentTimestamp.toISOString().split('T')[0];
            let messageDate = timestamp.toISOString().split('T')[0];
            let formattedDateTime = timestamp.toLocaleDateString('de-DE', {
                day: '2-digit', month: '2-digit', year: 'numeric'
            });
            if (currentDate === messageDate) {
                formattedDateTime = timestamp.toLocaleTimeString('de-DE', {
                    hour: '2-digit', minute: '2-digit'
                });
            }

            chatConfig = {
                bubbleClass: bubbleClass,
                title: formattedMessageDate,
                message: message[dbColumnNames.message],
                chatTime: formattedDateTime,
                username: username
            };

            let chatContainerElement = document.createElement('div');
            chatContainerElement.className = 'chat-container ' + chatConfig.bubbleClass;
            chatContainerElement.title = chatConfig.title;
            chatContainerElement.setAttribute('data-message-id', key);
            if (threadBtn !== null) {
                chatContainerElement.appendChild(threadBtn);
            }

            if (chatConfig.username !== ''){
                let chatMessageUsernameElement = document.createElement('div');
                chatMessageUsernameElement.className = 'chat-message-user';
                chatMessageUsernameElement.textContent = chatConfig.username;

                chatContainerElement.appendChild(chatMessageUsernameElement);

            }

            let chatPointedCornerElement = document.createElement('div');
            chatPointedCornerElement.className = 'chat-pointed-corner';
            chatContainerElement.appendChild(chatPointedCornerElement);

            let chatMessageElement = document.createElement('div');
            chatMessageElement.className = 'chat-message-content';

            let chatMessageTextElement = document.createElement('div');
            chatMessageTextElement.className = 'chat-message-text';
            chatMessageTextElement.textContent = chatConfig.message;
            chatMessageElement.appendChild(chatMessageTextElement);

            let chatMessageTimeElement = document.createElement('span');
            chatMessageTimeElement.className = 'chat-time';
            chatMessageTimeElement.textContent = chatConfig.chatTime;
            chatMessageElement.appendChild(chatMessageTimeElement);

            chatContainerElement.appendChild(chatMessageElement);

            if (!this.chatContainer[key]) {
                this.chatContainer[key] = {};
            }

            this.chatContainer[key].element = chatContainerElement;
            this.chatContainer[key].pIdCreator = message[dbColumnNames.pIdCreator];
            this.chatContainer[key].username = message[dbColumnNames.username];
            this.chatContainer[key].message = chatConfig.message;
        }

        // Create the html chat content with response data elements from server
        // The messages will be automatically ordered and set in right place over the given id as key.
        // This function is very flexible and can manage messages which aren't in right order returned from server.
        // Server always gets a list from currently showing messages which allows us to response only the none existing ones.
        refreshChat(element, chatItems, actualChatState, dbColumnNames, load_mode) {
            let messageIdKey = dbColumnNames.id;
            let threadIdKey = dbColumnNames.cIdThread;
            let lastMessageThreadContainer = null;
            let activeThread = actualChatState.activeThread;

            // Convert chatItems object to an array
            let chatItemsArray = Object.values(chatItems);

            // Sort chatItems by messageId (assuming it's numeric)
            chatItemsArray.sort((a, b) => parseInt(a[messageIdKey], 10) - parseInt(b[messageIdKey], 10));

            let messageElement = element.querySelector('.chat-messages');
            let noMessageBanner = element.querySelector('.chat-no-message');

            // Hide noMessageBanner if new messages exist and it is visible
            if (chatItemsArray.length > 0 && noMessageBanner && noMessageBanner.style.display !== 'none') {
                noMessageBanner.style.display = 'none';
            }

            // Insert each new message at the correct position
            chatItemsArray.forEach(chatItem => {
                let isThread = false;
                let isThreadContainerNew = false;
                let threadId = chatItem[threadIdKey];
                let messageId = parseInt(chatItem[messageIdKey], 10);
                let toolbarConfig = null;

                if (chatItem.toolbarConfig !== undefined && chatItem.toolbarConfig !== null) {
                    toolbarConfig = chatItem.toolbarConfig;
                    toolbarConfig = JSON.parse(chatItem.toolbarConfig);
                }

                if (!this.chatContainer[messageId]) {
                    // Create new chat message element
                    this.createNewMessagePlain(messageId, chatItem, actualChatState, dbColumnNames);
                    this.chatContainer[messageId].threadId = threadId;

                    // Create thread container if it doesn't exist and threadId exists.
                    if (threadId !== 0) {
                        isThread = true;
                        if (this.threadContainer[threadId] === undefined) {
                            let muted = false;

                            if (this.openThread && load_mode === 'ping') {
                                muted = true;
                            }
                            this.createThreadContainer(threadId, toolbarConfig, muted);
                            toolbarConfig = null;
                        } else if (toolbarConfig !== null) {
                            // In case thread was already created but not the first one delivered with toolbar configuration
                            if (load_mode !== 'ping') {
                                let currentHeight = this.threadContainer[threadId].element.offsetHeight;
                                let newHeight = currentHeight + 22;
                                this.threadContainer[threadId].element.style.height = newHeight + 'px';
                            }

                            this.initThreadContainer(threadId, toolbarConfig);
                            toolbarConfig = null;
                        }

                        lastMessageThreadContainer = this.threadContainer[threadId].element;
                    } else {
                        lastMessageThreadContainer = null;
                    }
                }

                // Now check if the first message with messageId equal current threadId was created.
                if (this.threadContainer[threadId] !== undefined && !this.threadContainer[threadId].messageIds.includes(threadId)) {
                    this.createFirstThreadMessage(threadId);
                    isThreadContainerNew = true;
                } else if (threadId !== 0) {
                    this.threadContainer[threadId].messageIds.push(messageId);
                }

                let chatContainerElement = this.chatContainer[messageId].element;

                // Only append if it's not already in the DOM
                if (!messageElement.contains(chatContainerElement)) {

                    // if a new thread container is needed then add thread container to the chat room.
                    if (isThreadContainerNew) {
                        this.addThreadToChat(messageElement, threadId, activeThread);
                    }

                    this.addNewMessage(messageElement, chatContainerElement, isThread, threadId, messageId);

                    if (toolbarConfig !== null) {
                        this.toolbar = new ChatToolbar(this, toolbarConfig);
                        this.toolbar.toolbarManager.checkOverflow();
                    }
                }
            });

            if (load_mode !== 'refresh' && load_mode !== 'ping') {
                if (this.openThread) {
                    // Fresh started thread isn't set as currentThread.
                    if (this.currentThread.classList.contains('chat-container')) {
                        this.currentThread = this.currentThread.parentElement;
                    }
                    messageElement = this.currentThread;
                }
                if (messageElement !== null) {
                    messageElement.scrollTo({ top: messageElement.scrollHeight, behavior: 'smooth' });
                }
            }
        }

        // Load message data from server with given chat configuration.
        // Result is returned as json. - ping
        getMessagesFromServer(that, load_mode) {
            // Server request over load.php only if spinner exists // place holder !== null
            if ((that.chatSpinner === undefined || that.chatSpinner === null) && load_mode === 'first') {
                return;
            }

            // Get the set of existing message ids to prevent them loading on serverside - performance increase
            let messageIds = Array.from(that.chatWindow.querySelectorAll('.chat-messages div[data-message-id]')).map(el => parseInt(el.getAttribute('data-message-id'), 10));
            let messageIdList = messageIds.join(',');

            messageIdList = qfqChat.htmlEncode(messageIdList);
            let serializedForm = `load_mode=${load_mode}&messageIdList=${encodeURIComponent(messageIdList)}`;

            // In case user started with empty chat and another client wrote a new message
            if (load_mode === 'ping' && that.messageId === null) {
                serializedForm = serializedForm + `&flagFirstMsg=true`;
            }

            // Loading spinner only if page loads the first time
            if (load_mode === 'first') {
                that.chatSpinner.style.display = 'block';
            }

            // Create an XMLHttpRequest to send the data
            let xhr = new XMLHttpRequest();
            xhr.open("POST", that.loadApi, true);
            xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");

            xhr.onload = function() {
                if (xhr.status === 200) {
                    let response = JSON.parse(xhr.responseText);
                    let chatItems =  response['chat-update'].chat;
                    let dbColumnNames = response['chat-update'].dbColumnNames;
                    let toolbarConfig = JSON.parse(response['chat-update'].toolbarConfig);

                    // Save chat room messageId (head) and active thread flag
                    if (load_mode === 'first' || load_mode === 'ping' && that.messageId === null) {
                        that.messageId = response['chat-update'].firstMsgId;
                        that.thread = response['chat-update'].thread;
                        that.resetDoneOnNewMessage = response['chat-update'].optionTagDoneResetOnNewMessage;
                    }

                    // Latest message data from server
                    let actualState = that.getActualStateInfo(response);

                    that.refreshChat(that.chatWindow, chatItems, actualState, dbColumnNames, load_mode);

                    // Initialize Toolbar config for chat room
                    if ((load_mode === 'first' || that.chatSpinner === null) && toolbarConfig !== null) {
                        that.toolbar = new ChatToolbar(that, toolbarConfig);
                        that.toolbar.toolbarManager.checkOverflow();

                        if (that.threadContainer[that.messageId] !== undefined) {
                            that.firstThreadContainer = that.threadContainer[that.messageId];
                            that.threadContainer[that.messageId].toolbar.toolbarManager.chat.chatToolbar = that.toolbar;
                        }
                    }

                    if (that.chatSpinner !== null) {
                        that.chatSpinner.style.display = 'none';
                    }
                    console.log('Chat successfully load.');
                    that.chatLoaded = true;
                } else {
                    that.chatSpinner.style.display = 'none';
                    console.log('Error in chat loading.');
                }
            };

            xhr.onerror = function() {
                console.log('Request failed.');
            };

            xhr.send(serializedForm);
        }

        // Server information data from latest message.
        getActualStateInfo(response) {
            let actualState = [];
            actualState.xId = response['chat-update'].xId;
            actualState.pIdCreator = response['chat-update'].pIdCreator;
            if (response['chat-update'].flagMoreRecords !== null) {
                this.flagMoreRecords = response['chat-update'].flagMoreRecords;
            }
            actualState.activeThread = response['chat-update'].activeThread;

            return actualState;
        }

        createThreadContainer(threadId, toolbarConfig, muted = false) {
            this.threadContainer[threadId] = {};
            this.threadContainer[threadId].messageIds = [];
            let threadDiv = document.createElement('div');
            threadDiv.className = 'chat-thread-container';
            threadDiv.setAttribute('data-thread-id', threadId);

            if (muted) {
                threadDiv.classList.add('chat-container-muted');
            }

            this.threadContainer[threadId].element = threadDiv;
            this.threadContainer[threadId].topBtnClicked = false;
            this.threadContainer[threadId].toolbar = null;

            if (toolbarConfig !== null) {
                this.initThreadContainer(threadId, toolbarConfig);
            }
        }

        initThreadContainer(threadId, toolbarConfig) {
            this.threadContainer[threadId].messenger = this.messenger;
            this.threadContainer[threadId].messengerChannel = this.messengerChannel;
            this.threadContainer[threadId].loadToolbarApi = this.loadToolbarApi;
            this.threadContainer[threadId].typeAheadSip = this.typeAheadSip;
            this.threadContainer[threadId].typeAheadUrl = this.typeAheadUrl;
            this.threadContainer[threadId].messageId = this.messageId;
            this.threadContainer[threadId].toolbar = new ChatToolbar(this.threadContainer[threadId], toolbarConfig, 'thread');
            this.threadContainer[threadId].toolbar.toolbarManager.checkOverflow();
        }

        createFirstThreadMessage(threadId) {
            if (!this.chatContainer[threadId] || !this.threadContainer[threadId]) {
                return;
            }
            this.threadContainer[threadId].element.appendChild(this.chatContainer[threadId].element);
            this.threadContainer[threadId].firstElementHeight = this.chatContainer[threadId].element.offsetHeight + 10;
            this.threadContainer[threadId].messageIds.push(threadId);
        }

        addNewMessage(messageElement, chatContainerElement, isThread, threadId, messageId) {
            // Add new message to thread container or to chat room itself
            if (isThread) {
                let threadContainer = this.threadContainer[threadId].element;
                let insertAtIndex = Array.from(threadContainer.children).findIndex(el => parseInt(el.getAttribute('data-message-id'), 10) > messageId);

                if (insertAtIndex !== -1) {
                    threadContainer.children[insertAtIndex].before(chatContainerElement);
                } else {
                    threadContainer.appendChild(chatContainerElement);
                }

            } else {
                let insertAtIndex = Array.from(messageElement.children).findIndex(el => {
                    const elId = parseInt(el.getAttribute('data-message-id') || el.getAttribute('data-thread-id'), 10);
                    return elId > messageId;
                });

                if (this.openThread) {
                    chatContainerElement.classList.add('chat-container-muted');
                }

                if (insertAtIndex !== -1) {
                    messageElement.children[insertAtIndex].before(chatContainerElement);
                } else {
                    messageElement.appendChild(chatContainerElement);
                }
            }
        }

        addThreadToChat(messageElement, threadId, activeThread) {
            if (!this.chatContainer[threadId] || !this.threadContainer[threadId]) {
                return;
            }

            let firstMessageIndex = Array.from(messageElement.children).findIndex(el => {
                const elId = parseInt(el.getAttribute('data-message-id') || el.getAttribute('data-thread-id'), 10);
                return elId > threadId;
            });

            // Remove first message if it already exists and thread container is missing.
            let firstMessageElement = this.chatContainer[threadId].element;
            if (messageElement.contains(firstMessageElement)) {
                messageElement.removeChild(firstMessageElement);
            }

            // Create open button for thread
            let button = document.createElement('button');
            let iconElement = document.createElement('i');
            button.className = 'chat-thread-open-btn btn btn-default';
            this.threadContainer[threadId].element.insertBefore(button, this.threadContainer[threadId].element.firstChild);

            // Insert the new thread container at the place where the first message was deleted.
            if (firstMessageIndex === -1) {
                messageElement.appendChild(this.threadContainer[threadId].element);
            } else {
                messageElement.children[firstMessageIndex].before(this.threadContainer[threadId].element);
            }

            let offsetHeight = this.chatContainer[threadId].element.offsetHeight + 30;

            if (this.threadContainer[threadId].toolbar !== null) {
                offsetHeight = offsetHeight + 24;
            }

            if (!activeThread) {
                this.threadContainer[threadId].element.style.height = offsetHeight + 'px';
                iconElement.className = 'fas fa-plus';
            } else {
                iconElement.className = 'fas fa-minus';
                if (!this.threadContainer[threadId].element.style.height) {
                    this.threadContainer[threadId].element.style.height = offsetHeight + 'px';
                }
                this.threadContainer[threadId].element.classList.add('chat-thread-open');
                this.chatMessages.classList.toggle('chat-messages-no-overflow');
            }
            button.appendChild(iconElement);

            // Check if typeahead was initialized. In case that typeahead element is created over another user it will not be initialized if chat was already open.
            QfqNS.TypeAhead.install(this.typeAheadUrl);
            this.threadContainer[threadId].created = true;
        }

        handleThreadBtnMarker(mode, matchFound = false, content = null) {
            if (mode === 'set' && matchFound) {
                // Check if the parent's parent has the class 'chat-thread-container'
                let grandparent = content.parentElement ? content.parentElement.parentElement : null;
                if (grandparent && grandparent.classList.contains('chat-thread-container')) {
                    // Find the button with class 'chat-thread-open-btn' and add a new class
                    let openThreadButton = grandparent.querySelector('.chat-thread-open-btn');
                    if (openThreadButton) {
                        openThreadButton.classList.add('chat-thread-btn-marker');
                        this.markedThreads.push(openThreadButton);
                    }
                }
            }

            if (mode === 'remove') {
                if (this.markedThreads.length > 0) {
                    this.markedThreads.forEach(threadBtn => {
                        threadBtn.classList.remove('chat-thread-btn-marker');

                    });

                    this.markedThreads = this.markedThreads.filter(threadBtn => {
                        return threadBtn && threadBtn.classList.contains('chat-thread-btn-marker');
                    });
                }
            }
        }
    }

    // Set the new input state which comes over dynamic update. The only feature that is dependent on the form functionality.
    qfqChat.setInputState = function (element, configItem) {
        let inputContainer = element.nextElementSibling;
        let inputElement = inputContainer.querySelector(".chat-input-field");
        inputElement.disabled = configItem.disabled;
        inputElement.required = configItem.required;
    }

    // Get the disabled required properties from input element. Can be useful on server side.
    qfqChat.getInputState = function (inputElement) {
        let elementMode = {};
        elementMode.required = inputElement.required;
        elementMode.disabled = inputElement.disabled;

        return JSON.stringify(elementMode);
    }

    // Handle chat message submit.
    qfqChat.submit = function(that) {
        let chatInput = that.chatInput.value;
        chatInput = chatInput.trim();
        chatInput = this.htmlEncode(chatInput);
        let inputState = this.getInputState(that.chatInput);
        let cId = that.messageId;
        let chatRoom = true;

        // Get thread id from input field if exists. Is append if user uses the thread btn to answer.
        let threadId = that.chatInput.getAttribute('data-thread-id');

        // Check if the current submitted message is the first one
        let flagFirstMsg = false;

        if (threadId !== null) {
            chatRoom = false;
            if (that.threadContainer[threadId] === undefined) {
                flagFirstMsg = true;
            }
        } else if (Object.keys(that.chatContainer).length === 0) {
            flagFirstMsg = true;
        }

        // Build client information for server actions.
        let serializedForm = `message=${encodeURIComponent(chatInput)}&chatRoomMsgId=${that.messageId}&element_mode=${inputState}&flagFirstMsg=${flagFirstMsg}`;
        if (threadId !== null) {
            serializedForm = `threadId=${threadId}&` + serializedForm;
            cId = threadId;
        }

        // saveApi has already a sip delivered inside.
        let submitUrl = that.saveApi;

        if (chatInput === ''){
            return;
        }

        // Create an XMLHttpRequest to send the data
        let xhr = new XMLHttpRequest();
        xhr.open("POST", submitUrl, true);
        xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");

        xhr.onload = function() {
            if (xhr.status === 200) {
                if (that.lastThreadBtn !== null) {
                    // Remove the visible comment button after first thread answer was sent. Instead of this the plus button is showed.
                    that.lastThreadBtn.classList.remove('chat-thread-btn-clicked');
                }

                let response = JSON.parse(xhr.responseText);
                let chatItems =  response['chat-update'].chat;
                let dbColumnNames = response['chat-update'].dbColumnNames;
                let messageId = response['chat-update'].messageId;
                if (that.messageId === null && messageId !== null) {
                    that.messageId = messageId;
                    that.thread = response['chat-update'].thread;
                    that.resetDoneOnNewMessage = response['chat-update'].optionTagDoneResetOnNewMessage;
                }

                // Get most recent/latest message data. This equals the highest response message id
                let actualState = that.getActualStateInfo(response);

                // Create chat content
                that.refreshChat(that.chatWindow, chatItems, actualState, dbColumnNames, 'save');

                // Clear input field
                that.chatInput.value = '';

                // Reset done btn
                if (that.resetDoneOnNewMessage) {
                    that.refreshToolbar(cId, chatRoom);
                }

                // Live notification published server-side by Chat::saveChatMessage — no client publish needed.
                console.log('Chat successfully saved.');
            } else {
                console.log('Error in chat saving.');
            }
        };

        xhr.onerror = function() {
            console.log('Request failed.');
        };

        xhr.send(serializedForm);
    };

    // Encode message input to prevent JS scripting attacks.
    qfqChat.htmlEncode = function(str) {
        let div = document.createElement('div');
        div.textContent = str;
        return div.innerHTML;
    }

    n.qfqChat = qfqChat;

})(QfqNS.Helper);

/**
 * QFQ Clipboard Helper
 * Provides clipboard operations with Figma-style HTML embedding for multi-format support
 *
 * @author Enis Nuredini <enis.nuredini@math.uzh.ch>
 */

/* global console */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    /**
     * QfqClipboard Class
     * Handles clipboard operations with support for multiple formats
     */
    class QfqClipboard {
        constructor(options) {
            options = options || {};
            this.metaName = options.metaName || 'qfq-data';
            this.validator = options.validator || this.defaultValidator;
        }

        /**
         * Default validator for QFQ data
         */
        defaultValidator(data) {
            if (!data || typeof data !== 'object') {
                return false;
            }
            return data.__qfq === true || (data.version && data.records);
        }

        /**
         * Set custom validator function
         * @param {Function} validatorFn - Function that takes data and returns boolean
         */
        setValidator(validatorFn) {
            this.validator = validatorFn;
        }

        /**
         * Copy data to clipboard in multiple formats (Figma-style)
         * - text/html: Contains hidden data + visible table for Word/Excel
         * - text/plain: JSON string for fallback
         *
         * @param {object} data - Data object to copy
         * @param {object} options - Optional settings
         * @param {Function} options.htmlBuilder - Custom HTML builder function
         * @param {boolean|number|string} options.pretty - Pretty print JSON (true = 2 spaces, number = custom spaces, string = custom indent like '\t')
         * @param {boolean} options.plainOnly - If true, only copy plain text JSON (no HTML)
         * @returns {Promise<void>}
         */
        async copy(data, options) {
            options = options || {};

            // Determine indentation for JSON
            var indent = null;
            if (options.pretty === true) {
                indent = 2;
            } else if (typeof options.pretty === 'number' || typeof options.pretty === 'string') {
                indent = options.pretty;
            }

            // JSON for plain text (with optional pretty print)
            var jsonString = JSON.stringify(data, null, indent);

            // Plain only mode - skip HTML
            if (options.plainOnly) {
                await this.copyText(jsonString);
                return;
            }

            // Encode data as base64 (always compact for HTML embedding)
            var jsonStringCompact = JSON.stringify(data);
            var base64 = btoa(unescape(encodeURIComponent(jsonStringCompact)));

            // Build HTML with hidden data + visible content
            var html;
            if (options.htmlBuilder) {
                html = options.htmlBuilder(data, base64, this.metaName);
            } else {
                html = this.buildDefaultHtml(data, base64);
            }

            // Try modern Clipboard API with multiple formats
            if (navigator.clipboard && navigator.clipboard.write) {
                try {
                    var clipboardItem = new ClipboardItem({
                        'text/html': new Blob([html], { type: 'text/html' }),
                        'text/plain': new Blob([jsonString], { type: 'text/plain' })
                    });
                    await navigator.clipboard.write([clipboardItem]);
                    return;
                } catch (err) {
                    console.warn('ClipboardItem write failed, falling back:', err.message);
                }
            }

            // Fallback: writeText (only plain text)
            if (navigator.clipboard && navigator.clipboard.writeText) {
                await navigator.clipboard.writeText(jsonString);
                return;
            }

            // Legacy fallback for older browsers
            this.legacyCopy(jsonString);
        }

        /**
         * Copy plain text to clipboard
         *
         * @param {string} text - Text to copy
         * @returns {Promise<void>}
         */
        async copyText(text) {
            if (navigator.clipboard && navigator.clipboard.writeText) {
                await navigator.clipboard.writeText(text);
                return;
            }

            this.legacyCopy(text);
        }

        /**
         * Legacy copy fallback using execCommand
         *
         * @param {string} text - Text to copy
         */
        legacyCopy(text) {
            var textarea = document.createElement('textarea');
            textarea.value = text;
            textarea.style.position = 'fixed';
            textarea.style.opacity = '0';
            document.body.appendChild(textarea);
            textarea.select();

            try {
                document.execCommand('copy');
            } catch (err) {
                throw new Error('Failed to copy to clipboard');
            } finally {
                document.body.removeChild(textarea);
            }
        }

        /**
         * Read and validate QFQ data from clipboard
         * Tries HTML format first (with embedded data), then plain text JSON
         *
         * @returns {Promise<object|null>} Parsed data or null if invalid
         */
        async read() {
            // Try modern Clipboard API with read()
            if (navigator.clipboard && navigator.clipboard.read) {
                try {
                    var items = await navigator.clipboard.read();

                    for (var i = 0; i < items.length; i++) {
                        var item = items[i];

                        // Try HTML first (Figma-style embedded data)
                        if (item.types.includes('text/html')) {
                            var htmlBlob = await item.getType('text/html');
                            var html = await htmlBlob.text();
                            var qfqData = this.extractFromHtml(html);
                            if (qfqData) {
                                return qfqData;
                            }
                        }

                        // Fallback: Try plain text JSON
                        if (item.types.includes('text/plain')) {
                            var textBlob = await item.getType('text/plain');
                            var text = await textBlob.text();
                            var jsonData = this.parseJson(text);
                            if (jsonData) {
                                return jsonData;
                            }
                        }
                    }
                } catch (err) {
                    console.warn('Clipboard read() failed:', err.message);
                }
            }

            // Fallback: readText()
            if (navigator.clipboard && navigator.clipboard.readText) {
                try {
                    var plainText = await navigator.clipboard.readText();
                    return this.parseJson(plainText);
                } catch (err) {
                    console.warn('Clipboard readText() failed:', err.message);
                }
            }

            // Last resort: prompt
            var userInput = prompt('Please paste the JSON data:');
            return userInput ? this.parseJson(userInput) : null;
        }

        /**
         * Read plain text from clipboard
         *
         * @returns {Promise<string|null>}
         */
        async readText() {
            if (navigator.clipboard && navigator.clipboard.readText) {
                try {
                    return await navigator.clipboard.readText();
                } catch (err) {
                    console.warn('Clipboard readText() failed:', err.message);
                }
            }

            return prompt('Please paste the text:');
        }

        /**
         * Check if clipboard contains valid QFQ data
         * Note: May fail due to permission restrictions outside user interaction
         * Does NOT use prompt fallback - only checks if API is available
         *
         * @returns {Promise<boolean>}
         */
        async hasValidData() {
            // Only check if modern Clipboard API is available
            if (!navigator.clipboard) {
                return true; // Assume valid if we can't check
            }

            try {
                // Try read() first (for HTML data)
                if (navigator.clipboard.read) {
                    var items = await navigator.clipboard.read();

                    for (var i = 0; i < items.length; i++) {
                        var item = items[i];

                        if (item.types.includes('text/html')) {
                            var htmlBlob = await item.getType('text/html');
                            var html = await htmlBlob.text();
                            var qfqData = this.extractFromHtml(html);
                            if (qfqData) {
                                return true;
                            }
                        }

                        if (item.types.includes('text/plain')) {
                            var textBlob = await item.getType('text/plain');
                            var text = await textBlob.text();
                            var jsonData = this.parseJson(text);
                            if (jsonData) {
                                return true;
                            }
                        }
                    }
                    return false;
                }

                // Fallback to readText()
                if (navigator.clipboard.readText) {
                    var plainText = await navigator.clipboard.readText();
                    return this.parseJson(plainText) !== null;
                }

                return true; // Assume valid if we can't check
            } catch (err) {
                console.warn('Clipboard validation failed:', err.message);
                return true; // Assume valid if we can't check
            }
        }

        /**
         * Extract QFQ data from HTML (Figma-style)
         *
         * @param {string} html - HTML string
         * @returns {object|null} Parsed data or null
         */
        extractFromHtml(html) {
            // Look for qfq-data meta tag
            var regex = new RegExp('name="' + this.metaName + '"\\s+content="([^"]+)"');
            var match = html.match(regex);
            if (!match) {
                return null;
            }

            try {
                // Decode base64 and parse JSON
                var json = decodeURIComponent(escape(atob(match[1])));
                var data = JSON.parse(json);

                // Validate it's QFQ data
                if (this.validator(data)) {
                    return data;
                }
            } catch (e) {
                console.warn('Failed to extract data from HTML:', e.message);
            }

            return null;
        }

        /**
         * Parse and validate QFQ JSON from plain text
         *
         * @param {string} text - JSON string
         * @returns {object|null} Parsed data or null
         */
        parseJson(text) {
            if (!text || !text.trim()) {
                return null;
            }

            try {
                var data = JSON.parse(text);
                if (this.validator(data)) {
                    return data;
                }
            } catch (e) {
                // Not valid JSON
            }

            return null;
        }

        /**
         * Build default HTML with embedded data and table
         *
         * @param {object} data - Data object
         * @param {string} base64 - Base64 encoded JSON
         * @returns {string} HTML string
         */
        buildDefaultHtml(data, base64) {
            var records = data.records || [];
            var tableHtml = '';

            if (records.length > 0) {
                var firstRecord = records[0].data || records[0];
                var columns = Object.keys(firstRecord);

                var headerRow = columns.map(function(col) {
                    return '<th>' + this.escapeHtml(col) + '</th>';
                }, this).join('');

                var dataRows = records.map(function(record) {
                    var rowData = record.data || record;
                    return '<tr>' + columns.map(function(col) {
                        var value = rowData[col] !== undefined && rowData[col] !== null ? rowData[col] : '';
                        return '<td>' + this.escapeHtml(String(value)) + '</td>';
                    }, this).join('') + '</tr>';
                }, this).join('');

                tableHtml = '<table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;">' +
                    '<thead><tr>' + headerRow + '</tr></thead>' +
                    '<tbody>' + dataRows + '</tbody>' +
                    '</table>';
            }

            return '<!DOCTYPE html>' +
                '<html>' +
                '<head>' +
                '<meta charset="utf-8">' +
                '<meta name="' + this.metaName + '" content="' + base64 + '">' +
                '</head>' +
                '<body>' +
                tableHtml +
                '<p><small>QFQ Export - v' + (data.version || '1.0') + ' - ' + records.length + ' record(s)</small></p>' +
                '</body>' +
                '</html>';
        }

        /**
         * Escape HTML special characters
         *
         * @param {string} text - Text to escape
         * @returns {string} Escaped text
         */
        escapeHtml(text) {
            var div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }
    }

    // Export class and default instance
    n.Helper.QfqClipboard = QfqClipboard;
    n.Helper.qfqClipboard = new QfqClipboard();

})(QfqNS);
/**
 * @author Enis Nuredini <enis.nuredini@math.uzh.ch>
 */

/* global console */
/* global qfqChat */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    let qfqHistory = function (historyBtnElement, n) {
        if (historyBtnElement !== undefined) {
            new HistoryView(historyBtnElement, n);
        }
    }

    class HistoryView {
        constructor(historyBtnElement, n) {
            this.modalTitle = historyBtnElement.getAttribute("data-history-title");

            // Get existing history view template
            let template = document.getElementsByClassName('qfq-history-view')[0];
            if (template === undefined) {
                template = this.createModalTemplate();
                document.body.appendChild(template);
            }

            this.historyView = template;
            this.historyViewBody = this.historyView.getElementsByClassName('modal-body')[0];
            this.historyBtn = historyBtnElement;
            this.baseUrl = historyBtnElement.getAttribute("data-base-url");
            this.restCallUrl = this.baseUrl + 'typo3conf/ext/qfq/Classes/Api/load.php?s=';
            this.dataSipRules = historyBtnElement.getAttribute("data-sip");
            this.tablesorterViewJson = historyBtnElement.getAttribute("data-tablesorter-view-json");
            this.formElements = {};
            this.formId = null;
            this.header = null;
            this.rows = {};
            this.takeBtn = null;
            this.data = null;
            this.formLabelData = null;
            this.formNames = null;
            this.form = n.form;
            this.table = null;
            this.checkboxes = [];
            this.tablesorterController = new n.TablesorterController();

            this.initHistoryBtn();
        }

        initHistoryBtn() {
            let that = this;
            this.historyBtn.addEventListener('click', function () {
                that.initView();
                setTimeout(function () {
                    that.initCheckboxes();
                }, 500);
            });
        }

        // Initialize the basic history view
        async initView() {
            let url = this.restCallUrl + this.dataSipRules;
            // Create a new html content for the modal window
            this.historyViewBody.innerHTML = 'Loading...';
            $(this.historyView).modal('show');

            let data = await this.makeApiCall(url);
            // extract formLabelData and formNames from data
            this.formLabelData = data.formLabelData;
            this.formNames = data.formNames;
            this.formId = data.formId;

            // Separate the formLabelData, formNames and formId from the data
            delete data.formLabelData;
            delete data.formNames;
            delete data.formId;
            this.data = data;

            // Map the formLabelData to keep order
            this.formLabelData = new Map(this.formLabelData);

            // Put historyHtml in to the modal window
            this.historyViewBody.innerHTML = this.buildHistoryView();
            this.table = $(this.historyViewBody).find('table');
            this.tablesorterController.setup($(this.historyViewBody).find('table'));
            this.takeBtn = this.buildTakeBtn();
            this.historyViewBody.appendChild(this.takeBtn);
            this.initTakeBtn(this);
            this.initShowMoreTextBtn();
        }

        initCheckboxes() {
            // Get all main checkboxes
            let mainCheckboxes = this.historyView.getElementsByClassName('main-checkbox');

            // Get all other checkboxes
            this.checkboxes = this.historyView.getElementsByClassName('checkbox');
            let checkboxes = this.checkboxes;

            // Add event listener to each main checkbox
            Array.from(mainCheckboxes).forEach(cb => {
                cb.addEventListener('click', function () {
                    let created = cb.getAttribute('data-created');
                    Array.from(checkboxes).forEach(c => {
                        const td = c.parentNode;
                        if (c.getAttribute('data-created') === created && td.clientWidth !== 0) {
                            c.checked = cb.checked;
                        } else if (cb.checked === true) {
                            c.checked = false;
                        }
                    });

                    // Uncheck all the other main checkboxes
                    Array.from(mainCheckboxes).forEach(c => {
                        if (c.getAttribute('data-created') !== created) {
                            c.checked = false;
                        }
                    });
                });
            });

            // Add event listener to other checkboxes.
            // If one is checked then the others with same data-name should be unchecked
            Array.from(checkboxes).forEach(cb => {
                cb.addEventListener('click', function () {
                    let name = cb.getAttribute('data-name');
                    let created = cb.getAttribute('data-created');
                    Array.from(checkboxes).forEach(c => {
                        if (c.getAttribute('data-name') === name && c.getAttribute('data-created') !== created) {
                            c.checked = false;
                        }
                    });
                });
            });

        }

        initTakeBtn(that) {
            this.takeBtn.addEventListener('click', function () {
                let data = {};
                let output = '';

                Array.from(that.checkboxes).forEach(cb => {
                    let name = cb.getAttribute('data-name');

                    if (cb.checked) {
                        data[name] = cb.value;
                    }
                });

                output = that.buildSelectedOutput(data);


                if (confirm(output)) {

                    console.log("Checked data: ", data);

                    that.setFormValues(data);
                    that.form.inputAndPasteHandler(that.form);
                    // Close the modal
                    $(that.historyView).modal('hide');
                }
            });
        }

        initShowMoreTextBtn() {
            // Get all more text buttons
            let moreTextBtns = this.historyView.getElementsByClassName('btn-link');

            // Add event listener to each more text button
            Array.from(moreTextBtns).forEach(btn => {
                btn.addEventListener('click', function () {
                    // Get the previous sibling span element
                    const span = btn.previousElementSibling;
                    if (span.style.display === 'block') {
                        span.style.display = 'none';
                        btn.textContent = '[...]';
                    } else {
                        span.style.display = 'block';
                        btn.textContent = '[<<]';
                    }

                });
            });
        }

        async makeApiCall(url) {
            const response = await fetch(url, {method: 'POST'});
            let data = null;
            if (!response.ok) {
                throw new Error();
            } else {
                data = await response.json();
            }

            return data['record-history-data'];
        }

        buildHistoryView() {
            let data = this.data;
            let formLabelData = this.formLabelData;
            let dataObj = data;
            let html = '';
            let tablesorterConfig = this.getTableSorterConfigJson();
            let tablesorterView = JSON.parse(this.tablesorterViewJson);
            let tablesorterHtml = tablesorterView.data;
            html += '<div class="table-responsive"><table id="qfq-history-' + this.formId + '" ' + tablesorterHtml + ' data-tablesorter-config=\'' + tablesorterConfig + '\' class="table table-bordered table-hover qfq-table-50 tablesorter tablesorter-filter tablesorter-pager tablesorter-column-selector table-condensed">';

            let actualFormValues = this.getCurrentFormValues();
            actualFormValues = this.reorderArray(formLabelData, actualFormValues);
            let tablesorterHeader = this.buildTablesorterHeader(formLabelData, actualFormValues);
            let tablesorterBody = this.buildTablesorterBody(dataObj);
            html += tablesorterHeader;
            html += tablesorterBody;
            html += '</table></div>';

            return html;
        }

        // formLabelData is an array of label and name: [label1 => name1, label2 => name2, label3 => name3]
        // build header with that array
        buildTablesorterHeader(formLabelData, actualFormValues) {
            let header = '<thead class="qfq-sticky">';
            let headerRow = '<tr>';

            // Summary checkbox position
            headerRow += '<th class="filter-false sorter-false"></th>';
            headerRow += '<th>Created</th>';
            headerRow += '<th>feUser</th>';

            // Add the name as data-label attribute to the cell inside the iterating formLabelData
            formLabelData.forEach((label, name) => {
                if (label === '') {
                    label = '(' + name + ')';
                }
                headerRow += '<th data-name="' + name + '">' + label + '</th>';
            });

            headerRow += '</tr>';
            header += headerRow;

            header += this.buildFirstRow(actualFormValues);
            header += '</thead>';

            return header;
        }

        // dataObj is an array of arrays: [created => [formdata => [value = test1, label = label1], feUser => username1], created2 => [formdata => [value = test2, label = label2], feUser => username2]]
        // build row with that array. first cell is created date. second cell is feUser value. Rest of the cells are formdata values
        buildTablesorterBody(dataObj) {
            let body = '<tbody>';
            let rows = '';
            let that = this;

            Object.entries(dataObj).forEach(([created, row], index, array) => {
                let feUser = row.feUser;
                let formdata = row.formData;
                let unchangedData = row.unchangedData;

                let rowHtml = '<tr>';
                // Add a checkbox in the first cell
                rowHtml += '<td><input type="checkbox" class="main-checkbox" data-created="' + created + '"></td>';
                rowHtml += '<td>' + created + '</td>';
                rowHtml += '<td>' + feUser + '</td>';

                // Add a checkbox in each cell at the end of the cell
                Object.entries(formdata).forEach(([key, fd], fdIndex, fdArray) => {
                    let plainText = that.stripTags(fd.value);
                    let strippedText = that.truncateWithMoreText(plainText);
                    let changedData = fd.name in unchangedData  ? false : true;
                    let tooltip = '';

                    if (strippedText === '') {

                        // If data is unchanged by another form, use empty string
                        strippedText = changedData ? '<span style="font-style: italic;">empty</span>' : '';
                        tooltip = changedData ? '' : ' title="Value of this field was not changed."';

                    } else if (strippedText === '*hide in log*') {

                        // If data is hidden in log, use 'hidden in log'
                        strippedText = changedData ? '<span style="font-style: italic;" title="Value of this field is excluded from the log.">hidden in log</span>' : '';

                        // No checkbox will be used
                        changedData = false;
                    }

                    // Only use checkbox if data has been changed
                    let checkbox = changedData ? that.buildCheckbox(created, fd.name, fd.value) : '';

                    // Compare with next row's value if it exists
                    let comparisonHtml = '';
                    if (index < array.length - 1) {
                        let nextRow = array[index + 1][1];
                        let nextFd = nextRow.formData[key];
                        if (nextFd && fd.value !== nextFd.value && changedData) {
                            strippedText = '<strong>' + strippedText + '</strong>';
                        }

                    } else {
                        strippedText = '<strong>' + strippedText + '</strong>';
                    }

                    rowHtml += '<td' + tooltip + '><div style="display: flex; align-items:flex-start; gap: 5px">' + checkbox + ' ' + strippedText + '</div></td>';
                });

                rowHtml += '</tr>';
                rows += rowHtml;
            });

            body += rows;
            body += '</tbody>';
            return body;
        }

        buildCheckbox(created, name, value) {
            let checkbox = '';
            checkbox += '<input type="checkbox" class="checkbox" data-name="' + name + '" data-created="' + created + '" value="' + value + '">';

            return checkbox;
        }

        buildFirstRow(actualFormValues) {
            let that = this;
            let row = '<tr class="static-row">';
            // Add a checkbox in the first cell
            row += '<th class="filter-false sorter-false"></th>';
            row += '<th class="filter-false sorter-false">Current</th>';
            row += '<th class="filter-false sorter-false"></th>';
            actualFormValues.forEach((value, key) => {
                let plainText = that.stripTags(value);
                let strippedText = that.truncateWithMoreText(plainText);
                if (strippedText === '') {
                    strippedText = '<span style="font-style: italic;">empty</span>';
                }
                row += '<th class="filter-false sorter-false">' + strippedText + '</th>';
            });
            row += '</tr>';

            return row;
        }

        buildTakeBtn() {
            let takeBtn = document.createElement('button');
            takeBtn.setAttribute('id', 'take-btn');
            takeBtn.setAttribute('class', 'btn btn-default');
            takeBtn.innerHTML = 'Restore selected values';
            return takeBtn;
        }


        getCurrentFormValues() {
            // Get all elements of the form which has name given from array formNames
            let form = this.form.$form[0];
            let formNames = this.formNames;
            let formValues = [];
            // iterate over formNames
            Object.values(formNames).forEach(name => {
                let formElement = form.querySelector('[name="' + name + '"]');
                let type = 'input';
                this.formElements[name] = {};

                if (formElement !== null) {
                    let value = '';
                    // Check for type radio
                    if (formElement.getAttribute('type') === 'radio') {
                        formElement = form.querySelector(`[name="${name}"]:checked`);
                        if (formElement === null) {
                            formElement = form.querySelector(`[name="${name}"]`);
                        }
                        type = 'radio';
                    }

                    // Check for codemirror
                    if (formElement.classList.contains('qfq-codemirror')) {
                        type = 'codemirror';
                    }

                    // Check for tinyMce
                    if (formElement.classList.contains('qfq-tinymce')) {
                        type = 'tinymce';
                    }

                    // Single checkbox and typeahead handling. Usually hidden input
                    if (formElement.getAttribute('type') === 'hidden') {

                        // Different between checkbox and typeahead
                        // Get the parent div element and check for first child with class twitter-typeahead
                        const parent = formElement.parentNode;
                        const typeahead = parent.querySelector('.twitter-typeahead');

                        if (typeahead !== null && parent.classList.contains('qfq-form-body') === false) {
                            type = 'typeahead';
                            value = formElement.value;
                        } else {
                            // Get the other element with the same name. Exclude the hidden input (Checkbox)
                            const formElementNew = form.querySelector(`[name="${name}"]:not([type="hidden"])`);

                            if (formElementNew !== null) {
                                type = 'checkbox';

                                if (formElementNew.checked === false) {
                                    value = formElement.value;
                                } else {
                                    value = formElementNew.value;
                                }
                                formElement = formElementNew;
                            } else {
                                // uploads are handled here
                                value = formElement.value;
                            }
                        }
                    } else {
                        value = formElement.value;
                    }

                    this.formElements[name].element = formElement;
                    this.formElements[name].value = value;
                    this.formElements[name].type = type;
                    formValues.push({name: name, value: value});
                } else {
                    // In case of checkboxes
                    formElement = form.querySelectorAll(`[name="${name}[]"]`);
                    if (formElement !== null) {
                        let values = [];
                        formElement.forEach(el => {
                            // Get checkmark element
                            const checkmark = el.nextElementSibling;
                            if (el.checked === true) {
                                values.push(el.value);
                            }
                        });

                        this.formElements[name].element = formElement;
                        this.formElements[name].value = values;
                        this.formElements[name].type = 'checkbox';

                        formValues.push({name: name, value: values});
                    }
                }
            });

            return formValues;
        }

        reorderArray(referenceArray, arrayToReorder) {
            const result = new Map();
            const placeholder = '';

            // Convert arrayToReorder to a Map for easier lookup
            const arrayToReorderMap = new Map(
                arrayToReorder.map(item => [item.name, item.value])
            );

            // Process all keys from the reference array (Map)
            for (const [key, value] of referenceArray) {
                if (arrayToReorderMap.has(key)) {
                    result.set(key, arrayToReorderMap.get(key));
                } else {
                    result.set(key, placeholder);
                }
            }

            return result;
        }

        setFormValues(data) {
            let that = this;
            Object.entries(data).forEach(([name, value]) => {
                if (this.formElements[name]) {
                    const {element, type} = this.formElements[name];

                    switch (type) {
                        case 'radio':
                            const radioButton = element;
                            if (radioButton) {
                                radioButton.Attributes.remove('checked');

                                // Get the radio button with the same name and value
                                const parent = radioButton.closest('div');
                                const radioButtonNew = parent.querySelector(`input[name="${name}"][value="${value}"]`);

                                // Get parent label element
                                const label = radioButtonNew.closest('label');
                                console.log('label: ', label);
                                label.click();
                                radioButtonNew.checked = true;
                            }
                            break;

                        case 'checkbox':
                            if (element instanceof NodeList) {
                                element.forEach(checkbox => {
                                    if (Array.isArray(value)) {
                                        checkbox.checked = value.includes(checkbox.value);
                                    } else if (typeof value === 'string') {
                                        checkbox.checked = value.split(',').includes(checkbox.value);
                                    } else {
                                        checkbox.checked = value == checkbox.value;
                                    }
                                });
                            } else {
                                element.checked = value === element.value;
                            }
                            break;

                        case 'codemirror':
                            if (element) {
                                // Select next sibling element with class CodeMirror
                                const codemirror = element.nextElementSibling.CodeMirror;
                                element.value = value;
                                codemirror.setValue(value);
                            }
                            break;

                        case 'tinymce':
                            if (tinymce && tinymce.get(element.id)) {
                                tinymce.get(element.id).setContent(value);
                            }
                            break;

                        default:
                            element.value = value;
                    }


                    this.formElements[name].value = value;
                }
            });

        }

        makeTdTextBold(checkbox) {
            // Get the parent td element
            const td = checkbox.closest('td');

            const textNode = td.childNodes[0];

            if (textNode && textNode.nodeType === Node.TEXT_NODE) {
                // Create a new strong element
                const strongElement = document.createElement('strong');

                // Set the text content of the strong element
                strongElement.textContent = textNode.textContent.trim();

                // Replace the text node with the strong element
                td.insertBefore(strongElement, textNode);
                td.removeChild(textNode);
            }
        }

        resetBoldTdText(checkboxes) {
            checkboxes.forEach(checkbox => {
                // Get the parent td element
                const td = checkbox.closest('td');

                const strongElement = td.querySelector('strong');

                if (strongElement) {
                    // Create a new text node
                    const textNode = document.createTextNode(strongElement.textContent);

                    // Replace the strong element with the text node
                    td.insertBefore(textNode, strongElement);
                    td.removeChild(strongElement);
                }
            });
        }

        getTableSorterConfigJson() {
            let tablesorterConfig = {
                theme: "bootstrap",
                widthFixed: true,
                headerTemplate: "{content} {icon}",
                dateFormat: "ddmmyyyy",
                widgets: ["uitheme", "filter", "columnSelector", "output"],
                widgetOptions: {
                    filter_columnFilters: true,
                    filter_reset: ".reset",
                    filter_cssFilter: "form-control",
                    filter_saveFilters: true,
                    columnSelector_mediaquery: false,
                    output_delivery: "download",
                    output_saveFileName: "tableExport.csv",
                    output_separator: ";"
                }
            };

            return JSON.stringify(tablesorterConfig);
        }

        truncateWithMoreText(input, maxlen = 30, plaintext = false) {
            if (typeof input !== 'string') {
                return input;
            }

            if (maxlen < 1) {
                maxlen = 1;
            }

            if (input.length > maxlen) {
                const visibleText = input.slice(0, maxlen);
                const hiddenText = input.slice(maxlen);
                if (plaintext) {
                    return `${visibleText}...`;
                } else {
                    return `${visibleText}<span class="qfq-more-text">${hiddenText}</span><button class="btn btn-link" type="button" style="outline-width: 0px;">[...]</button>`;
                }
            } else {
                return input;
            }
        }

        stripTags(input, allowed) {
            if (typeof input !== 'string') {
                return input;
            }
            allowed = (((allowed || "") + "").toLowerCase().match(/<[a-z][a-z0-9]*>/g) || []).join('');
            const tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi;
            return input.replace(tags, function ($0, $1) {
                return allowed.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : '';
            });
        }

        // Create modal template for the history view
        createModalTemplate() {
            let modal = document.createElement('div');
            modal.setAttribute('class', 'modal fade qfq-history-view');
            modal.setAttribute('role', 'dialog');

            let modalDialog = document.createElement('div');
            modalDialog.setAttribute('class', 'modal-dialog');

            let modalContent = document.createElement('div');
            modalContent.setAttribute('class', 'modal-content');

            let modalHeader = document.createElement('div');
            modalHeader.setAttribute('class', 'modal-header');

            let modalTitle = document.createElement('h2');
            modalTitle.setAttribute('class', 'modal-title');
            modalTitle.style.display = 'contents';
            let titleContent = '';
            if (this.modalTitle) {
                titleContent = 'History: ' + this.modalTitle;
            } else {
                titleContent = 'History';
            }
            modalTitle.textContent = titleContent;

            let modalCloseBtn = document.createElement('button');
            modalCloseBtn.setAttribute('type', 'button');
            modalCloseBtn.setAttribute('class', 'close');
            modalCloseBtn.setAttribute('data-dismiss', 'modal');

            let modalCloseBtnSpan = document.createElement('span');
            modalCloseBtnSpan.setAttribute('aria-hidden', 'true');
            modalCloseBtnSpan.innerHTML = '&times;';

            let modalBody = document.createElement('div');
            modalBody.setAttribute('class', 'modal-body');

            modalCloseBtn.appendChild(modalCloseBtnSpan);
            modalHeader.appendChild(modalTitle);
            modalHeader.appendChild(modalCloseBtn);
            modalContent.appendChild(modalHeader);
            modalContent.appendChild(modalBody);
            modalDialog.appendChild(modalContent);
            modal.appendChild(modalDialog);

            return modal;
        }

        buildSelectedOutput(data) {
            let labels = this.formLabelData;
            let output = 'Restore following selected values:\n';
            output += '---------------------------------\n';
            Object.entries(data).forEach(([name, value]) => {
                let strippedValue = this.stripTags(value);
                let truncatedValue = this.truncateWithMoreText(strippedValue, 80, true);

                let displayLabel = labels.get(name) || ('(' + name + ')');

                output += '- ' + displayLabel + ': ' + truncatedValue + '\n';
            });
            output += '---------------------------------\n';

            return output;
        }


    }

    n.qfqHistory = qfqHistory;

})(QfqNS.Helper);
/**
 * QFQ Inline Filter - Handles inline filter elements for faceted filtering
 *
 * Usage:
 * - Add class "qfq-inline-filter" to input elements
 * - Add data-target attribute with CSS selector of target container
 * - Add data-param attribute with parameter name
 * - SIP is retrieved from window.qfqFilterSips[target] (generated at page render end)
 *
 * @author Enis Nuredini <enis.nuredini@math.uzh.ch>
 */

/* global console */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    /**
     * QfqInlineFilter Class
     * Handles inline filter elements with data-sip
     */
    class QfqInlineFilter {
        constructor() {
            this.debounceTimers = {};
            this.apiPath = 'typo3conf/ext/qfq/Classes/Api/';
            this.initialValues = {};  // Store initial filter values for reset
            this.loadingTargets = {};  // Track which targets are currently loading (prevent loops)
            this.initialized = false;  // Prevent double initialization
        }

        /**
         * Check if element is a multi-select (original, transformed wrapper, or multi-checkbox)
         * @param {HTMLElement} element
         * @returns {boolean}
         */
        isMultiSelect(element) {
            return element.classList.contains('qfq-multi-select-container') ||
                   element.classList.contains('qfq-inline-multiselect-wrapper') ||
                   element.classList.contains('qfq-inline-multi-checkbox');
        }

        /**
         * Check if element is a transformed multi-select wrapper
         * @param {HTMLElement} element
         * @returns {boolean}
         */
        isTransformedMultiSelect(element) {
            return element.classList.contains('qfq-inline-multiselect-wrapper');
        }

        /**
         * Check if element is a radio button container
         * Supports both regular radio containers and Bootstrap btn-group style
         * @param {HTMLElement} element
         * @returns {boolean}
         */
        isRadioContainer(element) {
            return element.classList.contains('qfq-inline-radio') ||
                   (element.classList.contains('btn-group') && element.querySelector('input[type="radio"]'));
        }

        /**
         * Get value from a filter element (handles regular inputs, checkboxes, radios, and multi-selects)
         * @param {HTMLElement} filter
         * @returns {string}
         */
        getFilterValue(filter) {
            if (this.isMultiSelect(filter)) {
                // For transformed wrapper, use the dropdown list checkboxes
                var selector = this.isTransformedMultiSelect(filter) ?
                    '.qfq-inline-multiselect-list input[type="checkbox"]:checked' :
                    'input[type="checkbox"]:checked';
                var checkboxes = filter.querySelectorAll(selector);
                var values = [];
                checkboxes.forEach(function(cb) {
                    values.push(cb.value);
                });
                return values.join(',');
            }

            // Radio container: return value of checked radio
            if (this.isRadioContainer(filter)) {
                var checkedRadio = filter.querySelector('input[type="radio"]:checked');
                return checkedRadio ? checkedRadio.value : '';
            }

            // Single checkbox: return checked value or unchecked value from data attribute
            if (filter.type === 'checkbox') {
                if (filter.checked) {
                    return filter.value;
                }
                // Use data-unchecked attribute for unchecked value
                return filter.dataset.unchecked || '';
            }

            return filter.value || '';
        }

        /**
         * Set value on a filter element (handles regular inputs, checkboxes, radios, and multi-selects)
         * @param {HTMLElement} filter
         * @param {string} value - Comma-separated values for multi-select
         */
        setFilterValue(filter, value) {
            var self = this;
            if (this.isMultiSelect(filter)) {
                var values = value ? value.split(',') : [];
                var selector = this.isTransformedMultiSelect(filter) ?
                    '.qfq-inline-multiselect-list input[type="checkbox"]' :
                    'input[type="checkbox"]';
                var checkboxes = filter.querySelectorAll(selector);
                checkboxes.forEach(function(cb) {
                    cb.checked = values.indexOf(cb.value) !== -1;
                });
                // Sync with original container and update trigger
                if (this.isTransformedMultiSelect(filter)) {
                    this.syncMultiSelectState(filter);
                }
            } else if (this.isRadioContainer(filter)) {
                // Radio container: check the radio with matching value
                var radios = filter.querySelectorAll('input[type="radio"]');
                radios.forEach(function(radio) {
                    radio.checked = (radio.value === value);
                });
                // Update Bootstrap btn-group "active" class on labels
                if (filter.classList.contains('btn-group')) {
                    var labels = filter.querySelectorAll('label');
                    labels.forEach(function(label) {
                        var radio = label.querySelector('input[type="radio"]');
                        if (radio && radio.checked) {
                            label.classList.add('active');
                        } else {
                            label.classList.remove('active');
                        }
                    });
                }
            } else if (filter.type === 'checkbox') {
                // Single checkbox: check if value matches the checked value
                filter.checked = (value === filter.value);
            } else {
                filter.value = value;
            }
        }

        /**
         * Reset a filter element (handles regular inputs, checkboxes, radios, and multi-selects)
         * @param {HTMLElement} filter
         */
        resetFilter(filter) {
            if (this.isMultiSelect(filter)) {
                var selector = this.isTransformedMultiSelect(filter) ?
                    '.qfq-inline-multiselect-list input[type="checkbox"]' :
                    'input[type="checkbox"]';
                var checkboxes = filter.querySelectorAll(selector);
                checkboxes.forEach(function(cb) {
                    cb.checked = false;
                });
                // Sync with original container and update trigger
                if (this.isTransformedMultiSelect(filter)) {
                    this.syncMultiSelectState(filter);
                }
            } else if (this.isRadioContainer(filter)) {
                // Radio container: uncheck all radios
                var radios = filter.querySelectorAll('input[type="radio"]');
                radios.forEach(function(radio) {
                    radio.checked = false;
                });
                // Remove Bootstrap btn-group "active" class from labels
                if (filter.classList.contains('btn-group')) {
                    var labels = filter.querySelectorAll('label.active');
                    labels.forEach(function(label) {
                        label.classList.remove('active');
                    });
                }
            } else if (filter.type === 'checkbox') {
                // Single checkbox: uncheck it
                filter.checked = false;
            } else if (filter.tagName === 'SELECT') {
                filter.selectedIndex = 0;
            } else {
                filter.value = '';
            }
        }

        /**
         * Initialize filter functionality
         */
        init() {
            // Prevent double initialization (can happen with AJAX-loaded content)
            if (this.initialized) {
                console.warn('QfqInlineFilter: Already initialized, skipping');
                return;
            }
            this.initialized = true;

            this.transformMultiSelectFilters();
            this.storeInitialValues();  // Store initial values before URL overwrites them
            this.bindEvents();
            this.addResetButtons();
            this.loadFiltersFromUrl();

            // Always trigger initial content load (with or without filter values)
            this.triggerAllFilters();
        }

        /**
         * Store initial filter values for reset functionality
         * Called after transform but before URL loading
         */
        storeInitialValues() {
            var self = this;
            var filters = document.querySelectorAll('.qfq-inline-filter');

            filters.forEach(function(filter) {
                var paramName = filter.dataset.param;
                if (paramName) {
                    self.initialValues[paramName] = self.getFilterValue(filter);
                }
            });
        }

        /**
         * Transform multi-select containers into dropdown-style filters
         */
        transformMultiSelectFilters() {
            var self = this;
            var multiSelects = document.querySelectorAll('.qfq-multi-select-container.qfq-inline-filter');

            multiSelects.forEach(function(container) {
                // Skip if already transformed
                if (container.dataset.transformed) return;
                container.dataset.transformed = 'true';

                // Create wrapper
                var wrapper = document.createElement('div');
                wrapper.className = 'qfq-inline-multiselect-wrapper';

                // Get label from data attribute
                var labelText = container.dataset.label || '';
                var triggerText = labelText || 'All';

                // Create trigger button
                var trigger = document.createElement('button');
                trigger.type = 'button';
                trigger.className = 'form-control qfq-inline-multiselect-trigger';
                trigger.innerHTML = '<span class="qfq-inline-multiselect-text">' + triggerText + '</span> <span class="caret"></span>';

                // Create dropdown panel
                var dropdown = document.createElement('div');
                dropdown.className = 'qfq-inline-multiselect-dropdown';

                // Create header with toggle checkbox and label
                var header = document.createElement('div');
                header.className = 'qfq-inline-multiselect-header';

                // Build header: toggle checkbox + label
                var toggleCheckbox = document.createElement('input');
                toggleCheckbox.type = 'checkbox';
                toggleCheckbox.className = 'qfq-inline-select-toggle';

                var titleSpan = document.createElement('span');
                titleSpan.className = 'qfq-inline-multiselect-title';
                titleSpan.textContent = labelText;

                header.appendChild(toggleCheckbox);
                header.appendChild(titleSpan);

                // Create list container
                var listContainer = document.createElement('div');
                listContainer.className = 'qfq-inline-multiselect-list';

                // Move checkboxes to list, restructure for better layout
                var checkboxes = container.querySelectorAll('label.checkbox');
                checkboxes.forEach(function(label) {
                    var item = document.createElement('div');
                    item.className = 'qfq-inline-multiselect-item';

                    var checkbox = label.querySelector('input[type="checkbox"]');
                    var textContent = '';
                    var countText = '';

                    // Extract text and count
                    label.childNodes.forEach(function(node) {
                        if (node.nodeType === Node.TEXT_NODE) {
                            var text = node.textContent.trim();
                            var match = text.match(/^(.+?)\s*\((\d+)\)$/);
                            if (match) {
                                textContent = match[1];
                                countText = match[2];
                            } else {
                                textContent = text;
                            }
                        }
                    });

                    // Build new item structure
                    var newCheckbox = checkbox.cloneNode(true);
                    var labelText = document.createElement('span');
                    labelText.className = 'qfq-inline-multiselect-label';
                    labelText.textContent = textContent;

                    var countSpan = document.createElement('span');
                    countSpan.className = 'qfq-inline-multiselect-count';
                    countSpan.textContent = countText;
                    if (checkbox.dataset.count !== undefined) {
                        countSpan.dataset.count = checkbox.dataset.count;
                    }

                    item.appendChild(newCheckbox);
                    item.appendChild(labelText);
                    item.appendChild(countSpan);
                    listContainer.appendChild(item);
                });

                // Assemble dropdown
                dropdown.appendChild(header);
                dropdown.appendChild(listContainer);

                // Copy data attributes to wrapper (SIP comes from window.qfqFilterSips)
                wrapper.dataset.target = container.dataset.target;
                wrapper.dataset.param = container.dataset.param;
                wrapper.dataset.baseUrl = container.dataset.baseUrl || '';
                wrapper.dataset.label = container.dataset.label || '';
                if (container.dataset.countSip) {
                    wrapper.dataset.countSip = container.dataset.countSip;
                }

                // Replace container with new structure
                container.parentNode.insertBefore(wrapper, container);
                wrapper.appendChild(trigger);
                wrapper.appendChild(dropdown);
                container.style.display = 'none';
                container.classList.remove('qfq-inline-filter'); // Remove from original to avoid duplicate selectors
                wrapper.appendChild(container); // Keep original for form submission

                // Add wrapper class for CSS targeting
                wrapper.classList.add('qfq-inline-filter');

                // Event: Toggle dropdown
                trigger.addEventListener('click', function(e) {
                    e.preventDefault();
                    e.stopPropagation();
                    dropdown.classList.toggle('open');
                });

                // Event: Close on outside click
                document.addEventListener('click', function(e) {
                    if (!wrapper.contains(e.target)) {
                        dropdown.classList.remove('open');
                    }
                });

                // Event: Toggle all checkboxes via header checkbox
                toggleCheckbox.addEventListener('change', function() {
                    var isChecked = toggleCheckbox.checked;
                    listContainer.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
                        cb.checked = isChecked;
                    });
                    self.syncMultiSelectState(wrapper);
                    self.handleFilterChange(wrapper);
                });

                // Event: Checkbox change
                listContainer.addEventListener('change', function(e) {
                    if (e.target.type === 'checkbox') {
                        self.syncMultiSelectState(wrapper);
                        self.handleFilterChange(wrapper);
                    }
                });

                // Initial state update
                self.updateToggleCheckboxState(wrapper);
                self.updateMultiSelectTrigger(wrapper);
            });
        }

        /**
         * Sync transformed multi-select state with original container
         */
        syncMultiSelectState(wrapper) {
            var originalContainer = wrapper.querySelector('.qfq-multi-select-container');
            var listContainer = wrapper.querySelector('.qfq-inline-multiselect-list');

            if (!originalContainer || !listContainer) return;

            // Sync checkbox states
            var newCheckboxes = listContainer.querySelectorAll('input[type="checkbox"]');
            var originalCheckboxes = originalContainer.querySelectorAll('input[type="checkbox"]');

            newCheckboxes.forEach(function(newCb, index) {
                if (originalCheckboxes[index]) {
                    originalCheckboxes[index].checked = newCb.checked;
                }
            });

            // Update toggle checkbox state (checked/unchecked/indeterminate)
            this.updateToggleCheckboxState(wrapper);

            this.updateMultiSelectTrigger(wrapper);
        }

        /**
         * Update toggle checkbox state based on list checkbox states
         * @param {HTMLElement} wrapper
         */
        updateToggleCheckboxState(wrapper) {
            var toggleCheckbox = wrapper.querySelector('.qfq-inline-select-toggle');
            var listContainer = wrapper.querySelector('.qfq-inline-multiselect-list');

            if (!toggleCheckbox || !listContainer) return;

            var checkboxes = listContainer.querySelectorAll('input[type="checkbox"]');
            var total = checkboxes.length;
            var checked = listContainer.querySelectorAll('input[type="checkbox"]:checked').length;

            if (checked === 0) {
                toggleCheckbox.checked = false;
                toggleCheckbox.indeterminate = false;
            } else if (checked === total) {
                toggleCheckbox.checked = true;
                toggleCheckbox.indeterminate = false;
            } else {
                toggleCheckbox.checked = false;
                toggleCheckbox.indeterminate = true;
            }
        }

        /**
         * Update multi-select trigger button text
         */
        updateMultiSelectTrigger(wrapper) {
            var trigger = wrapper.querySelector('.qfq-inline-multiselect-trigger');
            var textSpan = trigger.querySelector('.qfq-inline-multiselect-text');
            var listContainer = wrapper.querySelector('.qfq-inline-multiselect-list');
            var label = wrapper.dataset.label || 'All';

            var checked = listContainer.querySelectorAll('input[type="checkbox"]:checked');
            var total = listContainer.querySelectorAll('input[type="checkbox"]').length;

            if (checked.length === 0) {
                // Nothing selected - show label
                textSpan.textContent = label;
                textSpan.className = 'qfq-inline-multiselect-text';
            } else {
                // Show selected labels (partial or all selected)
                var labels = [];
                checked.forEach(function(cb) {
                    var item = cb.closest('.qfq-inline-multiselect-item');
                    var labelEl = item.querySelector('.qfq-inline-multiselect-label');
                    if (labelEl) labels.push(labelEl.textContent);
                });

                if (labels.length <= 2) {
                    textSpan.textContent = labels.join(', ');
                } else {
                    textSpan.textContent = labels.slice(0, 2).join(', ') + ' +' + (labels.length - 2);
                }
                textSpan.className = 'qfq-inline-multiselect-text has-selection';
            }
        }

        /**
         * Add reset buttons to filter-panel containers
         */
        addResetButtons() {
            var self = this;
            var filterPanels = document.querySelectorAll('.filter-panel');

            filterPanels.forEach(function(panel) {
                // Check if reset button already exists
                if (panel.querySelector('.qfq-inline-reset')) {
                    return;
                }

                // Create reset button
                var resetBtn = document.createElement('button');
                resetBtn.type = 'button';
                resetBtn.className = 'btn btn-default qfq-inline-reset';
                resetBtn.textContent = 'Reset';

                // Handle click
                resetBtn.addEventListener('click', function() {
                    self.resetFilters(panel);
                });

                // Append to filter-panel
                panel.appendChild(resetBtn);
            });
        }

        /**
         * Reset all filters in a panel to their initial values and reload content
         * @param {HTMLElement} panel - The filter-panel container
         */
        resetFilters(panel) {
            var self = this;
            var filters = panel.querySelectorAll('.qfq-inline-filter');
            var target = null;
            var initialParams = {};

            // Reset all filter values to their initial state
            filters.forEach(function(filter) {
                var paramName = filter.dataset.param;
                var initialValue = self.initialValues[paramName] || '';

                // Restore initial value
                self.setFilterValue(filter, initialValue);

                // Collect non-empty initial values for URL
                if (initialValue !== '') {
                    initialParams[paramName] = initialValue;
                }

                // Get target from first filter
                if (!target) {
                    target = filter.dataset.target;
                }
            });

            // Update URL with initial parameters (or clear if all empty)
            this.updateUrl(initialParams);

            // Reload content
            if (target) {
                this.triggerFiltersForTarget(target);
            }
        }

        /**
         * Bind event listeners
         */
        bindEvents() {
            var self = this;

            // Bind events directly to filter elements (not document-level)
            // This avoids performance issues with tablesorter events bubbling up
            this.bindFilterEvents();

            // Browser back/forward
            window.addEventListener('popstate', function() {
                self.loadFiltersFromUrl();
                self.triggerAllFilters();
            });
        }

        /**
         * Bind events directly to filter elements
         * Called on init and can be called again for dynamically added filters
         */
        bindFilterEvents() {
            var self = this;
            var filters = document.querySelectorAll('.qfq-inline-filter:not([data-events-bound])');

            filters.forEach(function(filter) {
                // Mark as bound to prevent double-binding
                filter.dataset.eventsBound = 'true';

                // Change event for selects, checkboxes, radios
                filter.addEventListener('change', function(e) {
                    // For containers with checkboxes/radios, the event target is the input
                    // but the filter is the container - already handled correctly
                    self.handleFilterChange(filter);
                });

                // Input event for text fields (debounced)
                if (filter.type === 'text' || filter.type === 'search') {
                    filter.addEventListener('input', function(e) {
                        self.handleFilterInputDebounced(filter);
                    });
                }

                // Bootstrap btn-group click handler
                if (filter.classList.contains('btn-group')) {
                    filter.addEventListener('click', function(e) {
                        var label = e.target.closest('label');
                        if (label) {
                            // Small delay to let Bootstrap update the checked state
                            setTimeout(function() {
                                self.handleFilterChange(filter);
                            }, 10);
                        }
                    });
                }
            });
        }

        /**
         * Handle filter change (immediate)
         * @param {HTMLElement} element - Changed filter element
         */
        handleFilterChange(element) {
            var target = element.dataset.target;
            this.triggerFiltersForTarget(target);
        }

        /**
         * Handle filter input with debounce (for text fields)
         * @param {HTMLElement} element - Changed filter element
         */
        handleFilterInputDebounced(element) {
            var self = this;
            var target = element.dataset.target;

            clearTimeout(this.debounceTimers[target]);
            this.debounceTimers[target] = setTimeout(function() {
                self.triggerFiltersForTarget(target);
            }, 300);
        }

        /**
         * Collect all filters for a target and trigger AJAX request
         * @param {string} target - CSS selector of target container
         */
        triggerFiltersForTarget(target) {
            var self = this;
            var filters = document.querySelectorAll('.qfq-inline-filter[data-target="' + target + '"]');
            var params = {};
            var baseUrl = null;

            filters.forEach(function(filter) {
                var paramName = filter.dataset.param;
                var value = self.getFilterValue(filter);

                if (value !== '') {
                    params[paramName] = value;
                }

                // Get baseUrl from first filter
                if (baseUrl === null) {
                    baseUrl = filter.dataset.baseUrl || '';
                }
            });

            // Get SIP from global registry (generated at end of page render)
            var sip = window.qfqFilterSips && window.qfqFilterSips[target];

            if (sip) {
                this.updateUrl(params);
                this.loadContent(sip, params, target, baseUrl);
            }
        }

        /**
         * Load content via AJAX
         *
         * @param {string} sip - SIP token for API call
         * @param {object} params - Filter parameters
         * @param {string} targetSelector - CSS selector of target container
         * @param {string} baseUrl - Base URL from data-base-url attribute
         */
        loadContent(sip, params, targetSelector, baseUrl) {
            var self = this;

            // Prevent concurrent requests for the same target (loop prevention)
            if (this.loadingTargets[targetSelector]) {
                console.warn('QfqInlineFilter: Request already in progress for', targetSelector);
                return;
            }

            var targetElement = document.querySelector(targetSelector);
            if (!targetElement) {
                console.warn('QfqInlineFilter: Target element not found:', targetSelector);
                return;
            }

            // Set loading lock
            this.loadingTargets[targetSelector] = true;
            targetElement.classList.add('qfq-inline-loading');

            // Build API URL with baseUrl
            var apiUrl = baseUrl + this.apiPath + 'dataReport.php?s=' + sip;

            // Build request body with filter params
            // Note: allowedValues for validation is included in the combined SIP (tamper-proof)
            var requestBody = new URLSearchParams(params);

            fetch(apiUrl, {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: requestBody
            })
            .then(function(response) {
                if (!response.ok) {
                    throw new Error('HTTP error! status: ' + response.status);
                }
                return response.text();
            })
            .then(function(responseText) {
                targetElement.classList.remove('qfq-inline-loading');

                // Check if response is JSON (error) or HTML (success)
                try {
                    var jsonResponse = JSON.parse(responseText);
                    // If we get here, it's JSON - likely an error response
                    if (jsonResponse.status === 'error') {
                        self.showError(jsonResponse.message);
                        self.loadingTargets[targetSelector] = false;
                        return;
                    }
                } catch (e) {
                    // Not JSON, treat as HTML content (success case)
                }

                // Success: update content
                targetElement.innerHTML = responseText;
                self.reinitTablesorter(targetElement);
                // Bind events to any new filter elements in loaded content
                self.bindFilterEvents();
                // Update counts after content load (if enabled)
                self.refreshCounts(targetSelector, params);

                // Release loading lock
                self.loadingTargets[targetSelector] = false;
            })
            .catch(function(err) {
                targetElement.classList.remove('qfq-inline-loading');
                self.showError('Request failed: ' + err.message);
                console.error('QfqInlineFilter error:', err);
                // Release loading lock
                self.loadingTargets[targetSelector] = false;
            });
        }

        /**
         * Show error message using QfqNS.Alert
         * @param {string} message - Error message to display
         */
        showError(message) {
            if (typeof QfqNS !== 'undefined' && QfqNS.Alert) {
                var alert = new QfqNS.Alert({
                    message: message,
                    type: 'error',
                    modal: false
                });
                alert.show();
            } else {
                console.error('QfqInlineFilter:', message);
            }
        }

        /**
         * Refresh counts for all filters targeting the same container (batch request)
         * Uses first filter's countSip as master SIP (triggers FILTER_COUNT_MODE),
         * all countSips are passed in JSON body (similar to syncByRule pattern)
         *
         * @param {string} targetSelector - CSS selector of target container
         * @param {object} params - Current filter parameters
         */
        refreshCounts(targetSelector, params) {
            var self = this;
            var filters = document.querySelectorAll('.qfq-inline-filter[data-target="' + targetSelector + '"]');

            // Collect all filters with countSip
            var filterMap = {};  // paramName -> {element, sip}
            var baseUrl = '';
            var masterSip = '';

            filters.forEach(function(filter) {
                var countSip = filter.dataset.countSip;
                if (!countSip) return;

                var paramName = filter.dataset.param;
                filterMap[paramName] = {
                    element: filter,
                    sip: countSip
                };

                // Add updating class for visual feedback
                filter.classList.add('qfq-count-updating');

                // Use first filter's SIP as master and get baseUrl
                if (!masterSip) {
                    masterSip = countSip;
                    baseUrl = filter.dataset.baseUrl || '';
                }
            });

            // No filters with countSip found
            if (!masterSip) return;

            // Build batch request: use master SIP in URL, all countSips in JSON body
            var countSips = {};
            Object.keys(filterMap).forEach(function(paramName) {
                countSips[paramName] = filterMap[paramName].sip;
            });

            // Master SIP triggers FILTER_COUNT_MODE, countSips in body for batch processing
            var apiUrl = baseUrl + self.apiPath + 'load.php?s=' + masterSip;

            fetch(apiUrl, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    countSips: countSips,
                    filterParams: params
                })
            })
            .then(function(response) { return response.json(); })
            .then(function(data) {
                // Remove updating class from all filters
                Object.keys(filterMap).forEach(function(paramName) {
                    filterMap[paramName].element.classList.remove('qfq-count-updating');
                });

                // API returns {status: "success", filter-counts: {paramName: {...}, ...}}
                if (data.status === 'success' && data['filter-counts']) {
                    Object.keys(filterMap).forEach(function(paramName) {
                        var counts = data['filter-counts'][paramName];
                        if (counts) {
                            self.updateFilterCounts(filterMap[paramName].element, counts);
                        }
                    });
                }
            })
            .catch(function(err) {
                // Remove updating class on error
                Object.keys(filterMap).forEach(function(paramName) {
                    filterMap[paramName].element.classList.remove('qfq-count-updating');
                });
                console.warn('QfqInlineFilter: Count refresh failed:', err);
            });
        }

        /**
         * Update counts in filter UI
         * @param {HTMLElement} filter - Filter element to update
         * @param {object} counts - Count data from server
         */
        updateFilterCounts(filter, counts) {
            var self = this;
            var paramName = filter.dataset.param;

            if (self.isTransformedMultiSelect(filter)) {
                // Update transformed multi-select dropdown items
                var items = filter.querySelectorAll('.qfq-inline-multiselect-item');
                items.forEach(function(item) {
                    var checkbox = item.querySelector('input[type="checkbox"]');
                    var countSpan = item.querySelector('.qfq-inline-multiselect-count');
                    if (checkbox && countSpan && counts[checkbox.value] !== undefined) {
                        countSpan.textContent = counts[checkbox.value];
                        checkbox.dataset.count = counts[checkbox.value];
                    }
                });
            } else if (self.isMultiSelect(filter)) {
                // Update original multi-select checkbox labels
                var checkboxes = filter.querySelectorAll('input[type="checkbox"]');
                checkboxes.forEach(function(checkbox) {
                    var value = checkbox.value;
                    if (value && counts[value] !== undefined) {
                        var label = checkbox.parentElement;
                        if (label && label.tagName === 'LABEL') {
                            // Get text content without the checkmark span
                            var textNode = Array.from(label.childNodes).find(function(node) {
                                return node.nodeType === Node.TEXT_NODE;
                            });
                            if (textNode) {
                                var text = textNode.textContent.replace(/\s*\(\d+\)$/, '');
                                textNode.textContent = text + ' (' + counts[value] + ')';
                            }
                        }
                        checkbox.dataset.count = counts[value];
                    }
                });
            } else if (filter.tagName === 'SELECT') {
                // Update select options
                Array.from(filter.options).forEach(function(option) {
                    var value = option.value;
                    if (value && counts[value] !== undefined) {
                        var label = option.textContent.replace(/\s*\(\d+\)$/, '');
                        option.textContent = label + ' (' + counts[value] + ')';
                        option.dataset.count = counts[value];
                    }
                });
            } else if (self.isRadioContainer(filter)) {
                // Update radio button labels
                var radios = filter.querySelectorAll('input[type="radio"]');
                radios.forEach(function(radio) {
                    var value = radio.value;
                    if (value && counts[value] !== undefined) {
                        var label = radio.parentElement;
                        if (label && label.tagName === 'LABEL') {
                            // Get text content without the checkmark span
                            var textNode = Array.from(label.childNodes).find(function(node) {
                                return node.nodeType === Node.TEXT_NODE;
                            });
                            if (textNode) {
                                var text = textNode.textContent.replace(/\s*\(\d+\)$/, '');
                                textNode.textContent = text + ' (' + counts[value] + ')';
                            }
                        }
                        radio.dataset.count = counts[value];
                    }
                });
            } else if (filter.type === 'checkbox' || filter.type === 'text' || filter.type === 'search') {
                // Update count badge for checkbox or text input
                var badge = document.querySelector('.qfq-inline-count-badge[data-count-for="' + paramName + '"]');
                if (badge && counts.total !== undefined) {
                    // Checkbox shows count in parentheses, text input shows plain number
                    badge.textContent = filter.type === 'checkbox' ? '(' + counts.total + ')' : counts.total;
                    filter.dataset.count = counts.total;
                }
            }
        }

        /**
         * Update URL with filter parameters
         * @param {object} params - Filter parameters
         */
        updateUrl(params) {
            var urlParams = new URLSearchParams();
            Object.keys(params).forEach(function(key) {
                if (params[key] !== '') {
                    urlParams.set(key, params[key]);
                }
            });

            var newUrl = window.location.pathname;
            if (urlParams.toString()) {
                newUrl += '?' + urlParams.toString();
            }
            history.pushState(params, '', newUrl);
        }

        /**
         * Load filter values from URL
         */
        loadFiltersFromUrl() {
            var self = this;
            var urlParams = new URLSearchParams(window.location.search);

            urlParams.forEach(function(value, key) {
                var filter = document.querySelector('.qfq-inline-filter[data-param="' + key + '"]');
                if (filter) {
                    self.setFilterValue(filter, value);
                }
            });
        }

        /**
         * Trigger all filters (used after loading from URL)
         */
        triggerAllFilters() {
            var self = this;
            var targets = new Set();

            document.querySelectorAll('.qfq-inline-filter').forEach(function(filter) {
                targets.add(filter.dataset.target);
            });

            targets.forEach(function(target) {
                self.triggerFiltersForTarget(target);
            });
        }

        /**
         * Re-initialize tablesorter for dynamically loaded content
         * @param {HTMLElement} container - Container with new content
         */
        reinitTablesorter(container) {
            var tables = container.querySelectorAll('.tablesorter');

            if (tables.length > 0 && typeof QfqNS !== 'undefined' && QfqNS.TablesorterController) {
                var controller = new QfqNS.TablesorterController();
                tables.forEach(function(table, i) {
                    if (!table.classList.contains('tablesorter-initialized')) {
                        controller.setup($(table), 'filter-' + i);
                        table.classList.add('tablesorter-initialized');
                    }
                });
            }
        }
    }

    // Export class (initialization happens in Main.js)
    n.Helper.QfqInlineFilter = QfqInlineFilter;

})(QfqNS);

/**
 * QFQ InputCollectionJson Configuration Editor
 * Transforms textarea.qfq-icj elements into a user-friendly configuration editor.
 *
 * @author QFQ Team
 * @version 1.0
 */

/* global console */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    /**
     * QfqConfigTemplate - A lightweight FlexForm-like configuration editor
     *
     * Self-contained with no external CSS dependencies.
     *
     * Usage:
     *   <textarea class="qfq-icj" name="config" data-icj-template="# cat=...">
     *     {"category": {"varName": {"value": "..."}}}
     *   </textarea>
     *
     * - Template in data-icj-template attribute (plain string)
     * - JSON values in textarea content (this gets submitted with the form)
     * - On value change, textarea content is auto-updated
     *
     *
     * Supports two data modes:
     *
     * 1. WITH TEMPLATE: Template defines structure, JSON only needs values
     *    JSON: { "category": { "varName": { "value": "..." } } }
     *    If JSON is empty, values are auto-generated from template defaults.
     *
     * 2. WITHOUT TEMPLATE (JSON-only): Use reserved keys for metadata
     *    JSON: {
     *      "category": {
     *        "varName": {
     *          "value": "...",
     *          "__label": "Human Label",     // optional, fallback: key name
     *          "__cat": "subcategory",       // optional
     *          "__desc": "Description text", // optional
     *          "__type": "select",           // optional: text|select|checkbox|number|textarea
     *          "__options": ["a", "b"],      // required if __type is select
     *          "__required": true,           // optional: mark field as required
     *          "__readonly": true            // optional: mark field as readonly
     *        }
     *      }
     *    }
     */
    class QfqConfigTemplate {

        constructor(config) {
            this.categories = new Map();
            this.variables = new Map();
            this.container = null;
            this.textarea = null;
            this.values = {};
            this.template = '';
            this.mode = 'editor';
            this.wrapper = null;
            this.hasTemplate = false;
            this.loadingEl = null;
            this.validationError = null;
            this.isDisabled = false;
            this.isRequired = false;
            this.requiredError = '';
            this.rawContent = '';

            if (config instanceof HTMLTextAreaElement) {
                this._initFromTextarea(config);
            } else if (typeof config === 'object' && config !== null) {
                this._initFromConfig(config);
            }
        }

        _initFromTextarea(textarea) {
            this.textarea = textarea;

            // Check if disabled/readonly
            this.isDisabled = textarea.disabled || textarea.dataset.disabled === 'yes';

            // Check if required
            this.isRequired = textarea.dataset.required === 'yes' || textarea.hasAttribute('required');
            this.requiredError = textarea.dataset.requiredError || '';

            // Template from data-icj-template attribute (plain string)
            // Note: Do NOT convert \n to actual newlines here - values may contain literal \n
            // that should be preserved. The template uses actual line breaks as separators.
            var templateAttr = textarea.dataset.icjTemplate;
            if (templateAttr) {
                this.template = templateAttr.trim();
            }
            this.hasTemplate = this.template.length > 0;

            // JSON values from textarea content
            var icjContent = (textarea.value || '').trim();
            this.rawContent = icjContent;
            if (icjContent) {
                try {
                    this.values = JSON.parse(icjContent);
                } catch (e) {
                    console.error('QfqConfigTemplate: Invalid JSON in textarea content', e);
                    this.values = {};
                    this.validationError = 'Invalid JSON syntax.';
                }
            }

            // Handle empty/missing JSON
            if (!this.validationError) {
                var icjIsEmpty = Object.keys(this.values).length === 0;

                if (this.hasTemplate) {
                    // With template: parse template first, then validate/generate JSON
                    this._parseTemplate();

                    if (icjIsEmpty) {
                        // Auto-generate JSON from template defaults
                        this._generateIcjFromTemplate();
                        // Update textarea with generated JSON
                        if (this.textarea) {
                            this.textarea.value = this.getValuesIcj();
                        }
                    } else {
                        // Validate existing JSON structure
                        this.validationError = this._validateIcjStructure(this.values);
                    }
                } else {
                    // JSON-only mode: validate JSON
                    if (icjIsEmpty) {
                        this.validationError = 'No template and no JSON data provided.';
                    } else {
                        this.validationError = this._validateIcjStructure(this.values);
                        if (!this.validationError) {
                            this._parseIcjOnly();
                        }
                    }
                }
            }
        }

        _initFromConfig(config) {
            this.template = (config.template || '').trim();
            this.values = config.values || {};
            this.container = config.container || null;
            this.mode = config.mode || 'editor';
            this.hasTemplate = this.template.length > 0;

            var icjIsEmpty = Object.keys(this.values).length === 0;

            if (this.hasTemplate) {
                this._parseTemplate();

                if (icjIsEmpty) {
                    this._generateIcjFromTemplate();
                } else {
                    this.validationError = this._validateIcjStructure(this.values);
                }
            } else {
                if (icjIsEmpty) {
                    this.validationError = 'No template and no JSON data provided.';
                } else {
                    this.validationError = this._validateIcjStructure(this.values);
                    if (!this.validationError) {
                        this._parseIcjOnly();
                    }
                }
            }
        }

        /**
         * Generate JSON values from parsed template defaults
         */
        _generateIcjFromTemplate() {
            this.values = {};

            for (var entry of this.variables) {
                var name = entry[0];
                var variable = entry[1];
                // Handle null, undefined, or empty string category
                var category = variable.category || '';

                if (!this.values[category]) {
                    this.values[category] = {};
                }

                this.values[category][name] = { value: variable.defaultValue };
            }
        }

        /**
         * Validate JSON structure
         * Expected: { "category": { "varName": { "value": "..." } } }
         */
        _validateIcjStructure(json) {
            if (typeof json !== 'object' || json === null || Array.isArray(json)) {
                return 'JSON must be an object.';
            }

            if (Object.keys(json).length === 0) {
                // Empty JSON is handled separately in init methods
                return null;
            }

            var hasValidVariables = false;

            for (var category in json) {
                if (!json.hasOwnProperty(category)) continue;
                var vars = json[category];

                if (typeof vars !== 'object' || vars === null || Array.isArray(vars)) {
                    return 'Category "' + (category || '(empty)') + '" must be an object.';
                }

                for (var varName in vars) {
                    if (!vars.hasOwnProperty(varName)) continue;
                    var varData = vars[varName];

                    if (typeof varData !== 'object' || varData === null || Array.isArray(varData)) {
                        return 'Variable "' + varName + '" must be an object with at least a "value" property.';
                    }

                    if (!varData.hasOwnProperty('value')) {
                        return 'Variable "' + varName + '" is missing "value" property.';
                    }

                    hasValidVariables = true;
                }
            }

            // JSON-only mode: must have at least one valid variable
            if (!this.hasTemplate && !hasValidVariables) {
                return 'No valid variables found. Each variable needs at least a "value" property.';
            }

            return null; // No error
        }

        /**
         * Render error message with example
         */
        _renderError(message) {
            var wrapper = document.createElement('div');
            wrapper.className = 'qfq-icj-template qfq-icj-error';

            var errorBox = document.createElement('div');
            errorBox.className = 'qfq-icj-error-box';

            var errorMsg = document.createElement('div');
            errorMsg.className = 'qfq-icj-error-msg';
            errorMsg.textContent = 'Error: ' + message;

            var exampleLabel = document.createElement('div');
            exampleLabel.className = 'qfq-icj-error-label';
            exampleLabel.textContent = 'Expected JSON structure:';

            var example = document.createElement('pre');
            example.className = 'qfq-icj-error-example';
            example.textContent = '{\n' +
                '  "category": {\n' +
                '    "variableName": {\n' +
                '      "value": "your-value",\n' +
                '      "__label": "Label (optional)",\n' +
                '      "__desc": "Description (optional)",\n' +
                '      "__type": "text|select|checkbox|number (optional)",\n' +
                '      "__options": ["option1", "option2"] (for select),\n' +
                '      "__required": true (optional),\n' +
                '      "__readonly": true (optional)\n' +
                '    }\n' +
                '  }\n' +
                '}';

            errorBox.appendChild(errorMsg);
            errorBox.appendChild(exampleLabel);
            errorBox.appendChild(example);

            // Show current (invalid) content if available
            if (this.rawContent) {
                var currentLabel = document.createElement('div');
                currentLabel.className = 'qfq-icj-error-label';
                currentLabel.textContent = 'Current content:';

                var currentContent = document.createElement('pre');
                currentContent.className = 'qfq-icj-error-current';
                currentContent.textContent = this.rawContent;

                var hint = document.createElement('div');
                hint.className = 'qfq-icj-error-hint';
                hint.textContent = 'Note: Content may have been modified by QFQ encode or checkType settings. ' +
                    'Recommended: FE.encode = none, FE.checkType = all';

                errorBox.appendChild(currentLabel);
                errorBox.appendChild(currentContent);
                errorBox.appendChild(hint);
            }

            wrapper.appendChild(errorBox);

            return wrapper;
        }

        _parseIcjOnly() {
            var self = this;

            for (var category in this.values) {
                if (!this.values.hasOwnProperty(category)) continue;
                var vars = this.values[category];

                if (typeof vars !== 'object' || vars === null) continue;

                for (var name in vars) {
                    if (!vars.hasOwnProperty(name)) continue;
                    var data = vars[name];

                    if (typeof data !== 'object' || data === null) continue;

                    var inputType = 'text';
                    var options = [];

                    if (data.__type) {
                        inputType = data.__type;
                        if (inputType === 'select' && Array.isArray(data.__options)) {
                            options = data.__options;
                        }
                    } else if (Array.isArray(data.__options)) {
                        inputType = 'select';
                        options = data.__options;
                    }

                    var variable = {
                        name: name,
                        defaultValue: data.value !== undefined ? String(data.value) : '',
                        category: category,
                        subcategory: data.__cat || null,
                        label: data.__label || self._formatKeyAsLabel(name),
                        description: data.__desc || '',
                        inputType: inputType,
                        options: options,
                        type: inputType,
                        required: data.__required === true || data.__required === 'true',
                        readonly: data.__readonly === true || data.__readonly === 'true'
                    };

                    self.variables.set(name, variable);

                    if (!self.categories.has(category)) {
                        self.categories.set(category, []);
                    }
                    self.categories.get(category).push(variable);
                }
            }
        }

        _parseTemplate() {
            var self = this;
            var normalizedTemplate = this.template.replace(/\r/g, '').replace(/\\r/g, '');
            var lines = normalizedTemplate.split('\n');
            var currentMeta = null;

            for (var i = 0; i < lines.length; i++) {
                var line = lines[i].trim();

                if (line.startsWith('#')) {
                    var meta = self._parseMetaComment(line);
                    if (meta) currentMeta = meta;
                    continue;
                }

                var varMatch = line.match(/^(\w+)\s*=\s*(.*)$/);
                if (varMatch && currentMeta) {
                    var name = varMatch[1];
                    var defaultValue = varMatch[2];

                    var variable = {
                        name: name,
                        defaultValue: defaultValue.trim(),
                        category: currentMeta.category,
                        subcategory: currentMeta.subcategory,
                        type: currentMeta.type,
                        inputType: currentMeta.inputType,
                        options: currentMeta.options,
                        label: currentMeta.label,
                        description: currentMeta.description
                    };

                    self.variables.set(name, variable);

                    // Use empty string for no category (enables no-tabs mode)
                    var catKey = currentMeta.category || '';
                    if (!self.categories.has(catKey)) {
                        self.categories.set(catKey, []);
                    }
                    self.categories.get(catKey).push(variable);

                    currentMeta = null;
                }
            }
        }

        _parseMetaComment(line) {
            var content = line.replace(/^#\s*/, '');

            if (content.indexOf('cat=') === -1 && content.indexOf('type=') === -1 && content.indexOf('label=') === -1) {
                return null;
            }

            var meta = {
                category: null,
                subcategory: null,
                type: 'string',
                inputType: 'text',
                options: [],
                label: '',
                description: ''
            };

            var catMatch = content.match(/cat=([^;]+)/);
            if (catMatch) {
                var catParts = catMatch[1].trim().split('/');
                meta.category = catParts[0];
                meta.subcategory = catParts[1] || null;
            }

            var typeMatch = content.match(/type=([^;]+)/);
            if (typeMatch) {
                var typeStr = typeMatch[1].trim();
                meta.type = typeStr;

                var optionsMatch = typeStr.match(/options\[([^\]]+)\]/);
                if (optionsMatch) {
                    meta.inputType = 'select';
                    meta.options = optionsMatch[1].split(',').map(function(o) { return o.trim(); });
                } else if (typeStr === 'boolean') {
                    meta.inputType = 'checkbox';
                } else if (typeStr === 'int+' || typeStr === 'int') {
                    meta.inputType = 'number';
                } else if (typeStr === 'textarea' || typeStr.indexOf('user[') === 0) {
                    meta.inputType = 'textarea';
                } else {
                    meta.inputType = 'text';
                }
            }

            var labelMatch = content.match(/label=([^:]+)(?::(.+))?$/);
            if (labelMatch) {
                meta.label = labelMatch[1].trim();
                meta.description = labelMatch[2] ? labelMatch[2].trim() : '';
            }

            return meta;
        }

        _formatKeyAsLabel(key) {
            return key
                .replace(/([A-Z])/g, ' $1')
                .replace(/[_-]/g, ' ')
                .replace(/^\w/, function(c) { return c.toUpperCase(); })
                .trim();
        }

        getValue(name, category) {
            // If category provided, look there first (fixes duplicate field names across categories)
            if (category !== undefined && this.values[category] && this.values[category][name] && this.values[category][name].value !== undefined) {
                return String(this.values[category][name].value);
            }

            // Fallback to searching all categories (for backward compatibility)
            for (var cat in this.values) {
                if (!this.values.hasOwnProperty(cat)) continue;
                var vars = this.values[cat];
                if (vars && vars[name] && vars[name].value !== undefined) {
                    return String(vars[name].value);
                }
            }

            if (this.values[name] !== undefined) {
                return String(this.values[name]);
            }

            var variable = this.variables.get(name);
            return variable ? variable.defaultValue : '';
        }

        setValue(name, value, category) {
            // If category is provided, use it directly (fixes duplicate field names across categories)
            if (category !== undefined) {
                if (!this.values[category]) {
                    this.values[category] = {};
                }
                // Always preserve existing __* properties (metadata like __readonly, __required, __label, etc.)
                var existing = this.values[category][name] || {};
                this.values[category][name] = Object.assign({}, existing, { value: value });
                return;
            }

            // Fallback: try to find category from variables Map
            var variable = this.variables.get(name);
            if (!variable) return;

            // Preserve empty string category, only default to 'config' if undefined
            var cat = variable.category !== undefined ? variable.category : 'config';

            if (!this.values[cat]) {
                this.values[cat] = {};
            }

            // Always preserve existing __* properties (metadata like __readonly, __required, __label, etc.)
            var existingVal = this.values[cat][name] || {};
            this.values[cat][name] = Object.assign({}, existingVal, { value: value });
        }

        getValues() {
            return this.values;
        }

        getValuesIcj() {
            // First collect all current values from inputs
            this.collectValues();

            // Always return full values including __* metadata properties
            return JSON.stringify(this.values);
        }

        getFilledTemplate() {
            if (!this.hasTemplate) return '';

            var self = this;
            var result = this.template;

            for (var entry of this.variables) {
                var name = entry[0];
                var value = self.getValue(name);
                var regex = new RegExp('^(' + name + '\\s*=\\s*).*$', 'm');
                result = result.replace(regex, '$1' + value);
            }

            return result;
        }

        render(container, mode, cssClasses) {
            var self = this;
            mode = mode || 'editor';
            cssClasses = cssClasses || '';

            var target = container || this.container;

            // Auto-create container after textarea if not provided
            if (!target && this.textarea) {
                target = document.createElement('div');
                this.textarea.parentNode.insertBefore(target, this.textarea.nextSibling);
                // Hide the original textarea - it's used for form submission only
                this.textarea.style.display = 'none';
            }

            if (!target) {
                console.error('QfqConfigTemplate: No container specified and no textarea to attach to');
                return null;
            }

            if (mode === 'template' && !this.hasTemplate) {
                console.warn('QfqConfigTemplate: Template mode requires a template. Falling back to editor.');
                mode = 'editor';
            }

            this.mode = mode;

            // Remove previous wrapper
            if (this.wrapper) {
                this.wrapper.remove();
            }

            // Remove previous loading spinner
            if (this.loadingEl) {
                this.loadingEl.remove();
            }

            // Create main wrapper (hidden initially)
            var mainWrapper = document.createElement('div');
            mainWrapper.className = 'qfq-icj-wrapper' + (cssClasses ? ' ' + cssClasses : '');
            mainWrapper.style.display = 'none';

            // Create loading spinner
            this.loadingEl = document.createElement('div');
            this.loadingEl.className = 'qfq-icj-loading';
            this.loadingEl.innerHTML = '<div class="qfq-icj-spinner"></div>';
            target.appendChild(this.loadingEl);

            // Render content (use setTimeout to allow spinner to display)
            setTimeout(function() {
                // Check for validation error
                if (self.validationError) {
                    self.wrapper = self._renderError(self.validationError);
                } else if (mode === 'template') {
                    self.wrapper = self._renderTemplateMode();
                } else {
                    self.wrapper = self._renderEditorMode();
                }

                mainWrapper.appendChild(self.wrapper);
                target.appendChild(mainWrapper);

                // Hide spinner, show content
                if (self.loadingEl) {
                    self.loadingEl.remove();
                    self.loadingEl = null;
                }
                mainWrapper.style.display = 'block';

                // Dispatch ready event
                document.dispatchEvent(new CustomEvent('qfq-icj-ready', {
                    detail: { editor: self, error: self.validationError }
                }));
            }, 0);

            return mainWrapper;
        }

        _renderEditorMode() {
            var self = this;
            var wrapper = document.createElement('div');
            wrapper.className = 'qfq-icj-template qfq-icj-mode-editor';

            // Check if we should skip tabs (single empty category or single category)
            var categoryKeys = Array.from(this.categories.keys());
            var skipTabs = categoryKeys.length === 1 && (categoryKeys[0] === '' || categoryKeys[0] === '_default');

            if (skipTabs) {
                // No tabs - render fields directly
                var content = document.createElement('div');
                content.className = 'qfq-icj-no-tabs';

                var variables = this.categories.get(categoryKeys[0]);
                this._renderCategoryContent(content, variables);

                wrapper.appendChild(content);
            } else {
                // Render with tabs
                var tabBar = document.createElement('div');
                tabBar.className = 'qfq-icj-tab-bar';

                var tabContents = document.createElement('div');
                tabContents.className = 'qfq-icj-tab-contents';

                var isFirst = true;
                var tabClickHandler = function(e) { self._switchTab(e, wrapper); };
                for (var entry of this.categories) {
                    var category = entry[0];
                    var catVariables = entry[1];
                    var tabId = 'cat-' + (category || 'default') + '-' + Math.random().toString(36).substr(2, 9);

                    var tabBtn = document.createElement('button');
                    tabBtn.type = 'button';
                    tabBtn.className = 'qfq-icj-tab-btn' + (isFirst ? ' active' : '');
                    tabBtn.textContent = self._formatCategoryName(category || 'General');
                    tabBtn.dataset.tabId = tabId;
                    tabBtn.addEventListener('click', tabClickHandler);
                    tabBar.appendChild(tabBtn);

                    var tabContent = document.createElement('div');
                    tabContent.className = 'qfq-icj-tab-content' + (isFirst ? ' active' : '');
                    tabContent.dataset.tabId = tabId;

                    self._renderCategoryContent(tabContent, catVariables);
                    tabContents.appendChild(tabContent);

                    isFirst = false;
                }

                wrapper.appendChild(tabBar);
                wrapper.appendChild(tabContents);
            }

            return wrapper;
        }

        _renderTemplateMode() {
            var self = this;
            var wrapper = document.createElement('div');
            wrapper.className = 'qfq-icj-template qfq-icj-mode-template';

            var pre = document.createElement('pre');
            pre.className = 'qfq-icj-template-view';

            var lines = this.template.split('\n');

            for (var i = 0; i < lines.length; i++) {
                var line = lines[i];
                var lineEl = document.createElement('div');
                lineEl.className = 'qfq-icj-template-line';

                var varMatch = line.match(/^(\w+)(\s*=\s*)(.*)$/);
                if (varMatch) {
                    var name = varMatch[1];
                    var separator = varMatch[2];
                    var variable = this.variables.get(name);

                    if (variable) {
                        var nameSpan = document.createElement('span');
                        nameSpan.className = 'qfq-icj-tpl-var-name';
                        nameSpan.textContent = name;

                        var sepSpan = document.createElement('span');
                        sepSpan.className = 'qfq-icj-tpl-separator';
                        sepSpan.textContent = separator;

                        var input = self._createTemplateInput(variable);

                        lineEl.appendChild(nameSpan);
                        lineEl.appendChild(sepSpan);
                        lineEl.appendChild(input);
                    } else {
                        lineEl.textContent = line;
                    }
                } else if (line.startsWith('#')) {
                    lineEl.className += ' qfq-icj-tpl-comment';
                    lineEl.textContent = line;
                } else {
                    lineEl.textContent = line || '\u00A0';
                }

                pre.appendChild(lineEl);
            }

            wrapper.appendChild(pre);

            return wrapper;
        }

        _createTemplateInput(variable) {
            var self = this;
            var isFieldRequired = this.isRequired || this._getFieldFlag(variable, 'required');
            var isFieldReadonly = this._getFieldFlag(variable, 'readonly');
            var currentValue = this.getValue(variable.name, variable.category);
            var input;

            if (variable.inputType === 'select') {
                input = document.createElement('select');
                input.className = 'qfq-icj-tpl-input qfq-icj-tpl-select';
                for (var j = 0; j < variable.options.length; j++) {
                    var option = variable.options[j];
                    var opt = document.createElement('option');
                    opt.value = option;
                    opt.textContent = option;
                    opt.selected = option === currentValue;
                    input.appendChild(opt);
                }
            } else if (variable.inputType === 'checkbox') {
                input = document.createElement('select');
                input.className = 'qfq-icj-tpl-input qfq-icj-tpl-select';
                ['1', '0'].forEach(function(val) {
                    var opt = document.createElement('option');
                    opt.value = val;
                    opt.textContent = val;
                    opt.selected = val === currentValue;
                    input.appendChild(opt);
                });
            } else if (variable.inputType === 'textarea') {
                input = document.createElement('textarea');
                input.className = 'qfq-icj-tpl-input qfq-icj-tpl-textarea qfq-skip-dirty';
                input.value = currentValue;
                input.rows = 4;
                input.style.maxHeight = '200px';
                input.style.overflowY = 'auto';
                input.style.resize = 'vertical';
                input.dataset.icjTextarea = 'true';
            } else {
                input = document.createElement('input');
                input.type = variable.inputType === 'number' ? 'number' : 'text';
                input.className = 'qfq-icj-tpl-input';
                input.value = currentValue;
                input.size = Math.max(20, currentValue.length + 5);
            }

            input.dataset.variableName = variable.name;
            input.dataset.variableCategory = variable.category !== undefined ? variable.category : '';
            input.title = variable.description || variable.label || '';

            // Disable input if editor is in readonly mode or field is individually readonly
            if (this.isDisabled || isFieldReadonly) {
                input.disabled = true;
            }

            // Set required if editor is in required mode or field is individually required
            if (isFieldRequired) {
                input.required = true;
                input.dataset.required = 'yes';
                input.dataset.requiredError = this.requiredError || 'Required';
                input.dataset.error = 'Error';
            }

            input.addEventListener('change', function(e) { self._handleChange(e, variable); });
            input.addEventListener('input', function(e) {
                self._handleChange(e, variable);
                if (e.target.tagName === 'INPUT' && e.target.type === 'text') {
                    e.target.size = Math.max(20, e.target.value.length + 5);
                }
            });

            return input;
        }

        _renderCategoryContent(container, variables) {
            var self = this;
            var subcategories = new Map();

            for (var i = 0; i < variables.length; i++) {
                var variable = variables[i];
                var subcat = variable.subcategory || '_default';
                if (!subcategories.has(subcat)) {
                    subcategories.set(subcat, []);
                }
                subcategories.get(subcat).push(variable);
            }

            for (var entry of subcategories) {
                var subcatName = entry[0];
                var vars = entry[1];

                if (subcatName !== '_default' && subcategories.size > 1) {
                    var header = document.createElement('div');
                    header.className = 'qfq-icj-subcategory-header';
                    header.textContent = self._formatCategoryName(subcatName);
                    container.appendChild(header);
                }

                for (var j = 0; j < vars.length; j++) {
                    var field = self._renderField(vars[j]);
                    container.appendChild(field);
                }
            }
        }

        _switchTab(event, wrapper) {
            var tabId = event.target.dataset.tabId;
            this._activateTabById(tabId, wrapper);
        }

        _activateTabById(tabId, wrapper) {
            wrapper.querySelectorAll('.qfq-icj-tab-btn').forEach(function(btn) {
                btn.classList.toggle('active', btn.dataset.tabId === tabId);
            });

            wrapper.querySelectorAll('.qfq-icj-tab-content').forEach(function(content) {
                content.classList.toggle('active', content.dataset.tabId === tabId);
            });
        }

        /**
         * Activate the tab containing the given element
         * @param {HTMLElement} element - The element to find and activate its tab
         */
        activateTabForElement(element) {
            if (!this.wrapper) return;

            // Find the tab-content containing this element
            var tabContent = element.closest('.qfq-icj-tab-content');
            if (tabContent && tabContent.dataset.tabId) {
                this._activateTabById(tabContent.dataset.tabId, this.wrapper);
            }
        }

        /**
         * Get a field flag (__readonly, __required) from JSON values
         * This works in both template and JSON-only mode
         */
        _getFieldFlag(variable, flagName) {
            // First check variable property (set in JSON-only mode)
            if (variable[flagName]) return true;

            // Then check JSON values directly (works in template mode too)
            var category = variable.category !== undefined ? variable.category : '';
            if (this.values[category] && this.values[category][variable.name]) {
                var data = this.values[category][variable.name];
                var icjFlag = data['__' + flagName];
                return icjFlag === true || icjFlag === 'true';
            }
            return false;
        }

        _renderField(variable) {
            var self = this;
            var isFieldRequired = this.isRequired || this._getFieldFlag(variable, 'required');
            var isFieldReadonly = this._getFieldFlag(variable, 'readonly');
            var field = document.createElement('div');
            // Add form-group class for Bootstrap Validator error display
            field.className = 'qfq-icj-field form-group';

            var label = document.createElement('label');
            label.className = 'qfq-icj-label' + (isFieldRequired ? ' qfq-icj-required' : '');
            // Include category in ID to ensure uniqueness (field names like 'enabled' appear in multiple categories)
            var catPrefix = variable.category ? variable.category + '-' : '';
            label.htmlFor = 'qfq-icj-' + catPrefix + variable.name;
            label.textContent = variable.label || variable.name;
            if (variable.description) label.title = variable.description;

            var inputWrapper = document.createElement('div');
            inputWrapper.className = 'qfq-icj-input-wrapper';

            var input;
            var currentValue = this.getValue(variable.name, variable.category);

            switch (variable.inputType) {
                case 'select':
                    input = document.createElement('select');
                    for (var j = 0; j < variable.options.length; j++) {
                        var option = variable.options[j];
                        var opt = document.createElement('option');
                        opt.value = option;
                        opt.textContent = option;
                        opt.selected = option === currentValue;
                        input.appendChild(opt);
                    }
                    break;

                case 'checkbox':
                    input = document.createElement('input');
                    input.type = 'checkbox';
                    input.checked = currentValue === '1' || currentValue === 'true' || currentValue === true;
                    break;

                case 'number':
                    input = document.createElement('input');
                    input.type = 'number';
                    input.value = currentValue;
                    break;

                case 'textarea':
                    input = document.createElement('textarea');
                    // Convert literal \n to actual newlines for display in textarea
                    input.value = currentValue.replace(/\\n/g, '\n');
                    input.rows = 4;
                    input.style.maxHeight = '200px';
                    input.style.overflowY = 'auto';
                    input.style.resize = 'vertical';
                    // Mark as ICJ field to prevent QFQ form handlers from processing it
                    input.classList.add('qfq-skip-dirty');
                    input.dataset.icjTextarea = 'true';
                    break;

                default:
                    input = document.createElement('input');
                    input.type = 'text';
                    input.value = currentValue;
                    break;
            }

            // Use catPrefix from above for unique ID
            input.id = 'qfq-icj-' + catPrefix + variable.name;
            // Don't set name attribute - the original hidden textarea holds the full JSON for submission
            // Setting name would cause these inputs to be submitted separately
            // input.name = variable.name;
            // Add form-control class for Bootstrap Validator compatibility
            // Use classList.add to preserve any classes added in the switch (like qfq-skip-dirty for textareas)
            input.classList.add('qfq-icj-input', 'form-control');
            input.dataset.variableName = variable.name;
            input.dataset.variableCategory = variable.category !== undefined ? variable.category : '';

            // Disable input if editor is in readonly mode or field is individually readonly
            if (this.isDisabled || isFieldReadonly) {
                input.disabled = true;
            }

            // Set required if editor is in required mode or field is individually required
            if (isFieldRequired) {
                input.required = true;
                input.dataset.required = 'yes';
                input.dataset.requiredError = this.requiredError || 'Required';
                input.dataset.error = 'Error';
            }

            input.addEventListener('change', function(e) { self._handleChange(e, variable); });
            input.addEventListener('input', function(e) { self._handleChange(e, variable); });

            inputWrapper.appendChild(input);

            // Add help-block for Bootstrap Validator error messages
            var helpBlock = document.createElement('div');
            helpBlock.className = 'help-block with-errors';
            inputWrapper.appendChild(helpBlock);

            if (variable.description) {
                var desc = document.createElement('small');
                desc.className = 'qfq-icj-description';
                desc.textContent = variable.description;
                inputWrapper.appendChild(desc);
            }

            field.appendChild(label);
            field.appendChild(inputWrapper);

            return field;
        }

        _handleChange(event, variable) {
            var value;

            if (variable.inputType === 'checkbox' && event.target.type === 'checkbox') {
                value = event.target.checked ? '1' : '0';
            } else if (variable.inputType === 'textarea') {
                // Convert actual newlines back to literal \n for storage
                value = event.target.value.replace(/\n/g, '\\n');
            } else {
                value = event.target.value;
            }

            // Pass category to setValue to fix duplicate field names across categories
            this.setValue(variable.name, value, variable.category);

            // Update textarea value for form submission
            if (this.textarea) {
                this.textarea.value = this.getValuesIcj();

                // Manually trigger dirty state (don't pass textarea because it has qfq-skip-dirty)
                if (typeof QfqNS !== 'undefined' && QfqNS.form && typeof QfqNS.form.markChanged === 'function') {
                    QfqNS.form.markChanged();
                }
            }

            document.dispatchEvent(new CustomEvent('qfq-icj-change', {
                detail: { name: variable.name, value: value, allValues: this.values }
            }));
        }

        _formatCategoryName(name) {
            return name
                .replace(/([A-Z])/g, ' $1')
                .replace(/[_-]/g, ' ')
                .replace(/^\w/, function(c) { return c.toUpperCase(); })
                .trim();
        }

        collectValues() {
            if (!this.wrapper) return this.values;

            var self = this;
            var inputs = this.wrapper.querySelectorAll('[data-variable-name]');
            inputs.forEach(function(input) {
                var name = input.dataset.variableName;
                // Use category from dataset to fix duplicate field names across categories
                var category = input.dataset.variableCategory;

                var value;
                if (input.type === 'checkbox') {
                    value = input.checked ? '1' : '0';
                } else {
                    value = input.value;
                }

                // Pass category to setValue
                self.setValue(name, value, category);
            });

            return this.values;
        }

        collectValuesIcj() {
            this.collectValues();
            return this.getValuesIcj();
        }

        updateTextarea() {
            if (this.textarea) {
                this.collectValues();
                this.textarea.value = this.getValuesIcj();
            }
        }
    }

    /**
     * Initialize JSON editors for all textarea.qfq-icj elements
     *
     * @param {NodeList|Array} icjElements - Elements with class .qfq-icj
     * @param {object} n - Namespace reference
     */
    // Store all editors for later access
    var icjEditors = [];

    var qfqIcj = function (icjElements, n) {
        if (icjElements !== undefined && icjElements.length > 0) {
            icjElements.forEach(function(textarea) {
                if (textarea.tagName === 'TEXTAREA') {
                    // Add qfq-skip-dirty to prevent dirty detection during initialization
                    textarea.classList.add('qfq-skip-dirty');
                    var editor = new QfqConfigTemplate(textarea);
                    editor.render(null, 'editor', 'qfq-form-grey-2');
                    icjEditors.push(editor);
                }
            });

            // Update validator to include dynamically created inputs
            // The render() method uses setTimeout, so inputs are added to DOM asynchronously
            // We need to wait for the qfq-icj-ready event
            var $form = $(icjElements[0]).closest('form');
            if ($form.length) {
                var updateValidator = function() {
                    var validator = $form.data('bs.validator');
                    if (validator) {
                        $form.validator('update');
                        return true;
                    }
                    return false;
                };

                // Wait for qfq-icj-ready event (fired after inputs are added to DOM)
                document.addEventListener('qfq-icj-ready', function onReady() {
                    document.removeEventListener('qfq-icj-ready', onReady);

                    // Try immediately in case validator is already initialized
                    if (!updateValidator()) {
                        // If window is already loaded, use setTimeout as fallback
                        if (document.readyState === 'complete') {
                            setTimeout(updateValidator, 100);
                        } else {
                            // Wait for window.load when validator gets initialized
                            $(window).on('load.qfqIcj', function() {
                                updateValidator();
                                $(window).off('load.qfqIcj');
                            });
                        }
                    }

                    // Listen for save button click to activate tab with first invalid JSON input
                    var activateFirstInvalidTab = function() {
                        // Find first invalid JSON input - check both :invalid and empty required
                        var firstInvalid = null;
                        $form.find('.qfq-icj-input[required]').each(function() {
                            if (!this.validity.valid || (this.value === '' && this.required)) {
                                firstInvalid = this;
                                return false; // break
                            }
                        });

                        if (firstInvalid) {
                            // Find the tab content containing this element
                            var tabContent = firstInvalid.closest('.qfq-icj-tab-content');

                            if (tabContent && tabContent.dataset.tabId) {
                                // Find the wrapper
                                var wrapper = firstInvalid.closest('.qfq-icj-template');
                                if (wrapper) {
                                    var tabId = tabContent.dataset.tabId;
                                    // Activate tab directly
                                    wrapper.querySelectorAll('.qfq-icj-tab-btn').forEach(function(btn) {
                                        btn.classList.toggle('active', btn.dataset.tabId === tabId);
                                    });
                                    wrapper.querySelectorAll('.qfq-icj-tab-content').forEach(function(content) {
                                        content.classList.toggle('active', content.dataset.tabId === tabId);
                                    });
                                }
                            }

                            // Focus the element after tab switch
                            var elementToFocus = firstInvalid;
                            setTimeout(function() {
                                elementToFocus.focus();
                            }, 100);
                        }
                    };

                    // Listen on save button click using event delegation
                    // Also try document-level delegation as fallback
                    $(document).on('click', '[id^="save-button"]', activateFirstInvalidTab);
                });
            }
        }
    };

    // Export QfqConfigTemplate class
    n.Helper.QfqConfigTemplate = QfqConfigTemplate;

    // Export initialization function
    n.Helper.qfqIcj = qfqIcj;

})(QfqNS);
/**
 * @author Enis Nuredini <enis.nuredini@math.uzh.ch>
 */

/* global console */
/* global qfqChat */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    let qfqMerge = function (mergeWindowElement) {
        //let chatWindowsElements = document.getElementsByClassName("qfq-chat-window");
        let mergeWindow = null;
        if (mergeWindowElement !== undefined) {
            mergeWindow = new MergeWindow(mergeWindowElement);
        }
        return mergeWindow;
    }

    class MergeWindow {
        constructor(mergeWindowElement) {
            let that = this;
            this.mergeWindow = mergeWindowElement;
            this.baseUrl = mergeWindowElement.getAttribute("data-base-url");
            this.restCallUrl = this.baseUrl + 'typo3conf/ext/qfq/Classes/Api/dataReport.php?s=';
            this.dataSipRules = mergeWindowElement.getAttribute("data-sip-rules");
            this.merged = false;
            this.primaryElements = {};

            let primaryDeleteBtn = mergeWindowElement.querySelectorAll("button[disabled='disabled'][data-primary]");
            if (primaryDeleteBtn !== null) {
                Array.from(primaryDeleteBtn).forEach(btn => {
                    const primaryId = btn.getAttribute('data-primary');
                    const sip = btn.getAttribute('data-sip');
                    const sipUpdate = btn.getAttribute('data-sip-update');
                    const sipDelete = btn.getAttribute('data-sip-delete');
                    this.primaryElements[primaryId] = {};
                    this.primaryElements[primaryId].deleteBtn = btn;
                    this.primaryElements[primaryId].id = primaryId;
                    this.primaryElements[primaryId].sipUrl = this.restCallUrl + sip;
                    this.primaryElements[primaryId].sipUpdate = sipUpdate;
                    this.primaryElements[primaryId].sipDelete = sipDelete;
                    this.primaryElements[primaryId].relatedCheckboxes = mergeWindowElement.querySelectorAll(`input[type='checkbox'][data-id='${primaryId}']`);
                });
            }

            this.allCheckboxes = mergeWindowElement.querySelectorAll(".merge-window input[type='checkbox']");
            this.mergeBtn = mergeWindowElement.querySelector('#merge-window-btn');
            this.primaryCheckboxes = Array.from(mergeWindowElement.querySelectorAll('[name="checkAll"]'));
            this.checkboxes = mergeWindowElement.querySelectorAll("input[type='checkbox'][data-id]");
            this.ruleBtn = mergeWindowElement.querySelector('#merge-window-update-rule-btn');

            this.init();
        }

        init() {
            let that = this;

            // Iterate over primary elements and initialize delete btn
            Object.values(this.primaryElements).forEach(element => {
                // Check for existing merge data and enable delete buttons
                if (that.checkData(element.id)) {
                    element.deleteBtn.disabled = false;
                }

                element.deleteBtn.addEventListener('click', async function () {
                    if (that.checkData(element.id) && !that.merged) {
                        if (confirm("Are you sure you want to delete this data?")) {
                            console.log('Delete data click');
                            try {
                                const SQL_DELETE_PRIMARY = await that.makeApiCall(element.sipUrl, this, 'delete');
                            } catch (error) {
                            }
                        }
                    }
                });
            });

            // Initialize rule btn
            if (this.ruleBtn !== null) {
                this.ruleBtn.addEventListener('click', async function () {
                    try {
                        const sip_rules_url = that.restCallUrl + that.dataSipRules;
                        const SQL_INSERT = await that.makeApiCall(sip_rules_url, this, 'updateRules');
                    } catch (error) {
                    }
                });
            }

            // Initialize rest
            this.initTableContent();
            this.initCheckboxes(this);
            if (this.mergeBtn !== null) {
                this.initMergeBtn(this);
            }
        }

        checkData(dataId) {
            let foundCheckboxes = this.primaryElements[dataId].relatedCheckboxes;
            return foundCheckboxes.length === 0;
        }

        initTableContent() {
            this.mergeWindow.querySelectorAll('.merge-table-content').forEach(content => {
                let wrapper = content.parentElement;
                let button = wrapper.nextElementSibling;
                let icon = button.querySelector('i');
                if (content.scrollHeight > 150) {
                    button.classList.remove('hidden');
                } else {
                    button.classList.add('hidden');
                }
                button.addEventListener('click', function () {
                    if (!content.style.maxHeight || content.style.maxHeight === '150px') {
                        content.style.maxHeight = content.scrollHeight + 'px';
                        icon.classList.remove('fa-angle-double-down');
                        icon.classList.add('fa-angle-double-up');
                    } else {
                        content.style.maxHeight = '150px';
                        icon.classList.add('fa-angle-double-down');
                        icon.classList.remove('fa-angle-double-up');
                    }
                });
            });
        }

        initCheckboxes(that) {
            // Function to set initial state of a checkbox
            const setInitialState = (el) => {
                if (el.hasAttribute('checked')) {
                    el.dataset.state = "checked";
                    el.checked = true;
                } else if (el.dataset.state === "indeterminate") {
                    el.indeterminate = true;
                    // Note: indeterminate checkboxes might also be checked or unchecked in terms of their value submission.
                    // Adjust the following line if you have a convention for this.
                } else {
                    el.dataset.state = "unchecked";
                    el.checked = false;
                    el.unchecked = true;
                }
            };

            this.allCheckboxes.forEach(el => {
                // Set initial state for checkboxes
                setInitialState(el);
            });

            this.primaryCheckboxes.forEach(el => el.addEventListener('click', (e) => {
                let currentState = el.dataset.state || "unchecked";
                let nextState = currentState === "checked" ? "indeterminate" : (currentState === "indeterminate" ? "unchecked" : "checked");
                el.dataset.state = nextState;
                el.indeterminate = nextState === "indeterminate";
                el.checked = nextState === "checked";

                that.primaryElements[el.id].relatedCheckboxes.forEach(cb => {
                    cb.dataset.state = nextState;
                    cb.indeterminate = nextState === "indeterminate";
                    cb.checked = nextState === "checked";
                    cb.unchecked = nextState === "unchecked";

                    let parentNode = cb.parentNode.parentNode;
                    parentNode.classList.remove('bg-success', 'bg-danger', 'bg-warning');
                    if (nextState === "checked") {
                        parentNode.classList.add('bg-success');
                    } else if (nextState === "indeterminate") {
                        parentNode.classList.add('bg-warning');
                    } else {
                        parentNode.classList.add('bg-danger');
                    }
                });
            }));

            Object.values(that.primaryElements).forEach(element => {
                Array.from(element.relatedCheckboxes).forEach(cb => cb.addEventListener('click', () => {
                    let currentState = cb.dataset.state || "unchecked";
                    let nextState = currentState === "checked" ? "indeterminate" : (currentState === "indeterminate" ? "unchecked" : "checked");
                    cb.dataset.state = nextState;
                    cb.indeterminate = nextState === "indeterminate";
                    cb.checked = nextState === "checked";
                    cb.unchecked = nextState === "unchecked";

                    let parentNode = cb.parentNode.parentNode;
                    parentNode.classList.remove('bg-success', 'bg-danger', 'bg-warning');
                    if (nextState === "checked") {
                        parentNode.classList.add('bg-success');
                    } else if (nextState === "indeterminate") {
                        parentNode.classList.add('bg-warning');
                    } else {
                        parentNode.classList.add('bg-danger');
                    }
                }));
            });
        }

        initMergeBtn(that) {
            this.mergeBtn.addEventListener('click', async function () {
                if (that.merged) {
                    return;
                }

                if (confirm("Are you sure you want to process the selected checkboxes?")) {
                    let primaryId = that.mergeWindow.querySelector('#primaryId').value;
                    let checkedIds = {};
                    let uncheckedIds = {};
                    let columnSearch = {};

                   that.checkboxes.forEach(cb => {
                        let dataTable = cb.getAttribute('data-table');
                        let dataId = cb.getAttribute('id');
                        let columns = cb.getAttribute('data-column-found');

                        if (columnSearch[dataTable] === undefined) {
                            columnSearch[dataTable] = columns;
                        }

                        if (cb.checked) {
                            checkedIds[dataTable] = checkedIds[dataTable] ? checkedIds[dataTable] + ',' + dataId : dataId;
                        } else if (cb.unchecked) {
                            uncheckedIds[dataTable] = uncheckedIds[dataTable] ? uncheckedIds[dataTable] + ',' + dataId : dataId;
                        }
                    });

                    console.log("Checked IDs: ", checkedIds);
                    console.log("Unchecked IDs: ", uncheckedIds);

                    await that.processMerge(that, primaryId, checkedIds, columnSearch, 'update');
                    await that.processMerge(that, primaryId, uncheckedIds, columnSearch, 'delete');
                }
            });
        }

        async processMerge(that, primaryId, ids, columnSearch, type) {
            for (let dataTable in ids) {
                if (ids.hasOwnProperty(dataTable)) {
                    const dateValues = '&tableName=' + dataTable + '&columnIdList=' + ids[dataTable] + '&columnSearch=' + columnSearch[dataTable];
                    let sip = null;
                    if (type === 'update') {
                        console.log(`Processing UPDATE ${dataTable} with IDs: ${ids[dataTable]}`);
                        sip = that.primaryElements[primaryId].sipUpdate;
                    } else {
                        console.log(`Processing DELETE FROM ${dataTable} with IDs: ${ids[dataTable]}`);
                        sip = that.primaryElements[primaryId].sipDelete;
                    }
                    const url_sip = that.restCallUrl + sip + dateValues;
                    try {
                        const SQL_EXECUTE = await that.makeApiCall(url_sip, that.mergeBtn, 'update');
                    } catch (error) {
                    }
                }
            }
        }

        async makeApiCall(url, btn, type = '', body = '') {
            btn.classList.remove('btn-default');
            btn.classList.add('btn-warning');
            const response = await fetch(url, {method: 'POST', body: body});

            if (!response.ok) {
                btn.classList.remove('btn-default');
                btn.classList.remove('btn-warning');
                btn.classList.add('btn-danger');
                throw new Error();
            } else {
                btn.classList.remove('btn-default');
                btn.classList.remove('btn-warning');
                btn.classList.add('btn-success');

                // Case of update delete records
                if (type === 'update') {
                    this.merged = true;
                    this.allCheckboxes.forEach(checkbox => {
                        checkbox.disabled = true;
                    });
                }

                // Case of delete primary record
                if (type === 'delete') {
                    let idP = btn.getAttribute('data-id');
                    let optionElement = this.mergeWindow.querySelector('select[name="primaryId"] option[value="' + idP + '"]');
                    optionElement.remove();
                }
            }

            const data = await response.text();
            console.log(data);
            return data;
        }
    }

    n.qfqMerge = qfqMerge;

})(QfqNS.Helper);
/**
 * QFQ wrapper around the upstream nchan-messages JS Messenger.
 * Consumes the IIFE bundle exposed as window.NchanMessenger (built by esbuild
 * from node_modules/nchan-messages/js/index.js and concatenated first into
 * qfq.debug.js). Preserves the pre-existing DOM conventions used by Reports:
 *   .messenger-output, .messenger-input
 *   data-ws-group, data-ws-pub, data-ws-sub, data-ws-params, data-baseurl,
 *   data-ws-action, data-ws-payload
 * Adds data-ws-token for session-bound auth (minted server-side via the
 * _messengerToken report column).
 *
 * @author Enis Nuredini <enis.nuredini@math.uzh.ch>
 */

/* global console */
/* global qfqMessenger */
/* global $ */

/**
 * Qfq Namespace
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

/**
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    // Connection states from upstream nchan-messages ConnectionState enum.
    const STATUS_TO_STRING = ['waiting', 'connecting', 'configuring', 'connected', 'closing', 'closed'];

    // Pull upstream classes from the esbuild-bundled IIFE
    const Nchan = window.NchanMessenger || null;
    if (!Nchan) {
        // Degrade gracefully — let Reports that don't use messenger still work
        n.qfqMessenger = function () {
            console.warn('NchanMessenger bundle missing — qfqMessenger is disabled');
            return null;
        };
        return;
    }
    const { Messenger, UrlFactory } = Nchan;

    /**
     * MessengerElement - Base class for messenger input and output elements
     */
    class MessengerElement {
        constructor(element, messenger) {
            this.element = element;
            this.messenger = messenger;
            this.pubChannels = this.parseChannels(element.dataset.wsPub);
            this.subChannels = this.parseChannels(element.dataset.wsSub);
            this.wsGroupId = element.dataset.wsGroup || 'default';
            this.params = element.dataset.wsParams || '';
            this.baseUrl = element.dataset.baseurl || window.location.origin;
            this.action = element.dataset.wsAction || 'message';
            this.payload = element.dataset.wsPayload || '';
            this.tablesorterController = new QfqNS.TablesorterController();
        }

        parseChannels(channelStr) {
            return channelStr ? channelStr.split(',').map(ch => ch.trim()).filter(ch => ch) : [];
        }

        initialize() {
            // to be implemented by subclasses
        }
    }

    /**
     * MessengerOutput - Handles displaying messages
     */
    class MessengerOutput extends MessengerElement {
        constructor(element, messenger) {
            super(element, messenger);
            this.initialize();
        }

        initialize() {
            // Subscribe with channel filter — only receives messages from
            // this element's specific sub-channels. The upstream Messenger
            // maps nchan channel indices to names via _channels.sub (populated
            // from the onsub hook's 'hello' ctl message) and dispatches only
            // to subscribers registered for the matching channel.
            // Falls back to receiving all messages if _channels.sub isn't
            // populated (e.g. dev setup without nchan_subscribe_request).
            const channels = this.subChannels.length > 0 ? this.subChannels : undefined;
            this.messenger.subscribe({
                handleMessage: (msg) => {
                    let mode = msg.name || '';
                    switch (mode) {
                        case 'report':
                            this.getReportOutput(msg);
                            break;
                        case 'messenger':
                            this.appendMessage(msg);
                            break;
                        default:
                            this.replaceOutput(msg);
                    }
                },
                handleRequest: (request) => this.handleRequest(request)
            }, channels);
        }

        replaceOutput(message) {
            this.element.innerHTML = '';

            if (message.payload) {
                if (typeof message.payload === 'string') {
                    if (message.isHtml || (message.payload.trim().startsWith('<') && message.payload.trim().endsWith('>'))) {
                        this.element.insertAdjacentHTML('beforeend', message.payload);
                    } else {
                        const textNode = document.createTextNode(message.payload);
                        this.element.appendChild(textNode);
                    }
                } else if (typeof message.payload === 'object') {
                    if (message.payload.html) {
                        this.element.insertAdjacentHTML('beforeend', message.payload.html);
                    } else if (message.payload.text) {
                        const textNode = document.createTextNode(message.payload.text);
                        this.element.appendChild(textNode);
                    } else {
                        const pre = document.createElement('pre');
                        pre.textContent = JSON.stringify(message.payload, null, 2);
                        this.element.appendChild(pre);
                    }
                }
            } else if (message.html) {
                this.element.insertAdjacentHTML('beforeend', message.html);
            } else if (message.text) {
                const textNode = document.createTextNode(message.text);
                this.element.appendChild(textNode);
            } else {
                const textNode = document.createTextNode('Empty message');
                this.element.appendChild(textNode);
            }
        }

        getReportOutput(message) {
            const apiReport = this.baseUrl + (this.baseUrl.endsWith('/') ? '' : '/') + 'typo3conf/ext/qfq/Classes/Api/dataReport.php?uid=' + message.payload + '&' + this.params;

            fetch(apiReport, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({})
            })
                .then(response => response.text())
                .then(data => {
                    this.element.innerHTML = '';
                    this.element.insertAdjacentHTML('beforeend', data);
                    if ($(this.element).find('table.tablesorter').length > 0) {
                        this.tablesorterController.setup($(this.element).find('table.tablesorter'));
                    }
                })
                .catch(error => {
                    console.error('Error:', error);
                });
        }

        appendMessage(message) {
            const msgElement = document.createElement('div');
            msgElement.className = `message message-${message.type || 'unknown'}`;

            const timestamp = message.timestamp ?
                new Date(message.timestamp).toLocaleTimeString() :
                new Date().toLocaleTimeString();

            const channel = message.channel ? `[${message.channel}] ` : '';
            const name = message.name ? `<strong>${message.name}:</strong> ` : '';

            let payloadContent = '';
            if (message.payload) {
                if (typeof message.payload === 'object') {
                    payloadContent = JSON.stringify(message.payload, null, 2);
                } else {
                    payloadContent = message.payload;
                }
            }

            msgElement.innerHTML = `
                <span class="timestamp">${timestamp}</span>
                <span class="channel">${channel}</span>
                ${name}
                <pre class="payload">${payloadContent}</pre>
            `;

            this.element.appendChild(msgElement);
            this.element.scrollTop = this.element.scrollHeight;
        }

        async handleRequest(request) {
            this.replaceOutput({
                type: 'request',
                timestamp: request.timestamp,
                channel: request.channel,
                payload: {
                    id: request.requestId,
                    reply: request.requester,
                    payload: request.payload
                }
            });

            const progressWait = request.payload.progress | 0;
            const responseWait = request.payload.response | 0;

            await new Promise(res => setTimeout(res, progressWait));
            await request.notify('Processing request...');

            await new Promise(res => setTimeout(res, responseWait));
            await request.respond('Request completed');
        }
    }

    /**
     * MessengerInput - Handles user input for sending messages
     */
    class MessengerInput extends MessengerElement {
        constructor(element, messenger) {
            super(element, messenger);

            this.payloadChannelMap = null;
            if (this.action === 'report' && this.payload.includes('|') &&
                this.pubChannels.length > 1) {
                this.preprocessReportPayload();
            }

            this.initialize();
        }

        initialize() {
            this.createInputControls();
            this.setupEventListeners();
        }

        createInputControls() {
            let inputType = 'default';
            if (this.element != null && this.element.classList.contains('drop-zone')) {
                inputType = 'drop-zone';
            }

            switch (inputType) {
                case 'drop-zone':
                    this.createDragAndDropControls();
                    break;
                default:
                    this.createMessengerInputControls();
                    break;
            }
        }

        createDragAndDropControls() {
            const dropArea = this.element;
            if (!dropArea.innerHTML.trim()) {
                const dropZone = document.createElement('div');
                dropZone.className = 'ws-drop-area';

                dropZone.innerHTML = `
            <div class="drop-message">
                <span class="drop-icon">📋</span>
                <p>Drag and drop</p>
            </div>
            <div class="dropped-items-container"></div>
        `;
                dropArea.appendChild(dropZone);

                const handleDragOver = function(e) {
                    e.preventDefault();
                    e.stopPropagation();
                    dropZone.classList.add('active');
                }.bind(this);

                const handleDragLeave = function() {
                    dropZone.classList.remove('active');
                }.bind(this);

                dropZone.addEventListener('dragover', handleDragOver);
                dropZone.addEventListener('dragleave', handleDragLeave);
                dropZone.addEventListener('drop', function(){
                    dropZone.classList.remove('active');
                });
            }

            this.element.addEventListener('drop', (e) => {
                e.preventDefault();
                e.stopPropagation();
                const content = e.dataTransfer.getData("text/plain");
                const contentElement = document.getElementById(content);
                if (contentElement) {
                    contentElement.getAttribute("data-record-id");
                }
                console.log(content);

                this.sendMessage(false);
            });
        }

        createMessengerInputControls() {
            const connectionControls = document.createElement('div');
            connectionControls.className = 'connection-controls';
            connectionControls.innerHTML = `
                <button class="connect-btn btn btn-default">Connect</button>
                <button class="disconnect-btn btn btn-default">Disconnect</button>
                <span class="status-text">Status: ${STATUS_TO_STRING[this.messenger.state] || 'unknown'}</span>
            `;
            this.element.appendChild(connectionControls);

            const messageInput = document.createElement('div');
            messageInput.className = 'message-input';
            messageInput.innerHTML = `
                <div class="input-row">
                    <label>Name: <input type="text" class="action-input" value="message"></label>
                </div>
                <div class="input-row">
                    <label>Payload: <textarea class="payload-input" rows="4" placeholder="JSON or text"></textarea></label>
                </div>
                <div class="input-row">
                    <button class="send-message-btn btn btn-default">Send Message</button>
                    <button class="send-request-btn btn btn-default">Send Request</button>
                </div>
            `;
            this.element.appendChild(messageInput);

            if (this.pubChannels.length > 1) {
                const channelSelect = document.createElement('div');
                channelSelect.className = 'channel-select';
                channelSelect.innerHTML = `
                    <label>Publish Channel:
                        <select class="channel-selector">
                            ${this.pubChannels.map(ch => `<option value="${ch}">${ch}</option>`).join('')}
                        </select>
                    </label>
                `;
                messageInput.insertBefore(channelSelect, messageInput.querySelector('.input-row:last-child'));
            }

            this.element.querySelector('.connect-btn').addEventListener('click', () => {
                this.messenger.open().then(
                    () => this.updateStatus('Connected'),
                    (err) => this.updateStatus(`Connection failed: ${err}`)
                );
            });

            this.element.querySelector('.disconnect-btn').addEventListener('click', () => {
                this.messenger.close().then(
                    () => this.updateStatus('Disconnected'),
                    (err) => this.updateStatus(`Disconnection failed: ${err}`)
                );
            });

            this.element.querySelector('.send-message-btn').addEventListener('click', () => {
                this.sendMessage(false);
            });

            this.element.querySelector('.send-request-btn').addEventListener('click', () => {
                this.sendMessage(true);
            });
        }

        setupEventListeners() {
            this.messenger.onstatechange = (evt) => {
                this.updateStatus(STATUS_TO_STRING[evt.currentValue] || 'unknown');
            };
        }

        updateStatus(status) {
            const statusText = this.element.querySelector('.status-text');
            if (statusText) {
                statusText.textContent = `Status: ${status}`;
            }
        }

        sendMessage(isRequest) {
            const nameInput = this.element.querySelector('.action-input');
            const name = nameInput ? nameInput.value : this.action;
            const payloadInput = this.element.querySelector('.payload-input');
            let payload = payloadInput ? payloadInput.value : this.payload;

            if (name === 'report' && this.payloadChannelMap) {
                this.handleMultiplePublish(name);
                return;
            }

            if (payload.trim().startsWith('{')) {
                try {
                    payload = JSON.parse(payload);
                } catch (e) {
                    // Keep as string if parsing fails
                }
            }

            let channels = this.pubChannels;
            const channelSelector = this.element.querySelector('.channel-selector');
            if (channelSelector) {
                channels = [channelSelector.value];
            }

            if (isRequest) {
                this.messenger.request(name, payload, { channels }).then(
                    async (response) => {
                        response.onprogress = (evt) => {
                            console.log('Progress:', evt.data);
                        };
                        const result = await response.payload();
                        console.log('Response:', result);
                    },
                    (err) => console.error('Request failed:', err)
                );
            } else {
                this.messenger.publish(name, payload, { channels });
            }

            if (payloadInput !== null) {
                payloadInput.value = '';
            }
        }

        preprocessReportPayload() {
            const payloadParts = this.payload.split('|');
            this.payloadChannelMap = [];

            payloadParts.forEach((part, index) => {
                if (index < this.pubChannels.length) {
                    let parsedPart = part;
                    if (part.trim().startsWith('{')) {
                        try {
                            parsedPart = JSON.parse(part);
                        } catch (e) {
                            // Keep as string if parsing fails
                        }
                    }

                    this.payloadChannelMap.push({
                        payload: parsedPart,
                        channel: this.pubChannels[index]
                    });
                }
            });
        }

        handleMultiplePublish(name) {
            this.payloadChannelMap.forEach(mapping => {
                this.messenger.publish(name, mapping.payload, {
                    channels: [mapping.channel]
                });
            });
        }
    }

    /**
     * MessengerApplication - Main application class.
     * Scans the DOM for .messenger-output/.messenger-input elements, groups
     * them by data-ws-group, mints one upstream Messenger per group with the
     * union of pub/sub channels and all supplied auth tokens.
     */
    class MessengerApplication {
        constructor(config = {}) {
            this.config = config;
            this.messengers = new Map();
            this.elements = [];
            this.endpoint = config.endpoint || '/messenger';
            this.baseUrl = config.baseUrl || window.location.origin;
        }

        initialize() {
            const outputElements = document.querySelectorAll('.messenger-output');
            const inputElements = document.querySelectorAll('.messenger-input');

            this.setupMessengersForElements([...outputElements, ...inputElements]);

            this.setupOutputElements(outputElements);
            this.setupInputElements(inputElements);

            if (this.config.autoConnect !== false) {
                this.connectAll();
            }
        }

        setupMessengersForElements(elements) {
            const groups = new Map();

            elements.forEach(el => {
                const wsGroupId = el.dataset.wsGroup || 'default';
                if (!groups.has(wsGroupId)) {
                    groups.set(wsGroupId, {
                        pub: new Set(),
                        sub: new Set(),
                        tokens: new Set(),
                        baseUrl: null
                    });
                }
                const bucket = groups.get(wsGroupId);

                if (el.dataset.wsPub) {
                    el.dataset.wsPub.split(',').map(c => c.trim()).filter(Boolean).forEach(c => bucket.pub.add(c));
                }
                if (el.dataset.wsSub) {
                    el.dataset.wsSub.split(',').map(c => c.trim()).filter(Boolean).forEach(c => bucket.sub.add(c));
                }
                if (el.dataset.wsToken) {
                    bucket.tokens.add(el.dataset.wsToken);
                }
                if (!bucket.baseUrl && el.dataset.baseurl) {
                    bucket.baseUrl = el.dataset.baseurl;
                }
            });

            groups.forEach((bucket, wsGroupId) => {
                // Resolve baseHref through window.location.origin so relative
                // values from TYPO3 ({{baseUrl:Y}} often yields '/subdir/')
                // work, not only absolute URLs. Empty falls back to origin.
                const baseHrefRaw = bucket.baseUrl || this.baseUrl || window.location.origin;
                let urlBase;
                try {
                    const absBase = new URL(baseHrefRaw, window.location.origin);
                    const baseWithSlash = absBase.href.endsWith('/') ? absBase.href : absBase.href + '/';
                    // Strip a leading slash so the endpoint resolves under the
                    // base path instead of replacing it.
                    const endpointPath = this.endpoint.replace(/^\//, '').replace(/\/?$/, '/');
                    urlBase = new URL(endpointPath, baseWithSlash);
                } catch (e) {
                    console.error('qfqMessenger: invalid base URL for group', wsGroupId, baseHrefRaw, e);
                    return;
                }

                const urlFactory = new UrlFactory(urlBase, wsGroupId, {
                    pub: [...bucket.pub],
                    sub: [...bucket.sub]
                });

                const messenger = new Messenger(urlFactory, { logger: console });

                // Workaround: without nchan_subscribe_request (disabled because
                // nchan's subrequest handler crashes on nginx <= 1.30), the
                // upstream Messenger never receives the 'hello' ctl message that
                // populates _channels. Set them manually so handleSocketMessage
                // passes incoming messages to processMessage instead of queuing
                // them forever in _received.
                messenger._channels.sub = [...bucket.sub];
                messenger._channels.pub = [...bucket.pub];

                if (bucket.tokens.size > 0) {
                    messenger.tokens.add(...bucket.tokens);
                } else {
                    console.warn(`qfqMessenger: group "${wsGroupId}" has no data-ws-token — auth will be rejected by nchan`);
                }

                this.messengers.set(wsGroupId, messenger);
            });
        }

        setupOutputElements(outputElements) {
            outputElements.forEach(el => {
                const wsGroupId = el.dataset.wsGroup || 'default';
                const messenger = this.messengers.get(wsGroupId);
                if (messenger) {
                    const output = new MessengerOutput(el, messenger);
                    this.elements.push(output);
                }
            });
        }

        setupInputElements(inputElements) {
            inputElements.forEach(el => {
                const wsGroupId = el.dataset.wsGroup || 'default';
                const messenger = this.messengers.get(wsGroupId);
                if (messenger) {
                    const input = new MessengerInput(el, messenger);
                    this.elements.push(input);
                }
            });
        }

        connectAll() {
            this.messengers.forEach((messenger, wsGroupId) => {
                messenger.open().then(
                    () => console.log(`Messenger ${wsGroupId} connected`),
                    (err) => console.error(`Messenger ${wsGroupId} connection failed:`, err)
                );
            });
        }

        disconnectAll() {
            this.messengers.forEach((messenger, wsGroupId) => {
                messenger.close().then(
                    () => console.log(`Messenger ${wsGroupId} disconnected`),
                    (err) => console.error(`Messenger ${wsGroupId} disconnection failed:`, err)
                );
            });
        }

        getMessenger(wsGroupId = 'default') {
            return this.messengers.get(wsGroupId);
        }
    }

    function qfqMessenger(config = {}) {
        const app = new MessengerApplication(config);
        app.initialize();
        return app;
    }

    n.qfqMessenger = qfqMessenger;

})(QfqNS.Helper);

/**
 * QFQ SyncByRule
 * Export/Import functionality with clipboard support and validation error handling
 *
 * Requires: qfqClipboard.js
 *
 * @author Enis Nuredini <enis.nuredini@math.uzh.ch>
 * @version 1.1
 */

/* global console */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    /**
     * SyncByRule Error Types (must match server-side constants)
     */
    var SYNC_BY_RULE_ERROR_TYPES = {
        DATA_TYPE_MISMATCH: 'dataTypeMismatch',
        TABLE_NOT_FOUND: 'tableNotFound',
        CHECKSUM_INVALID: 'checksumInvalid',
        CHECKSUM_VERIFICATION_FAILED: 'checksumVerificationFailed'
    };

    /**
     * Error messages for different error types
     */
    var SYNC_BY_RULE_ERROR_MESSAGES = {
        dataTypeMismatch: 'Data type is not matching.',
        tableNotFound: 'Data type is not matching.',
        checksumInvalid: 'Data integrity check failed. The data may have been modified.',
        checksumVerificationFailed: 'Could not verify data integrity with source server.'
    };

    /**
     * Initialize SyncByRule for button and container elements
     *
     * @param {NodeList|Array} syncByRuleElements - Elements with class .qfq-syncByRule or .qfq-syncByRule-container
     * @param {object} n - Namespace reference
     */
    var qfqSyncByRule = function (syncByRuleElements, n) {
        if (syncByRuleElements !== undefined && syncByRuleElements.length > 0) {
            syncByRuleElements.forEach(function(element) {
                if (element.classList.contains('qfq-syncByRule-container')) {
                    // Container mode
                    new SyncByRuleContainerHandler(element, n);
                } else {
                    // Button mode (existing behavior)
                    new SyncByRulePasteHandler(element, n);
                }
            });
        }
    };

    /**
     * SyncByRule Core API
     * Provides programmatic access to export/import without UI
     */
    class SyncByRuleAPI {
        constructor(baseUrl) {
            this.baseUrl = baseUrl || '';
            this.apiBaseUrl = this.baseUrl + 'typo3conf/ext/qfq/Classes/Api/load.php';
        }

        /**
         * Execute Export - Returns JSON string
         *
         * @param {string} sip - SIP identifier
         * @param {object|null} ruleData - Optional rule data to send with request
         * @returns {Promise<string>} JSON string with exported data
         * @throws {Error} If export fails
         */
        /**
         * Execute Export - Returns JSON string
         *
         * @param {string} sip - SIP identifier
         * @param {object|null} ruleData - Optional rule data to send with request
         * @returns {Promise<string>} JSON string with exported data
         * @throws {Error} If export fails
         */
        async executeExport(sip, ruleData = null) {
            var body = {};
            if (ruleData && ruleData.ruleSips) {
                // Send array of rule SIPs
                body.ruleSips = ruleData.ruleSips;
            }

            var response = await fetch(this.apiBaseUrl + '?s=' + sip, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
            });

            if (!response.ok) {
                throw new Error('HTTP error! status: ' + response.status);
            }

            var result = await response.json();

            if (result.status !== 'success') {
                throw new Error(result.message || 'Export failed');
            }

            var jsonData = result.syncByRuleData;

            if (!jsonData) {
                throw new Error('No data returned from export');
            }

            return jsonData;
        }

        /**
         * Execute Import - Returns import result
         *
         * @param {string} sip - SIP identifier
         * @param {string|object} jsonData - JSON string or object to import
         * @returns {Promise<object>} Import result with success, imported items, idMapping
         * @throws {SyncByRuleValidationError} If validation fails (dataType mismatch, table not found, checksum invalid)
         * @throws {Error} If import fails for other reasons
         */
        async executeImport(sip, jsonData) {
            var jsonString = typeof jsonData === 'string' ? jsonData : JSON.stringify(jsonData);

            // Validate JSON
            try {
                JSON.parse(jsonString);
            } catch (e) {
                throw new Error('Invalid JSON: ' + e.message);
            }

            var response = await fetch(this.apiBaseUrl + '?s=' + sip, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    jsonData: jsonString
                })
            });

            if (!response.ok) {
                throw new Error('HTTP error! status: ' + response.status);
            }

            var result = await response.json();

            // Check for validation errors
            if (result.syncByRuleErrorType) {
                var errorType = result.syncByRuleErrorType;
                var errorMessage = result.syncByRuleErrorMessage ||
                    SYNC_BY_RULE_ERROR_MESSAGES[errorType] ||
                    'Validation error';

                throw new SyncByRuleValidationError(errorMessage, errorType);
            }

            if (result.status !== 'success') {
                throw new Error(result.message || 'Import failed');
            }

            var importResult = result.syncByRuleData;

            if (!importResult || !importResult.success) {
                throw new Error('Import failed');
            }

            return importResult;
        }
    }

    /**
     * Custom error class for SyncByRule validation errors
     */
    class SyncByRuleValidationError extends Error {
        constructor(message, errorType) {
            super(message);
            this.name = 'SyncByRuleValidationError';
            this.errorType = errorType;
        }

        /**
         * Get user-friendly error message based on error type
         */
        getUserMessage() {
            return SYNC_BY_RULE_ERROR_MESSAGES[this.errorType] || this.message;
        }

        /**
         * Get alert type for notification styling
         */
        getAlertType() {
            switch (this.errorType) {
                case SYNC_BY_RULE_ERROR_TYPES.DATA_TYPE_MISMATCH:
                case SYNC_BY_RULE_ERROR_TYPES.TABLE_NOT_FOUND:
                    return 'warning';
                case SYNC_BY_RULE_ERROR_TYPES.CHECKSUM_INVALID:
                case SYNC_BY_RULE_ERROR_TYPES.CHECKSUM_VERIFICATION_FAILED:
                    return 'danger';
                default:
                    return 'danger';
            }
        }
    }

    /**
     * SyncByRuleContainerHandler Class
     * Handles container with multiple rule elements
     */
    class SyncByRuleContainerHandler {
        constructor(containerElement, n) {
            this.container = containerElement;
            this.namespace = n;
            this.type = this.container.getAttribute('data-sync-by-rule-type');
            this.sip = this.container.getAttribute('data-sip');
            this.baseUrl = this.container.getAttribute('data-base-url') || '';

            // Create API instance
            this.api = new SyncByRuleAPI(this.baseUrl);

            // Get clipboard helper
            this.clipboard = n.Helper.qfqClipboard;

            // Find trigger button within container
            this.button = this.container.querySelector('.qfq-syncByRule-trigger');

            if (this.button) {
                this.originalHtml = this.button.innerHTML;
                this.originalClasses = this.button.className;
                this.button.addEventListener('click', this.handleClick.bind(this));
            }

            // Initialize checkbox handling
            this.initCheckboxHandling();
        }

        /**
         * Initialize checkbox event listeners and initial button state
         */
        initCheckboxHandling() {
            var self = this;
            var checkboxes = this.container.querySelectorAll('.qfq-syncByRule-element input[type="checkbox"]');
            this.selectAllCheckbox = this.container.querySelector('.qfq-syncByRule-checkbox-all');

            if (checkboxes.length > 0 && this.button) {
                // Set initial state (disabled since no checkboxes are checked)
                this.updateButtonState();

                // Add change listener to each checkbox
                checkboxes.forEach(function(checkbox) {
                    checkbox.addEventListener('change', function() {
                        self.updateButtonState();
                        self.updateSelectAllState();
                    });
                });

                // Handle "Select All" checkbox
                if (this.selectAllCheckbox) {
                    this.selectAllCheckbox.addEventListener('change', function() {
                        self.toggleAllCheckboxes(this.checked);
                    });
                }
            }
        }

        /**
         * Toggle all checkboxes on or off
         */
        toggleAllCheckboxes(checked) {
            var checkboxes = this.container.querySelectorAll('.qfq-syncByRule-element input[type="checkbox"]');
            checkboxes.forEach(function(checkbox) {
                checkbox.checked = checked;
            });
            this.updateButtonState();
        }

        /**
         * Update "Select All" checkbox state based on individual checkboxes
         */
        updateSelectAllState() {
            if (!this.selectAllCheckbox) {
                return;
            }

            var checkboxes = this.container.querySelectorAll('.qfq-syncByRule-element input[type="checkbox"]');
            var total = checkboxes.length;
            var checkedCount = Array.from(checkboxes).filter(function(cb) {
                return cb.checked;
            }).length;

            if (checkedCount === 0) {
                this.selectAllCheckbox.checked = false;
                this.selectAllCheckbox.indeterminate = false;
            } else if (checkedCount === total) {
                this.selectAllCheckbox.checked = true;
                this.selectAllCheckbox.indeterminate = false;
            } else {
                this.selectAllCheckbox.checked = false;
                this.selectAllCheckbox.indeterminate = true;
            }
        }

        /**
         * Update button disabled state based on checkbox selection
         */
        updateButtonState() {
            if (!this.button) {
                return;
            }

            var checkboxes = this.container.querySelectorAll('.qfq-syncByRule-element input[type="checkbox"]');
            var hasChecked = Array.from(checkboxes).some(function(cb) {
                return cb.checked;
            });

            if (hasChecked) {
                this.button.classList.remove('disabled');
            } else {
                this.button.classList.add('disabled');
            }
        }

        /**
         * Collect SIP strings from container elements
         * If checkboxes are present, only collect checked elements
         *
         * @returns {string[]|null} Array of SIP strings or null if none found
         */
        collectRules() {
            var ruleElements = this.container.querySelectorAll('.qfq-syncByRule-element');

            if (ruleElements.length === 0) {
                console.warn('SyncByRuleContainerHandler: No .qfq-syncByRule-element found');
                return null;
            }

            // Check if any element has a checkbox
            var hasCheckboxes = Array.from(ruleElements).some(function(el) {
                return el.querySelector('input[type="checkbox"]') !== null;
            });

            var ruleSips = [];

            ruleElements.forEach(function(el) {
                // If checkboxes are present, only include checked elements
                if (hasCheckboxes) {
                    var checkbox = el.querySelector('input[type="checkbox"]');
                    if (!checkbox || !checkbox.checked) {
                        return; // Skip unchecked elements
                    }
                }

                // Now data-rule contains a SIP string (encrypted), not JSON
                var ruleSip = el.getAttribute('data-rule');

                if (!ruleSip) {
                    console.warn('SyncByRuleContainerHandler: Element missing data-rule attribute', el);
                    return;
                }

                ruleSips.push(ruleSip);
            });

            if (ruleSips.length === 0) {
                return null;
            }

            return ruleSips;
        }

        /**
         * Handle button click
         */
        async handleClick(e) {
            e.preventDefault();

            // Don't execute if button is disabled (class or attribute)
            if (this.button.disabled || this.button.classList.contains('disabled')) {
                return;
            }

            var self = this;

            // Collect merged rules
            var ruleSips = this.collectRules();

            if (!ruleSips) {
                this.showNotification('warning', 'No elements selected for export');
                return;
            }

            // Disable button during operation
            var wasDisabled = this.button.disabled;
            this.button.disabled = true;

            // Show loading state
            this.setLoadingState();

            try {
                if (this.type === 'export') {
                    await this.handleExport(ruleSips);
                } else if (this.type === 'import') {
                    await this.handleImport(ruleSips);
                } else {
                    throw new Error('Unknown sync-by-rule-type: ' + this.type);
                }

                // Success state
                this.setSuccessState();

            } catch (error) {
                console.error('SyncByRule container error:', error);

                // Handle validation errors specially
                if (error instanceof SyncByRuleValidationError) {
                    this.setValidationErrorState(error);
                    this.showNotification(error.getAlertType(), error.getUserMessage());
                } else {
                    this.setErrorState();
                    this.showNotification('error', error.message || 'An error occurred');
                }
            } finally {
                // Re-enable button
                this.button.disabled = wasDisabled;

                // Restore original state after 3 seconds
                setTimeout(function() {
                    self.restoreOriginalState();
                }, 3000);
            }
        }

        /**
         * Handle Export with collected rule SIPs
         */
        async handleExport(ruleSips) {
            // Use API to get JSON, passing rule SIPs array
            var jsonData = await this.api.executeExport(this.sip, { ruleSips: ruleSips });
            var data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;

            // Add QFQ marker
            data.__qfq = true;

            // Copy to clipboard
            await this.clipboard.copy(data, { pretty: '\t', plainOnly: true });

            // Show success message
            this.showNotification('success', 'Exported to clipboard!');
        }

        /**
         * Handle Import with merged rules
         */
        async handleImport(mergedRule) {
            // Read from clipboard
            var qfqData = await this.clipboard.read();

            if (!qfqData) {
                throw new Error('No valid QFQ data in clipboard');
            }

            // Remove QFQ marker
            delete qfqData.__qfq;

            // Use API to import
            var importResult = await this.api.executeImport(this.sip, qfqData);

            var importedCount = (importResult.imported && importResult.imported.length) || 0;
            this.showNotification('success', 'Import successful! Imported ' + importedCount + ' element(s)');

            console.log('Import result:', importResult);
        }

        /**
         * Set loading state with spinner
         */
        setLoadingState() {
            var actionText = this.type === 'export' ? 'Exporting' : 'Importing';
            this.button.innerHTML = '<span class="glyphicon glyphicon-refresh glyphicon-spin"></span> ' + actionText + '...';
        }

        /**
         * Set success state with green button
         */
        setSuccessState() {
            this.button.className = this.button.className
                .replace(/btn-default|btn-primary|btn-danger|btn-warning|btn-info/g, '')
                .trim();

            if (!this.button.className.includes('btn-success')) {
                this.button.className += ' btn-success';
            }

            var actionText = this.type === 'export' ? 'Exported' : 'Imported';
            this.button.innerHTML = '<span class="glyphicon glyphicon-ok"></span> ' + actionText + '!';
        }

        /**
         * Set error state with red button
         */
        setErrorState() {
            this.button.className = this.button.className
                .replace(/btn-default|btn-primary|btn-success|btn-warning|btn-info/g, '')
                .trim();

            if (!this.button.className.includes('btn-danger')) {
                this.button.className += ' btn-danger';
            }

            this.button.innerHTML = '<span class="glyphicon glyphicon-remove"></span> Failed';
        }

        /**
         * Set validation error state (orange/warning for dataType mismatch, red for checksum)
         */
        setValidationErrorState(error) {
            this.button.className = this.button.className
                .replace(/btn-default|btn-primary|btn-success|btn-danger|btn-info/g, '')
                .trim();

            var alertType = error.getAlertType();
            if (alertType === 'warning') {
                if (!this.button.className.includes('btn-warning')) {
                    this.button.className += ' btn-warning';
                }
                this.button.innerHTML = '<span class="glyphicon glyphicon-warning-sign"></span> Mismatch';
            } else {
                if (!this.button.className.includes('btn-danger')) {
                    this.button.className += ' btn-danger';
                }
                this.button.innerHTML = '<span class="glyphicon glyphicon-remove"></span> Invalid';
            }
        }

        /**
         * Restore original button state
         */
        restoreOriginalState() {
            this.button.className = this.originalClasses;
            this.button.innerHTML = this.originalHtml;
        }

        /**
         * Show notification to user
         */
        showNotification(type, message) {
            var alertType = 'info';
            if (type === 'success') {
                alertType = 'success';
            } else if (type === 'error' || type === 'danger') {
                alertType = 'danger';
            } else if (type === 'warning') {
                alertType = 'warning';
            }

            var alert = new QfqNS.Alert({
                message: message,
                type: alertType,
                timeout: 3000
            });
            alert.show();
        }
    }

    /**
     * SyncByRulePasteHandler Class
     * Handles individual export/import button with UI feedback
     */
    class SyncByRulePasteHandler {
        constructor(buttonElement, n) {
            this.button = buttonElement;
            this.namespace = n;
            this.type = this.button.getAttribute('data-sync-by-rule-type');
            this.sip = this.button.getAttribute('data-sip');
            this.baseUrl = this.button.getAttribute('data-base-url');
            this.originalHtml = this.button.innerHTML;
            this.originalClasses = this.button.className;
            this.hasValidClipboard = true;

            // Create API instance
            this.api = new SyncByRuleAPI(this.baseUrl);

            // Get clipboard helper
            this.clipboard = n.Helper.qfqClipboard;

            // Bind click handler
            this.button.addEventListener('click', this.handleClick.bind(this));

            // For import buttons: Add clipboard validation on hover/focus
            if (this.type === 'import') {
                this.initClipboardValidation();
            }
        }

        /**
         * Initialize clipboard validation for import buttons
         */
        initClipboardValidation() {
            var self = this;

            this.button.addEventListener('mouseenter', function() {
                self.validateClipboard();
            });

            this.button.addEventListener('focus', function() {
                self.validateClipboard();
            });

            window.addEventListener('focus', function() {
                if (self.button.matches(':hover')) {
                    self.validateClipboard();
                }
            });
        }

        /**
         * Validate clipboard content
         */
        async validateClipboard() {
            try {
                var isValid = await this.clipboard.hasValidData();
                this.setImportAllowed(isValid);
            } catch (err) {
                console.warn('Clipboard validation failed:', err.message);
                this.setImportAllowed(true);
            }
        }

        /**
         * Update button state based on clipboard validity
         */
        setImportAllowed(allowed) {
            this.hasValidClipboard = allowed;

            if (allowed) {
                this.button.classList.remove('qfq-import-forbidden');
                this.button.removeAttribute('title');
            } else {
                this.button.classList.add('qfq-import-forbidden');
                this.button.setAttribute('title', 'Clipboard contains no valid QFQ data');
            }
        }

        /**
         * Handle button click
         */
        async handleClick(e) {
            e.preventDefault();

            // Don't execute if button is disabled (class or attribute)
            if (this.button.disabled || this.button.classList.contains('disabled')) {
                return;
            }

            var self = this;
            var validatedData = null;

            // For import: Validate clipboard on click
            if (this.type === 'import') {
                try {
                    validatedData = await this.clipboard.read();
                    if (!validatedData) {
                        this.setImportAllowed(false);
                        this.setWarningState();
                        this.showNotification('warning', 'Clipboard contains no valid QFQ data');
                        setTimeout(function() {
                            self.setImportAllowed(true);
                            self.restoreOriginalState();
                        }, 5000);
                        return;
                    }
                } catch (err) {
                    this.showNotification('error', 'Cannot read clipboard: ' + err.message);
                    return;
                }
            }

            // Disable button during operation
            var wasDisabled = this.button.disabled;
            this.button.disabled = true;

            // Show loading state
            this.setLoadingState();

            try {
                if (this.type === 'export') {
                    await this.handleExport();
                } else if (this.type === 'import') {
                    await this.handleImport(validatedData);
                } else {
                    throw new Error('Unknown copy-paste-type: ' + this.type);
                }

                // Success state
                this.setSuccessState();

            } catch (error) {
                console.error('SyncByRule error:', error);

                // Handle validation errors specially
                if (error instanceof SyncByRuleValidationError) {
                    this.setValidationErrorState(error);
                    this.showNotification(error.getAlertType(), error.getUserMessage());
                } else {
                    this.setErrorState();
                    this.showNotification('error', error.message || 'An error occurred');
                }
            } finally {
                // Re-enable button
                this.button.disabled = wasDisabled;

                // Restore original state after 3 seconds
                setTimeout(function() {
                    self.restoreOriginalState();
                }, 3000);
            }
        }

        /**
         * Set loading state with spinner
         */
        setLoadingState() {
            var actionText = this.type === 'export' ? 'Exporting' : 'Importing';
            this.button.innerHTML = '<span class="glyphicon glyphicon-refresh glyphicon-spin"></span> ' + actionText + '...';
        }

        /**
         * Set success state with green button
         */
        setSuccessState() {
            this.button.className = this.button.className
                .replace(/btn-default|btn-primary|btn-danger|btn-warning|btn-info/g, '')
                .trim();

            if (!this.button.className.includes('btn-success')) {
                this.button.className += ' btn-success';
            }

            var actionText = this.type === 'export' ? 'Exported' : 'Imported';
            this.button.innerHTML = '<span class="glyphicon glyphicon-ok"></span> ' + actionText + '!';
        }

        /**
         * Set error state with red button
         */
        setErrorState() {
            this.button.className = this.button.className
                .replace(/btn-default|btn-primary|btn-success|btn-warning|btn-info/g, '')
                .trim();

            if (!this.button.className.includes('btn-danger')) {
                this.button.className += ' btn-danger';
            }

            this.button.innerHTML = '<span class="glyphicon glyphicon-remove"></span> Failed';
        }

        /**
         * Set warning state with orange button
         */
        setWarningState() {
            this.button.className = this.button.className
                .replace(/btn-default|btn-primary|btn-success|btn-danger|btn-info/g, '')
                .trim();

            if (!this.button.className.includes('btn-warning')) {
                this.button.className += ' btn-warning';
            }

            this.button.innerHTML = '<span class="glyphicon glyphicon-warning-sign"></span> Not supported format';
        }

        /**
         * Set validation error state (orange/warning for dataType mismatch, red for checksum)
         */
        setValidationErrorState(error) {
            this.button.className = this.button.className
                .replace(/btn-default|btn-primary|btn-success|btn-danger|btn-info/g, '')
                .trim();

            var alertType = error.getAlertType();
            if (alertType === 'warning') {
                if (!this.button.className.includes('btn-warning')) {
                    this.button.className += ' btn-warning';
                }
                this.button.innerHTML = '<span class="glyphicon glyphicon-warning-sign"></span> Mismatch';
            } else {
                if (!this.button.className.includes('btn-danger')) {
                    this.button.className += ' btn-danger';
                }
                this.button.innerHTML = '<span class="glyphicon glyphicon-remove"></span> Invalid';
            }
        }

        /**
         * Restore original button state
         */
        restoreOriginalState() {
            this.button.className = this.originalClasses;
            this.button.innerHTML = this.originalHtml;
        }

        /**
         * Handle Export with UI feedback
         */
        async handleExport() {
            // Use API to get JSON
            var jsonData = await this.api.executeExport(this.sip);
            var data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;

            // Add QFQ marker
            data.__qfq = true;

            // Copy to clipboard with multiple formats
            await this.clipboard.copy(data, { pretty: '\t', plainOnly: true });

            // Show success message
            this.showNotification('success', 'Exported to clipboard!');
        }

        /**
         * Handle Import with UI feedback
         * @param {object} qfqData - Pre-validated QFQ data from clipboard
         */
        async handleImport(qfqData) {
            if (!qfqData) {
                throw new Error('No valid QFQ data in clipboard');
            }

            // Remove QFQ marker before sending to server
            delete qfqData.__qfq;

            // Use API to import
            var importResult = await this.api.executeImport(this.sip, qfqData);

            // Show success message with details
            var importedCount = (importResult.imported && importResult.imported.length) || 0;
            this.showNotification('success', 'Import successful! Imported ' + importedCount + ' element(s)');

            // Log details
            console.log('Import result:', importResult);
        }

        /**
         * Show notification to user
         */
        showNotification(type, message) {
            var alertType = 'info';
            if (type === 'success') {
                alertType = 'success';
            } else if (type === 'error' || type === 'danger') {
                alertType = 'danger';
            } else if (type === 'warning') {
                alertType = 'warning';
            }

            var alert = new QfqNS.Alert({
                message: message,
                type: alertType,
                timeout: 3000
            });
            alert.show();
        }
    }

    // Create and export default API instance
    n.Helper.qfqSyncByRuleObj = new SyncByRuleAPI();

    // Export API class
    n.Helper.SyncByRuleAPI = SyncByRuleAPI;

    // Export validation error class
    n.Helper.SyncByRuleValidationError = SyncByRuleValidationError;

    // Export error types
    n.Helper.SYNC_BY_RULE_ERROR_TYPES = SYNC_BY_RULE_ERROR_TYPES;

    // Export button initialization function
    n.Helper.qfqSyncByRule = qfqSyncByRule;

})(QfqNS);
/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */

var QfqNS = QfqNS || {};

/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';

    /**
     * Initializes all FEs type select with the selectBS notation.
     * Only elements having the class "qfq-select-bs-parent" are initialized.
     *
     * @function
     */
    let selectBS = function () {

        // all selectBS <div> elements that contain the button and list used for the dropdown
        const dropdownParent = document.querySelectorAll(".qfq-select-bs-parent");

        let lastCursorPos = {x: 0, y: 0};
        let disabledClick = false;

        const observer = new MutationObserver(function (mutations) {
            mutations.forEach(function (mutation) {

                // selected option from dropdown
                let prevSelectedItem = mutation.target.querySelector(".qfq-select-bs-list > li[data-selected]");

                // focused option(s) from dropdown
                let prevFocusedItem = mutation.target.querySelector(".qfq-select-bs-list > li.qfq-item-focused");

                // remove class/style
                if (prevFocusedItem !== prevSelectedItem) {
                    prevFocusedItem.classList.remove('qfq-item-focused');
                }

                if (!prevSelectedItem) {
                    return;
                }

                prevSelectedItem.classList.add('qfq-item-focused');

                // dropdown is open
                if (mutation.target.classList.contains("open")) {

                    // scroll selected item into center of dropdown
                    prevSelectedItem.parentElement.scrollTop = prevSelectedItem.offsetTop - prevSelectedItem.parentElement.offsetHeight / 2;
                }
            });
        });

        dropdownParent.forEach(function (parentItem) {
            const enabledItems = parentItem.querySelectorAll(".qfq-select-bs-list > li:not(.qfq-item-disabled, .qfq-dropdown-item-disabled)");
            const disabledItems = parentItem.querySelectorAll(".qfq-select-bs-list > li.qfq-item-disabled, .qfq-select-bs-list > li.qfq-dropdown-item-disabled");
            const dropdownButton = parentItem.querySelector(".qfq-select-bs-button");

            // adding events to all options
            enabledItems.forEach(function (item) {

                // hover style
                item.addEventListener("mousemove", function (event) {

                    // set current cursor position
                    let currentCursorPos = {x: event.screenX, y: event.screenY};

                    // cursor has not been moved
                    if (currentCursorPos.x === lastCursorPos.x && currentCursorPos.y === lastCursorPos.y) {
                        return;
                    }

                    lastCursorPos = {x: event.screenX, y: event.screenY};

                    let previousFocusedItem = parentItem.querySelector(".qfq-select-bs-list > li.qfq-item-focused");
                    previousFocusedItem.classList.remove('qfq-item-focused');

                    item.classList.add('qfq-item-focused');
                });

                // insert value
                item.addEventListener("click", function (event) {
                    selectBS.insertValue(item);
                    parentItem.classList.toggle("open");
                    dropdownButton.focus();
                });
            });

            disabledItems.forEach(function (item) {

                // nothing happens
                item.addEventListener("click", function (event) {
                    disabledClick = true;
                    dropdownButton.focus();
                    event.preventDefault();
                    event.stopPropagation();
                });
            });

            dropdownButton.addEventListener("click", function (event) {
                parentItem.classList.toggle("open");
            });

            dropdownButton.addEventListener("blur", function(event) {

                // wait for other stuff to finish
                setTimeout(function() {
                    if (disabledClick) {
                        disabledClick = false;
                        return;
                    }

                    parentItem.classList.remove("open");
                }, 200);
            });

            dropdownButton.addEventListener("keydown", function (event) {
                let previousSelectedItem = parentItem.querySelector(".qfq-select-bs-list > li.qfq-item-focused");
                let previousIndex = Array.prototype.indexOf.call(enabledItems, previousSelectedItem);

                let selectedItem = null;
                let selectedItemSibling = null;
                let scroll = null;

                switch (event.key) {
                    case 'ArrowDown':
                        event.preventDefault();
                        selectedItem = (previousSelectedItem) ? enabledItems[previousIndex + 1] : enabledItems[0];
                        selectedItemSibling = selectedItem.nextSibling || null;

                        // determine position of item in viewport
                        scroll = selectedItem.getBoundingClientRect().bottom - selectedItem.parentElement.getBoundingClientRect().bottom + 3;

                        // scroll > 0 means that the item is outside the lower border
                        scroll = (scroll > 0) ? scroll : 0;
                        break;

                    case 'ArrowUp':
                        event.preventDefault();
                        selectedItem = (previousSelectedItem) ? enabledItems[previousIndex - 1] : null;
                        selectedItemSibling = selectedItem.previousSibling || null;

                        // determine position of item in viewport
                        scroll = selectedItem.getBoundingClientRect().top - selectedItem.parentElement.getBoundingClientRect().top - 3;

                        // scroll < 0 means that the item is outside the top border
                        scroll = (scroll < 0) ? scroll : 0;
                        break;

                    case 'Enter':
                        event.preventDefault();
                        selectBS.insertValue(previousSelectedItem);
                        parentItem.classList.toggle("open");
                        return;

                    case 'Escape':
                        parentItem.classList.remove("open");
                        return;

                    default:
                        return;
                }

                // dropdown is closed, sibling exists
                if (dropdownButton === document.activeElement && !parentItem.classList.contains("open") && selectedItemSibling && selectedItem) {
                    selectBS.insertValue(selectedItem);
                    if (previousSelectedItem) previousSelectedItem.classList.toggle('qfq-item-focused');
                    selectedItem.classList.toggle("qfq-item-focused");

                // dropdown is open, sibling exists
                } else if (dropdownButton === document.activeElement && parentItem.classList.contains("open") && selectedItemSibling && selectedItem) {

                    if (previousSelectedItem) previousSelectedItem.classList.toggle('qfq-item-focused');
                    selectedItem.classList.toggle("qfq-item-focused");

                    // scroll if item is outside of viewport
                    selectedItem.parentElement.scrollBy(0, scroll);
                }
            });

            // mutation observer
            observer.observe(parentItem, {
                attributes: true,
                attributeFilter: ["class"]
            });
        });
    }

    // insert value of option into button and hidden input
    selectBS.insertValue = function(element) {
        let dropdownParent = element.closest(".qfq-select-bs-parent");
        let button = dropdownParent.querySelector("button");
        let input = dropdownParent.querySelector("input");
        button.innerHTML = element.innerHTML + ` <span class="caret"></span>`;
        input.value = element.getAttribute("value");
        let event = new Event("input");
        input.dispatchEvent(event);

        // mark (only) current option as selected
        if (!element.getAttribute("data-selected")) {
            if (element.parentElement.querySelector("li[data-selected]")) {
                element.parentElement.querySelector("li[data-selected]").removeAttribute("data-selected");
            }
            element.setAttribute("data-selected", "");
        }
    };

    n.selectBS = selectBS;

})(QfqNS.Helper);
/* ===================================================
 * tagmanager.js v3.0.2
 * http://welldonethings.com/tags/manager
 * ===================================================
 * Copyright 2012 Max Favilli
 *
 * Licensed under the Mozilla Public License, Version 2.0 You may not use this work except in compliance with the License.
 *
 * http://www.mozilla.org/MPL/2.0/
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * ========================================================== */
(function ($) {

    "use strict";

    var defaults = {
            prefilled: null,
            CapitalizeFirstLetter: false,
            preventSubmitOnEnter: true,     // deprecated
            isClearInputOnEsc: true,        // deprecated
            externalTagId: false,
            prefillIdFieldName: 'Id',
            prefillValueFieldName: 'Value',
            AjaxPush: null,
            AjaxPushAllTags: null,
            AjaxPushParameters: null,
            delimiters: [9, 13, 44],        // tab, enter, comma
            backspace: [8],
            maxTags: 0,
            hiddenTagListName: null,        // deprecated
            hiddenTagListId: null,          // deprecated
            replace: true,
            output: null,
            deleteTagsOnBackspace: true,    // deprecated
            tagsContainer: null,
            tagCloseIcon: '<i class="fas fa-trash"></i>',
            tagClass: '',
            validator: null,
            onlyTagList: false,
            tagList: null,
            fillInputOnTagRemove: false,
            AjaxPushDataType: 'json' //allow plugin to send data using different encodings (xml, json, script, text, html)
        },

        publicMethods = {
            pushTag: function (tag, ignoreEvents, externalTagId, ignoreValidator) {
                var $self = $(this), opts = $self.data('opts'), alreadyInList, tlisLowerCase, max, tagId,
                    tlis = $self.data("tlis"), tlid = $self.data("tlid"), idx, newTagId, newTagRemoveId, escaped,
                    html, $el, lastTagId, lastTagObj;

                tag = privateMethods.trimTag(tag, opts.delimiterChars);

                if (!tag || tag.length <= 0) {
                    return;
                }

                // check if restricted only to the tagList suggestions
                if (opts.onlyTagList && undefined !== opts.tagList) {

                    //if the list has been updated by look pushed tag in the tagList. if not found return
                    if (opts.tagList) {
                        var $tagList = opts.tagList;

                        // change each array item to lower case
                        $.each($tagList, function (index, item) {
                            $tagList[index] = item.toLowerCase();
                        });
                        var suggestion = $.inArray(tag.toLowerCase(), $tagList);

                        if (-1 === suggestion) {
                            //console.log("tag:" + tag + " not in tagList, not adding it");
                            return;
                        }
                    }

                }

                if (opts.CapitalizeFirstLetter && tag.length > 1) {
                    tag = tag.charAt(0).toUpperCase() + tag.slice(1).toLowerCase();
                }

                // call the validator (if any) and do not let the tag pass if invalid
                if (!ignoreValidator && opts.validator && !opts.validator(tag) && !opts.disableHiddenUpdate) {
                    $self.trigger('tm:invalid', tag);
                    return;
                }

                // dont accept new tags beyond the defined maximum
                if (opts.maxTags > 0 && tlis.length >= opts.maxTags) {
                    return;
                }

                alreadyInList = false;
                //use jQuery.map to make this work in IE8 (pure JS map is JS 1.6 but IE8 only supports JS 1.5)
                tlisLowerCase = jQuery.map(tlis, function (elem) {
                    return elem.toLowerCase();
                });

                idx = $.inArray(tag.toLowerCase(), tlisLowerCase);

                if (-1 !== idx) {
                    // console.log("tag:" + tag + " !!already in list!!");
                    alreadyInList = true;
                }

                if (alreadyInList) {
                    $self.trigger('tm:duplicated', tag);
                    if (opts.blinkClass) {
                        for (var i = 0; i < 6; ++i) {
                            $("#" + $self.data("tm_rndid") + "_" + tlid[idx]).queue((function (_$, loc_opts, next) {
                                _$(this).toggleClass(loc_opts.blinkClass);
                                next();
                            }).bind(this, $, opts)).delay(100);
                        }
                    } else {
                        $("#" + $self.data("tm_rndid") + "_" + tlid[idx]).stop()
                            .animate({backgroundColor: opts.blinkBGColor_1}, 100)
                            .animate({backgroundColor: opts.blinkBGColor_2}, 100)
                            .animate({backgroundColor: opts.blinkBGColor_1}, 100)
                            .animate({backgroundColor: opts.blinkBGColor_2}, 100)
                            .animate({backgroundColor: opts.blinkBGColor_1}, 100)
                            .animate({backgroundColor: opts.blinkBGColor_2}, 100);
                    }
                } else {
                    if (opts.externalTagId === true) {
                        if (externalTagId === undefined) {
                            $.error('externalTagId is not passed for tag -' + tag);
                        }
                        tagId = externalTagId;
                    } else {
                        max = Math.max.apply(null, tlid);
                        max = max === -Infinity ? 0 : max;

                        tagId = ++max;
                    }
                    if (!ignoreEvents) {
                        $self.trigger('tm:pushing', [tag, tagId]);
                    }
                    tlis.push(tag);
                    tlid.push(tagId);

                    if (!ignoreEvents)
                        if (opts.AjaxPush !== null && opts.AjaxPushAllTags == null) {
                            if ($.inArray(tag, opts.prefilled) === -1) {
                                $.post(opts.AjaxPush, $.extend({tag: tag}, opts.AjaxPushParameters), null, opts.AjaxPushDataType);
                            }
                        }

                    // console.log("tagList: " + tlis);

                    newTagId = $self.data("tm_rndid") + '_' + tagId;
                    newTagRemoveId = $self.data("tm_rndid") + '_Remover_' + tagId;
                    escaped = $("<span/>").text(tag).html();

                    html = '<span class="' + privateMethods.tagClasses.call($self) + '" id="' + newTagId + '">';
                    html += '<span>' + escaped + '</span>';
                    html += '<a href="#" class="tm-tag-remove" id="' + newTagRemoveId + '" TagIdToRemove="' + tagId + '">';
                    html += opts.tagCloseIcon + '</a></span> ';
                    $el = $(html);


                    var typeAheadMess = $self.parents('.twitter-typeahead')[0] !== undefined;
                    if (opts.tagsContainer !== null) {
                        $(opts.tagsContainer).append($el);
                    } else {
                        if (tlid.length > 1) {
                            if (typeAheadMess) {
                                lastTagId = $self.data("tm_rndid") + '_' + --tagId;
                                jQuery('#' + lastTagId).after($el);
                            } else {
                                lastTagObj = $self.siblings("#" + $self.data("tm_rndid") + "_" + tlid[tlid.length - 2]);
                                lastTagObj.after($el);
                            }
                        } else {
                            if (typeAheadMess) {
                                $self.parents('.twitter-typeahead').before($el);
                            } else {
                                $self.before($el);
                            }
                        }
                    }

                    $el.find("#" + newTagRemoveId).on("click", $self, function (e) {
                        e.preventDefault();
                        var TagIdToRemove = parseInt($(this).attr("TagIdToRemove"));
                        privateMethods.spliceTag.call($self, TagIdToRemove, e.data);
                    });

                    if (!ignoreEvents) {
                        $self.trigger('tm:pushed', [tag, tagId]);
                    }

                    privateMethods.refreshHiddenTagList.call($self);

                    privateMethods.showOrHide.call($self);
                    //if (tagManagerOptions.maxTags > 0 && tlis.length >= tagManagerOptions.maxTags) {
                    //  obj.hide();
                    //}
                }
                $self.val("");

                // empty field
                (this).typeahead('val', '');
            },

            popTag: function () {
                var $self = $(this), tagId, tagBeingRemoved,
                    tlis = $self.data("tlis"),
                    tlid = $self.data("tlid");

                if (tlid.length > 0) {
                    tagId = tlid.pop();

                    tagBeingRemoved = tlis[tlis.length - 1];
                    $self.trigger('tm:popping', [tagBeingRemoved, tagId]);
                    tlis.pop();

                    // console.log("TagIdToRemove: " + tagId);
                    $("#" + $self.data("tm_rndid") + "_" + tagId).remove();
                    privateMethods.refreshHiddenTagList.call($self);
                    $self.trigger('tm:popped', [tagBeingRemoved, tagId]);

                    privateMethods.showOrHide.call($self);
                    // console.log(tlis);
                }
            },

            empty: function () {
                var $self = $(this), tlis = $self.data("tlis"), tlid = $self.data("tlid"), tagId;

                while (tlid.length > 0) {
                    tagId = tlid.pop();
                    tlis.pop();
                    // console.log("TagIdToRemove: " + tagId);
                    $("#" + $self.data("tm_rndid") + "_" + tagId).remove();
                    privateMethods.refreshHiddenTagList.call($self);
                    // console.log(tlis);
                }
                $self.trigger('tm:emptied', null);

                privateMethods.showOrHide.call($self);
                //if (tagManagerOptions.maxTags > 0 && tlis.length < tagManagerOptions.maxTags) {
                //  obj.show();
                //}
            },

            tags: function () {
                var $self = this, tlis = $self.data("tlis");
                return tlis;
            }
        },

        privateMethods = {
            showOrHide: function () {
                var $self = this, opts = $self.data('opts'), tlis = $self.data("tlis");

                if (opts.maxTags > 0 && tlis.length < opts.maxTags) {
                    $self.show();
                    $self.trigger('tm:show');
                }

                if (opts.maxTags > 0 && tlis.length >= opts.maxTags) {
                    $self.hide();
                    $self.trigger('tm:hide');
                }
            },

            tagClasses: function () {
                var $self = $(this), opts = $self.data('opts'), tagBaseClass = opts.tagBaseClass,
                    inputBaseClass = opts.inputBaseClass, cl;
                // 1) default class (tm-tag)
                cl = tagBaseClass;
                // 2) interpolate from input class: tm-input-xxx --> tm-tag-xxx
                if ($self.attr('class')) {
                    $.each($self.attr('class').split(' '), function (index, value) {
                        if (value.indexOf(inputBaseClass + '-') !== -1) {
                            cl += ' ' + tagBaseClass + value.substring(inputBaseClass.length);
                        }
                    });
                }
                // 3) tags from tagClass option
                cl += (opts.tagClass ? ' ' + opts.tagClass : '');
                return cl;
            },

            trimTag: function (tag, delimiterChars) {
                var i;
                tag = $.trim(tag);
                // truncate at the first delimiter char
                i = 0;
                for (i; i < tag.length; i++) {
                    if ($.inArray(tag.charCodeAt(i), delimiterChars) !== -1) {
                        break;
                    }
                }
                return tag.substring(0, i);
            },

            refreshHiddenTagList: function () {
                var $self = $(this), opts = $self.data('opts'), tlis = $self.data("tlis"), lhiddenTagList = $self.data("lhiddenTagList");
                if (!opts.disableHiddenUpdate) {

                    if (lhiddenTagList) {
                        $(lhiddenTagList).val(tlis.join($self.data('opts').baseDelimiter)).change();
                        $self.trigger('tm:hiddenUpdate', [tlis, $(lhiddenTagList)]);
                    }

                    $self.trigger('tm:refresh', tlis.join($self.data('opts').baseDelimiter));
                }
            },

            killEvent: function (e) {
                e.cancelBubble = true;
                e.returnValue = false;
                e.stopPropagation();
                e.preventDefault();
            },

            keyInArray: function (e, ary) {
                return $.inArray(e.which, ary) !== -1;
            },

            applyDelimiter: function (e) {
                var $self = $(this);
                publicMethods.pushTag.call($self, $(this).val());
                e.preventDefault();
            },

            prefill: function (pta) {
                var $self = $(this);
                var opts = $self.data('opts');
                $.each(pta, function (key, val) {
                    if (opts.externalTagId === true) {
                        publicMethods.pushTag.call($self, val[opts.prefillValueFieldName], true, val[opts.prefillIdFieldName], true);
                    } else {
                        publicMethods.pushTag.call($self, val, true, false, true);
                    }
                });
            },

            pushAllTags: function (e, tag) {
                var $self = $(this), opts = $self.data('opts'), tlis = $self.data("tlis");
                if (opts.AjaxPushAllTags) {
                    if (e.type !== 'tm:pushed' || $.inArray(tag, opts.prefilled) === -1) {
                        $.post(opts.AjaxPush, $.extend({tags: tlis.join(opts.baseDelimiter)}, opts.AjaxPushParameters));
                    }
                }
            },

            spliceTag: function (tagId) {
                var $self = this, tlis = $self.data("tlis"), tlid = $self.data("tlid"), idx = $.inArray(tagId, tlid),
                    tagBeingRemoved;

                // console.log("TagIdToRemove: " + tagId);
                // console.log("position: " + idx);

                if (-1 !== idx) {
                    tagBeingRemoved = tlis[idx];
                    $self.trigger('tm:splicing', [tagBeingRemoved, tagId]);
                    $("#" + $self.data("tm_rndid") + "_" + tagId).remove();
                    tlis.splice(idx, 1);
                    tlid.splice(idx, 1);
                    privateMethods.refreshHiddenTagList.call($self);
                    $self.trigger('tm:spliced', [tagBeingRemoved, tagId]);
                    // console.log(tlis);
                }

                privateMethods.showOrHide.call($self);
                //if (tagManagerOptions.maxTags > 0 && tlis.length < tagManagerOptions.maxTags) {
                //  obj.show();
                //}
            },

            init: function (options) {
                var opts = $.extend({}, defaults, options), delimiters, keyNums;

                opts.hiddenTagListName = (opts.hiddenTagListName === null) ? 'hidden-' + this.attr('name') : opts.hiddenTagListName;

                delimiters = opts.delimeters || opts.delimiters; // 'delimeter' is deprecated
                keyNums = [9, 13, 17, 18, 19, 37, 38, 39, 40]; // delimiter values to be handled as key codes
                opts.delimiterChars = [];
                opts.delimiterKeys = [];

                $.each(delimiters, function (i, v) {
                    if ($.inArray(v, keyNums) !== -1) {
                        opts.delimiterKeys.push(v);
                    } else {
                        opts.delimiterChars.push(v);
                    }
                });

                opts.baseDelimiter = String.fromCharCode(opts.delimiterChars[0] || 44);
                opts.tagBaseClass = 'tm-tag';
                opts.inputBaseClass = 'tm-input';
                opts.disableHiddenUpdate = false;

                if (!$.isFunction(opts.validator)) {
                    opts.validator = null;
                }

                this.each(function () {
                    var $self = $(this), hiddenObj = '', rndid = '',
                        albet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

                    // prevent double-initialization of TagManager
                    if ($self.data('tagManager')) {
                        return false;
                    }
                    $self.data('tagManager', true);

                    for (var i = 0; i < 5; i++) {
                        rndid += albet.charAt(Math.floor(Math.random() * albet.length));
                    }

                    $self.data("tm_rndid", rndid);

                    // store instance-specific data in the DOM object
                    $self.data('opts', opts)
                        .data('tlis', []) //list of string tags
                        .data('tlid', []); //list of ID of the string tags

                    if (opts.output === null) {
                        hiddenObj = $('<input/>', {
                            type: 'hidden',
                            name: opts.hiddenTagListName
                        });
                        $self.after(hiddenObj);
                        $self.data("lhiddenTagList", hiddenObj);
                    } else {
                        $self.data("lhiddenTagList", $(opts.output));
                    }

                    if (opts.AjaxPushAllTags) {
                        $self.on('tm:spliced', privateMethods.pushAllTags);
                        $self.on('tm:popped', privateMethods.pushAllTags);
                        $self.on('tm:pushed', privateMethods.pushAllTags);
                    }

                    // hide popovers on focus and keypress events
                    $self.on('focus keypress', function (e) {
                        if ($(this).popover) {
                            $(this).popover('hide');
                        }
                    });

                    // handle ESC (keyup used for browser compatibility)
                    if (opts.isClearInputOnEsc) {
                        $self.on('keyup', function (e) {
                            if (e.which === 27) {
                                // console.log('esc detected');
                                $(this).val('');
                                privateMethods.killEvent(e);
                            }
                        });
                    }

                    $self.on('keypress', function (e) {
                        // push ASCII-based delimiters
                        if (privateMethods.keyInArray(e, opts.delimiterChars)) {
                            privateMethods.applyDelimiter.call($self, e);
                        }
                    });

                    $self.on('keydown', function (e) {
                        // disable ENTER
                        if (e.which === 13) {
                            if (opts.preventSubmitOnEnter) {
                                privateMethods.killEvent(e);
                            }
                        }

                        // push key-based delimiters (includes <enter> by default)
                        if (privateMethods.keyInArray(e, opts.delimiterKeys)) {
                            privateMethods.applyDelimiter.call($self, e);
                        }
                    });

                    // BACKSPACE (keydown used for browser compatibility)
                    if (opts.deleteTagsOnBackspace) {
                        $self.on('keydown', function (e) {
                            if (privateMethods.keyInArray(e, opts.backspace)) {
                                // console.log("backspace detected");
                                if ($(this).val().length <= 0) {
                                    publicMethods.popTag.call($self);
                                    privateMethods.killEvent(e);
                                }
                            }
                        });
                    }

                    // on tag pop fill back the tag's content to the input field
                    if (opts.fillInputOnTagRemove) {
                        $self.on('tm:popped', function (e, tag) {
                            $(this).val(tag);
                        });
                    }

                    $self.change(function (e) {
                        if (!/webkit/.test(navigator.userAgent.toLowerCase())) {
                            $self.focus();
                        } // why?

                        /* unimplemented mode to push tag on blur
                         else if (tagManagerOptions.pushTagOnBlur) {
                         console.log('change: pushTagOnBlur ' + tag);
                         pushTag($(this).val());
                         } */
                        privateMethods.killEvent(e);
                    });

                    if (opts.prefilled !== null) {
                        if (typeof (opts.prefilled) === "object") {
                            privateMethods.prefill.call($self, opts.prefilled);
                        } else if (typeof (opts.prefilled) === "string") {
                            privateMethods.prefill.call($self, opts.prefilled.split(opts.baseDelimiter));
                        } else if (typeof (opts.prefilled) === "function") {
                            privateMethods.prefill.call($self, opts.prefilled());
                        }
                    } else if (opts.output !== null) {
                        if ($(opts.output) && $(opts.output).val()) {
                            var existing_tags = $(opts.output);
                        }
                        privateMethods.prefill.call($self, $(opts.output).val().split(opts.baseDelimiter));
                    }

                });

                return this;
            }
        };

    $.fn.tagsManager = function (method) {
        var $self = $(this), opts = $self.data('opts');

        if (!(0 in this)) {
            return this;
        }

        if (publicMethods[method]) {
            return publicMethods[method].apply($self, Array.prototype.slice.call(arguments, 1));
        } else if (typeof method === 'object' || !method) {
            return privateMethods.init.apply(this, arguments);
        } else if (method === 'disableHiddenUpdate') {
            opts.disableHiddenUpdate = arguments[1];
        } else {
            $.error('Method ' + method + ' does not exist.');
            return false;
        }
    };

}(jQuery));
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */
/* global console */
/* global tinymce */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq Helper Namespace
 *
 * @namespace QfqNS.Helper
 */
QfqNS.Helper = QfqNS.Helper || {};

(function (n) {
    'use strict';


    /**
     * Initialize tinyMce. All HTML elements having the `qfq-tinymce` class will be initialized. TinyMce configuration
     * is provided in the `data-config` attribute. Each element further requires the `id` attribute to be present.
     *
     * @function
     */
    var tinyMce = function () {

        if (typeof tinymce === 'undefined') {
            //QfqNS.log.error("tinymce not loaded, cannot initialize Qfq tinymce.");
            return;
        }
        function showImageModal(imageUrl) {
            let $modal = $('#tinymce-image-modal');

            // Create modal if it doesn't exist
            if (!$modal.length) {
                $modal = $(`
            <div id="tinymce-image-modal" style="
                position: fixed;
                top: 0; left: 0; width: 100%; height: 100%;
                background: rgba(0,0,0,0.8);
                display: flex; align-items: center; justify-content: center;
                z-index: 9999;
            ">
                <img id="tinymce-image-modal-img" style="
                    max-width: 90%; max-height: 90%; border: 4px solid white;
                    box-shadow: 0 0 20px rgba(0,0,0,0.5);
                "/>
            </div>
        `).appendTo('body');

                $modal.on('click', function () {
                    $modal.fadeOut(200);
                });
            }

            $('#tinymce-image-modal-img').attr('src', imageUrl);
            $modal.fadeIn(200);
        }

        $(".qfq-tinymce").each(
            function () {
                var config = {};
                var myEditor = {};
                var $this = $(this);
                var tinyMCEId = $this.attr('id');
                var defaultImageClass = $this.attr('defaultImageClass');
                var defaultImageBorder = $this.attr('imageBorderDefault');

                if (!tinyMCEId) {
                    QfqNS.Log.warning("TinyMCE container does not have an id attribute. Ignoring.");
                    return;
                }

                // Skip elements that already have a TinyMCE instance
                if (tinymce.get(tinyMCEId)) {
                    return;
                }

                var configData = $this.data('config');
                if (configData) {
                    if (configData instanceof Object) {

                        // Iterate through properties to check if they are valid JSON
                        for (const key in configData) {
                            if (typeof configData[key] === 'string') {
                                try {

                                    // Attempt to parse the string
                                    const parsedValue = JSON.parse(configData[key]);

                                    // Check if parsedValue is an object or an array of objects
                                    if (typeof parsedValue === 'object' || Array.isArray(parsedValue)) {

                                        // Replace with parsed object
                                        configData[key] = parsedValue;
                                    }
                                } catch {
                                    // Do nothing
                                }
                            }
                        }

                        // jQuery takes care of decoding data-config to JavaScript object.
                        config = configData;
                    } else {
                        QfqNS.Log.warning("'data-config' is invalid: " + configData);
                    }
                }

                config.selector = "#" + QfqNS.escapeJqueryIdSelector(tinyMCEId);
                config.setup = function (editor) {
                    myEditor = editor;
                    var element = document.getElementById(QfqNS.escapeJqueryIdSelector(tinyMCEId));
                    var isReadOnly = $(element).hasClass("readonly");

                    // QFQ AI assist: register one 'AI' dropdown (toolbar token 'qfqAi') if the 'Translate' becomes a submenu with the offered target languages.
                    var aiSip = $(element).data('ai-sip');
                    if (!isReadOnly && aiSip && QfqNS.AiAssist) {
                        var aiUrl = $(element).data('ai-url');
                        var aiModes = String($(element).data('ai-assist') || '').split(',');
                        var aiLanguages = String($(element).data('ai-translate-languages') || '').split(',')
                            .map(function (l) { return l.trim(); })
                            .filter(function (l) { return l !== ''; });

                        var aiRun = function (aiMode, aiTargetLanguage) {
                            // If the menu was opened from the overflow toolbar drawer ('...'), close the drawer - it does not close on its own.
                            if (editor.queryCommandState('ToggleToolbarDrawer')) {
                                editor.execCommand('ToggleToolbarDrawer');
                            }
                            QfqNS.AiAssist.tinyMceAction(editor, aiUrl, aiSip, aiMode, aiTargetLanguage);
                        };

                        editor.ui.registry.addMenuButton('qfqAi', {
                            text: 'AI',
                            tooltip: 'AI assist',
                            fetch: function (callback) {
                                var items = [];
                                aiModes.forEach(function (aiMode) {
                                    aiMode = aiMode.trim();
                                    var aiConfig = QfqNS.AiAssist.modeConfig[aiMode];
                                    if (!aiConfig) {
                                        return;
                                    }
                                    if (aiMode === 'translate' && aiLanguages.length > 1) {
                                        items.push({
                                            type: 'nestedmenuitem',
                                            text: aiConfig.menuLabel,
                                            getSubmenuItems: function () {
                                                return aiLanguages.map(function (language) {
                                                    return {
                                                        type: 'menuitem',
                                                        text: '→ ' + QfqNS.AiAssist.languageLabel(language),
                                                        onAction: function () {
                                                            aiRun(aiMode, language);
                                                        }
                                                    };
                                                });
                                            }
                                        });
                                    } else {
                                        items.push({
                                            type: 'menuitem',
                                            text: aiConfig.menuLabel,
                                            onAction: function () {
                                                aiRun(aiMode, '');
                                            }
                                        });
                                    }
                                });
                                callback(items);
                            }
                        });
                    }

                    // if form is opened in readonly mode disable buttons and make element gray.
                    if (isReadOnly) {
                        editor.mode.set("readonly"); // Make TinyMCE read-only

                        // Apply styles directly
                        editor.on('init', function () {
                            // Disable and style TextArea
                            var iframe = document.querySelector("iframe#" + tinyMCEId + "_ifr");
                            if (iframe) {
                                var iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
                                var body = iframeDoc.body;
                                body.style.backgroundColor = "#f4f4f4";  // Light gray
                                body.style.color = "#888";              // Gray text
                                body.style.cursor = "not-allowed";      // Disable text selection
                                body.style.pointerEvents = "none";      // Disable interactions
                            }
                            // Style Toolbar
                            var toolbar = document.querySelector(".mce-toolbar-grp");
                            if (toolbar) {
                                toolbar.style.backgroundColor = "#f4f4f4";
                                toolbar.style.pointerEvents = "none";  // Prevent clicks

                            }
                            // Disable all toolbar buttons
                            var buttons = document.querySelectorAll(".mce-btn");
                            buttons.forEach(button => {
                                button.setAttribute("disabled", "true");
                                button.style.pointerEvents = "none";  // Prevents clicking
                                button.style.opacity = "0.5";  // Make them faded
                                button.style.backgroundColor = "#f4f4f4";
                            });
                            // Footer / Statusbar visual changes
                            var statusbar = document.querySelector(".mce-statusbar");
                            if (statusbar) {
                                statusbar.style.pointerEvents = "none";  // Prevent interactions
                                statusbar.style.backgroundColor = "#f4f4f4";
                            }
                        });
                    }

                    const $wrapper = $("#" + tinyMCEId + "-i").children(".extra-buttons-tinyMCE");
                    if ($wrapper.length) {

                        const $origInfoBtn = $wrapper.find(".extraButtonInfo");
                        const $origLockBtn = $wrapper.find(".extraButtonLock");

                        // Register Info Button if it exists
                        if ($origInfoBtn.length) {
                            editor.ui.registry.addButton("extraInfo", {
                                icon: "help",
                                tooltip: "Show Info",
                                onAction: function () {
                                    $("#" + tinyMCEId + "-extra-info").slideToggle("swing");
                                }
                            });

                            // Optionally remove the original button from the DOM
                            $origInfoBtn.detach();
                        }

                        // Register Lock Button if it exists
                        if ($origLockBtn.length) {
                            editor.ui.registry.addToggleButton("extraLock", {
                                icon: "lock",
                                tooltip: "Toggle Readonly",
                                onAction: function (api) {
                                    const isLocked = editor.mode.get() === "readonly";
                                    editor.mode.set(isLocked ? "design" : "readonly");

                                    // This toggles the active state
                                    api.setActive(!isLocked);
                                },
                                onSetup: function (api) {
                                    api.setActive(editor.mode.get() === "readonly");
                                }
                            });

                            $origLockBtn.detach();
                        }
                    }


                    var counterFlag = false;
                    if ($this.data('character-count-id') !== undefined) {
                        counterFlag = true;
                    }
                    var maxLength = config.maxLength; // set the maximum length here
                    var maxLengthDisplay = maxLength;
                    if (maxLength === '' || maxLength === 0) {
                        maxLength = false;
                        maxLengthDisplay = "∞";
                    }

                    var pattern = /[^\r\n\t\v\f ]/g;

                    editor.on('init', function() {
                        if (maxLength) {
                            editor.dom.setAttrib(editor.getBody(), 'maxlength', maxLength);
                        }

                        // initialize counter after first load
                        if (counterFlag) {
                            var content = editor.getContent({format: 'text'});
                            var charCount = (content.match(pattern) || []).length;
                            var characterCountTarget = "#" + $this.data('character-count-id');

                            $this.data('character-count-display', $(characterCountTarget));
                            $this.data('character-count-display').text(charCount + "/" + maxLengthDisplay);
                        }

                        editor.getBody().addEventListener('dblclick', function (e) {
                            const target = e.target;
                            if (target && target.nodeName === 'IMG') {
                                showImageModal(target.src);
                            }
                        });

                        const maxAttempts = 10;
                        const retryDelay = 300;
                        let attemptCount = 0;

                        const enableLockBtn = (lockButton) => {
                            lockButton.style.pointerEvents = "auto";
                            lockButton.removeAttribute("aria-disabled");
                            lockButton.classList.remove("tox-tbtn--disabled");
                        };

                        const observeLockBtn = (lockButton) => {
                            const observer = new MutationObserver(() => {
                                enableLockBtn(lockButton);
                            });
                            observer.observe(lockButton, {
                                attributes: true,
                                attributeFilter: ["aria-disabled"]
                            });
                            return observer;
                        };

                        const waitForLockButton = () => {
                            const lockButton = editor.getContainer().querySelector('.tox-tbtn[data-mce-name="extralock"]');
                            if (!lockButton) {
                                attemptCount++;
                                if (attemptCount < maxAttempts) {
                                    setTimeout(waitForLockButton, retryDelay);
                                }
                                return;
                            }

                            // Enable button and start observing
                            enableLockBtn(lockButton);
                            let observer = observeLockBtn(lockButton);

                            // Ensure this is kept in sync when the mode changes
                            editor.on("SetMode", function (e) {
                                if (e.mode === "readonly") {
                                    enableLockBtn(lockButton);
                                    observer.disconnect();
                                    observer = observeLockBtn(lockButton);
                                    lockButton.setAttribute("aria-pressed", "true");
                                } else {
                                    lockButton.setAttribute("aria-pressed", "false");
                                }
                            });

                            // Set readonly initially if desired
                            editor.mode.set("readonly");
                        };

                        waitForLockButton();

                    });

                    // Needed for accurate character count. Update counter after input or keyup.
                    editor.on('keyup', function() {
                        if (counterFlag) {
                            var content = editor.getContent({format: 'text'});
                            var charCount = (content.match(pattern) || []).length;
                            $this.data('character-count-display').text(charCount + "/" + maxLengthDisplay);
                        }
                    });

                    // Activate save button on every change (like regular input fields)
                    editor.on('input', function() {
                        element.value = editor.getContent();
                        $(element.closest("form")).trigger("change");
                    });

                    editor.on('Change', function() {
                        element.value = editor.getContent();
                        $(element.closest("form")).trigger("change");
                    });

                    editor.on('Dirty', function() {
                        element.value = editor.getContent();
                        $(element.closest("form")).trigger("change");
                    });

                    // Preventing input of more than maxLength
                    editor.on('keydown', function(e) {
                        if (maxLength) {
                            var content = editor.getContent({format: 'text'}); // get content without HTML tags
                            var charCount = (content.match(pattern) || []).length;
                            if (charCount >= maxLength && e.keyCode !== 8) { // prevent input when maximum length is reached
                                e.preventDefault();
                                e.stopPropagation();
                            }
                        }
                    });

                    // Apply Default Image Classes if given and none is set
                    if (defaultImageClass || defaultImageBorder) {
                        editor.on('SetContent', function () {
                            const images = editor.getBody().querySelectorAll('img');

                            images.forEach(img => {

                                if (defaultImageClass) {
                                    const classList = img.classList;

                                    const hasKnownClass =
                                        classList.contains('img-auto-tinyMCE') ||
                                        classList.contains('img-small-tinyMCE') ||
                                        classList.contains('img-medium-tinyMCE') ||
                                        classList.contains('img-large-tinyMCE') ||
                                        classList.contains('img-full-tinyMCE');

                                    if (!hasKnownClass) {
                                        img.classList.add(defaultImageClass);
                                    }
                                }

                                if (defaultImageBorder) {
                                    const hasStyle = img.hasAttribute('style');
                                    const isDefaulted = img.hasAttribute('data-border-default-applied');

                                    // Only apply default if no style and not already defaulted
                                    if (!hasStyle && !isDefaulted) {
                                        img.style = defaultImageBorder;
                                        img.setAttribute('data-border-default-applied', '1');
                                    }
                                }
                            });
                        });
                    }

                    // Remember content on focus to detect changes on blur
                    var oldContent = '';
                    editor.on('focus', function () {
                        oldContent = editor.getContent();
                    });

                    // Trigger dynamic update on focus lost, only if content changed (like regular input fields)
                    editor.on('blur', function () {
                        element.value = tinymce.get(QfqNS.escapeJqueryIdSelector(tinyMCEId)).getContent();
                        if (element.value !== oldContent) {
                            $(config.selector).trigger('change');
                        }
                        $(element).trigger('focusout');
                    });

                    /* Remove ReadOnly Again - we have to implement tinymce differently
                       to make it easier to change such  */
                    var me = editor;
                    var $parent = $(config.selector);
                    
                    $parent.on("blur", function(e, configuration) {
                        if(configuration.disabled || configuration.readonly) {
                            me.mode.set("readonly");
                            $(this).siblings(".mce-tinymce").addClass("qfq-tinymce-readonly");
                        } else {
                            me.mode.set("design");
                            $(this).siblings(".mce-tinymce").removeClass("qfq-tinymce-readonly");
                        }
                    });
                };

                var defaults = {
                    relative_urls : false,
                    remove_script_host : false,
                    license_key: 'gpl'
                };

                var tinyConfig = Object.assign(defaults, config);

                tinymce.init(tinyConfig);
                if($(this).is('[disabled]')) {
                    myEditor.mode.set("readonly");
                }
            }
        );
    };

    /**
     * Force update of the shadowed `<textarea>`. Usually called before a form submit.
     */
    tinyMce.prepareSave = function () {
        if (typeof tinymce === 'undefined') {
            return;
        }

        tinymce.triggerSave();
    };

    n.tinyMce = tinyMce;


})(QfqNS.Helper);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* @depend FormGroup.js */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq.Element Namespace
 *
 * @namespace QfqNS.Element
 */
QfqNS.Element = QfqNS.Element || {};

(function (n) {
    'use strict';

    /**
     * Checkbox (`<checkbox>`) Form Group.
     *
     * @param $element
     * @constructor
     * @name QfqNS.Element.Checkbox
     */
    function Checkbox($element) {
        n.FormGroup.call(this, $element);

        var type = "checkbox";

        if (!this.isType(type)) {
            throw new Error("$element is not of type 'checkbox'");
        }

        // We allow one Form Group to have several checkboxes. Therefore, we have to remember which checkbox was
        // selected if possible.
        if ($element.length === 1 && $element.attr('type') === type) {
            this.$singleElement = $element;
        } else {
            this.$singleElement = null;
        }
    }

    Checkbox.prototype = Object.create(n.FormGroup.prototype);
    Checkbox.prototype.constructor = Checkbox;

    Checkbox.prototype.setValue = function (val) {
        if (this.$singleElement) {
            this.$singleElement.prop('checked', val);
        } else {
            this.$element.prop('checked', val);
        }
    };

    Checkbox.prototype.getValue = function () {
        if (this.$singleElement) {
            return this.$singleElement.prop('checked');
        } else {
            return this.$element.prop('checked');
        }
    };

    n.Checkbox = Checkbox;

})(QfqNS.Element);

/**
 * @author Benjamin Baer <benjamin.baer@math.uzh.ch>
 */

/* global $ */
/* global EventEmitter */
/* @depend ../QfqEvents.js */


/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};

(function (n) {
    'use strict';

    n.ElementBuilder = function(obj, parent) {
        this.type  = obj.type;
        this.class = obj.class || false;
        this.text  = obj.text || false;
        this.tooltip = obj.tooltip || false;
        this.label = obj.label || false;
        this.value = obj.value || false;
        this.width = obj.width || false;
        this.name = obj.name || false;
        this.onClick = obj.onClick || false;
        this.parent = parent || false;
        this.submitTo = obj.submitTo || false;
        this.checked = obj.checked || false;
        this.$element = {};
        this.children = [];
        this.eventEmitter = new EventEmitter();


        if (obj.children) {
            for (var i = 0; i < obj.children.length; i++) {
                var iparent = this;
                if (this.type !== "form") {
                    iparent = this.parent;
                }
                var element = new n.ElementBuilder(obj.children[i], iparent);

                this.children.push(element);
            }
        }

    };

    n.ElementBuilder.prototype.on = n.EventEmitter.onMixin;

    n.ElementBuilder.prototype.display = function() {
        var $element = {};

        if (this.type === "form") {
            $element = this._buildForm();
            var that = this;
            $element.submit(function(event) {
                event.preventDefault();
                that.handleSubmit();
            });
            this.$form = $element;
        }

        if (this.type === "row") $element = this._buildRow();
        if (this.type === "checkbox" ||
            this.type === "radio" ||
            this.type === "hidden") $element = this._buildInput();
        if (this.type === "label") $element = this._buildLabel();

        if (this.children) {
            for (var i = 0; i < this.children.length; i++) {
                var $child = this.children[i].display();
                $element.append($child);
            }
        }

        this.$element = $element;
        return $element;
    };

    n.ElementBuilder.prototype._buildRow = function() {
        var options = {
            class: "row" + this._getOption(this.class),
            text: this._getOption(this.text)
        };
        return $("<div />", options);
    };

    n.ElementBuilder.prototype._buildInput = function() {
        var $block = {};

        var options = {
            class: this._getOption(this.class),
            type: this._getOption(this.type),
            name: this._getOption(this.name),
            value: this._getOption(this.value)
        };

        if (this.type === "checkbox" || this.type === "radio") {
            options.checked = this.checked;
        }

        if (this.type !== "hidden") {
            $block = this._buildBlock(this.width);
        } else {
            options.required = false;
        }

        var $input = $("<input />", options);

        if (this.onClick === "submit") {
            var that = this;
            $input.on("click", function() {
                that.submit();
            });
        }

        if (this.type !== "hidden") {
            $block.append($input);
            return $block;
        } else {
            $input.removeAttr("pattern");
            return $input;
        }
    };

    n.ElementBuilder.prototype._buildLabel = function() {
        var $block = this._buildBlock(this.width, "qfq-label");
        var options = {
            class: "control-label" + this._prepareClass(this.class, true),
            text: this._getOption(this.text)
        };
        var $label = $("<span />", options);
        $block.append($label);
        return $block;
    };

    n.ElementBuilder.prototype._buildBlock = function(size, cssClass) {
        var options = {
            class: "col-md-" + size + this._prepareClass(cssClass, true)
        };
        return $("<div />", options);
    };

    n.ElementBuilder.prototype._prepareClass = function(value, isAddition) {
        if (isAddition) return " " + value;
        return "" + value;
    };

    n.ElementBuilder.prototype._buildForm = function() {
        return $("<form />");
    };

    n.ElementBuilder.prototype._getOption = function(o) {
        if (o !== undefined && o) {
            return o;
        } else {
            return "";
        }
    };

    n.ElementBuilder.prototype.submit = function() {
          if (this.type !== "form") {
              this.parent.$element.submit();
          } else {
              this.$element.submit();
          }
    };

    n.ElementBuilder.prototype.handleSubmit = function() {
        $.post(this.submitTo, this.$element.serialize())
            .done(this.submitSuccessHandler.bind(this))
            .fail(this.submitFailureHandler.bind(this));
    };

    n.ElementBuilder.prototype.submitSuccessHandler = function(data, textStatus, jqXHR) {
        var configuration = data['element-update'];
        n.ElementUpdate.updateAll(configuration);
        this.eventEmitter.emitEvent('form.submit.success',
            n.EventEmitter.makePayload(this, "submit"));
    };

    n.ElementBuilder.prototype.submitFailureHandler = function (data, textStatus, jqXHR) {
        console.error("Submit failed");
    };

})(QfqNS);

/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq.Element Namespace
 *
 * @namespace QfqNS.Element
 */
QfqNS.Element = QfqNS.Element || {};

(function (n) {
    'use strict';

    /**
     * Factory for FormGroupS.
     *
     * @param name
     * @returns {*}
     * @function QfqNS.Element.getElement
     */
    n.getElement = function (name) {
        var elementName;
        var $element = $('[name="' + QfqNS.escapeJqueryIdSelector(name) + '"]:not([type="hidden"])');
        if ($element.length === 0) {
            // Get right element for tinymce with given name (is hidden input field)
            $element = $('[name="' + QfqNS.escapeJqueryIdSelector(name) + '"]');
            $element = getTypeAheadInput($element);
            if ($element === undefined) {
                throw Error('No element with name "' + name + '" found.');
            }
        }

        // Handle <select> and <textarea>
        elementName = $element[0].nodeName.toLowerCase();
        if (elementName === "select") {
            return new n.Select($element);
        }

        if (elementName === "textarea") {
            return new n.TextArea($element);
        }

        // Handle chat element
        if ($element.hasClass('qfq-chat')) {
            return $element[0].querySelector('.qfq-chat-window');
        }

        // Since it is neither a <select> nor a <textarea>, we assume it is an <input> element. Thus we analyze the
        // type attribute
        if (!$element[0].hasAttribute('type')) {
            return new n.Textual($element);
        }

        var type = $element[0].getAttribute('type').toLowerCase();

        if (type === 'checkbox') {
            var $formGroup = $('#' + $element.attr('id') + '-i');
            if (!$formGroup || $formGroup.length === 0) {
                type = 'nonFormGroupCheckbox';
            }
        }

        switch (type) {
            case 'checkbox':
                return new n.Checkbox($element);
            case 'nonFormGroupCheckbox':
            case "file":
                return $element;
            case 'radio':
                return new n.Radio($element);
            case 'text':
            case 'number':
            case "email":
            case "url":
            case "password":
            case "datetime":
            case "datetime-local":
            case "date":
            case "month":
            case "time":
            case "week":
            case "search":
                return new n.Textual($element);
            default:
                throw new Error("Don't know how to handle <input> of type '" + type + "'");
        }

        // Get typeahead input element
        function getTypeAheadInput (elem) {
            var selector = '.twitter-typeahead';
            var sibling = elem.prev();
            var siblingCount = elem.siblings().length;
            // If the sibling matches our selector, use it
            // If not, jump to the next sibling and continue the loop
            for (var i = 0; i < siblingCount; i++) {
                if (sibling.is(selector)) return sibling.children('.tt-input');
                sibling = sibling.prev();
            }
        }
    };
})(QfqNS.Element);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq.Element Namespace
 *
 * @namespace QfqNS.Element
 */
QfqNS.Element = QfqNS.Element || {};

(function (n) {
    'use strict';


    /**
     * Radio (`<radio>`) Form Group.
     *
     * @param $element
     * @constructor
     * @name QfqNS.Element.Radio
     */
    function Radio($element) {
        n.FormGroup.call(this, $element);

        if (!this.isType("radio")) {
            throw new Error("$element is not of type 'radio'");
        }
    }

    Radio.prototype = Object.create(n.FormGroup.prototype);
    Radio.prototype.constructor = Radio;

    Radio.prototype.setValue = function (val) {
        this.$element.prop('checked', false);
        this.$element.filter('[value="' + val.replace(/"/g, "\\\"") + '"]').prop('checked', true);
    };

    Radio.prototype.getValue = function () {
        return this.$element.filter(':checked').val();
    };

    n.Radio = Radio;

})(QfqNS.Element);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq.Element Namespace
 *
 * @namespace QfqNS.Element
 */
QfqNS.Element = QfqNS.Element || {};

(function (n) {
    'use strict';


    /**
     * Select (`<select>`) Form Group.
     *
     * @param $element
     * @constructor
     * @name QfqNS.Element.Select
     */
    function Select($element) {
        n.FormGroup.call(this, $element, 'select');

        if (!this.isType("select")) {
            throw new Error("$element is not of type 'select'");
        }
    }

    Select.prototype = Object.create(n.FormGroup.prototype);
    Select.prototype.constructor = Select;

    /**
     * Set the value or selection of a `<select>` tag
     *
     * @param {string|array} val when passing a string, the corresponding <option> tag will get selected. If passed
     * array of objects, `<select>` will have its `<option>` tags set correspondingly.
     */
    Select.prototype.setValue = function (val) {
        if (['string', 'number'].indexOf(typeof(val)) !== -1) {
            this.setSelection(val);
        } else if (Array.isArray(val)) {
            this.$element.empty();

            // Fill array with new <select> elements first and add it to the dom in one step, instead of appending
            // each '<select>' separately.
            var selectArray = [];
            val.forEach(function (selectObj) {
                var $option = $('<option>')
                    .attr('value', selectObj.value ? selectObj.value : selectObj.text)
                    .prop('selected', selectObj.selected ? selectObj.selected : false)
                    .append(selectObj.text);
                selectArray.push($option);
            });
            this.$element.append(selectArray);
        } else {
            throw Error('Unsupported type of argument in Select.setValue: "' + typeof(val) + '". Expected either' +
                ' "string" or "array"');
        }
    };

    /**
     *
     * @param val
     *
     * @private
     */
    Select.prototype.setSelection = function (val) {
        this.clearSelection();

        // First, see if we find an <option> tag having an attribute 'value' matching val. If that doesn't work,
        // fall back to comparing text content of <option> tags.
        var $selectionByValue = this.$element.find('option[value="' + val.replace(/"/g, "\\\"") + '"]');
        if ($selectionByValue.length > 0) {
            $selectionByValue.prop('selected', true);
        } else {
            this.$element.find('option').each(function () {
                var $element = $(this);
                if ($element.text() === val) {
                    $element.prop('selected', true);
                }

                return true;
            });
        }
    };

    /**
     * @private
     */
    Select.prototype.clearSelection = function () {
        this.$element.find(':selected').each(function () {
            $(this).prop('selected', false);
        });
    };

    Select.prototype.getValue = function () {
        var returnValue = [];
        this.$element.find(':selected').each(
            function () {
                if (this.hasAttribute('value')) {
                    returnValue.push(this.getAttribute('value'));
                } else {
                    returnValue.push($(this).text());
                }
            }
        );

        return returnValue;
    };

    n.Select = Select;

})(QfqNS.Element);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */
/* global $ */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq.Element Namespace
 *
 * @namespace QfqNS.Element
 */
QfqNS.Element = QfqNS.Element || {};

(function (n) {
    'use strict';


    /**
     * Textarea (`<textarea>`) Form Group.
     *
     * @param $element
     * @constructor
     * @name QfqNS.Element.TextArea
     */
    function TextArea($element) {
        n.FormGroup.call(this, $element, 'textarea');

        if (!this.isType("textarea")) {
            throw new Error("$element is not of type 'textarea'");
        }
    }

    TextArea.prototype = Object.create(n.FormGroup.prototype);
    TextArea.prototype.constructor = TextArea;

    /**
     * Set the value or selection of a `<textarea>` tag
     *
     * @param {string} val content of the textarea
     */
    TextArea.prototype.setValue = function (val) {
        this.$element.val(val);
    };

    TextArea.prototype.getValue = function () {
        return this.$element.val();
    };

    n.TextArea = TextArea;

})(QfqNS.Element);

/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq.Element Namespace
 *
 * @namespace QfqNS.Element
 */
QfqNS.Element = QfqNS.Element || {};

(function (n) {
    'use strict';


    /**
     * Textual `<input>` form groups.
     *
     * @param $element
     * @constructor
     * @name QfqNS.Element.Textual
     */
    function Textual($element) {
        n.FormGroup.call(this, $element);

        var textualTypes = [
            'text',
            'datetime',
            'datetime-local',
            'date',
            'month',
            'time',
            'week',
            'number',
            'range',
            'email',
            'url',
            'search',
            'tel',
            'password',
            'hidden'
        ];
        var textualTypesLength = textualTypes.length;
        var isTextual = false;

        for (var i = 0; i < textualTypesLength; i++) {
            if (this.isType(textualTypes[i])) {
                isTextual = true;
                break;
            }
        }

        if (!isTextual) {
            throw new Error("$element is not of type 'text'");
        }
    }

    Textual.prototype = Object.create(n.FormGroup.prototype);
    Textual.prototype.constructor = Textual;

    Textual.prototype.setValue = function (val) {
        // Typeahead delivers an array with refreshed value after save progress.
        if (Array.isArray(val)) {
            this.setTypeAheadInput(val);
        } else {
            this.$element.val(val);
        }
    };

    // Apply and see changed value after save (no page reload needed), typeahead elements needs to be handled separately.
    Textual.prototype.setTypeAheadInput = function (value) {
        this.$element.val(value[0].key);
        // Display new value and correctly set new key in hidden input element.
        this.$element.eq(1).typeahead('val', value[0].value);
        var hiddenElement =  this.$element.eq(0).closest('div').find('input[type=hidden]');
        hiddenElement.val(value[0].key);
    };

    Textual.prototype.getValue = function () {
        return this.$element.val();
    };

    n.Textual = Textual;

})(QfqNS.Element);
/**
 * @author Rafael Ostertag <rafael.ostertag@math.uzh.ch>
 */

/**
 * Qfq Namespace
 *
 * @namespace QfqNS
 */
var QfqNS = QfqNS || {};
/**
 * Qfq.Element Namespace
 *
 * @namespace QfqNS.Element
 */
QfqNS.Element = QfqNS.Element || {};

(function (n) {
    'use strict';

    /**
     * Known values of `type` attribute of `<input>` elements
     *
     * @type {string[]}
     */
    n.knownElementTypes = [
        'text',
        'password',
        'checkbox',
        'radio',
        'button',
        'submit',
        'reset',
        'file',
        'hidden',
        'image',
        'datetime',
        'datetime-local',
        'date',
        'month',
        'time',
        'week',
        'number',
        'range',
        'email',
        'url',
        'search',
        'tel',
        'color'
    ];

    /*
     * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input for input types ignoring the readonly
     * attribute.
     */
    n.readOnlyIgnored = [
        'hidden',
        'range',
        'checkbox',
        'radio',
        'file',
        'select'
    ];

})(QfqNS.Element);
class ImageAdjust {

    initialize(parentElement, qfqPage, n) {
        this.qfqPage = qfqPage
        this.n = n
        const canvas = document.createElement('canvas')
        this.parentElement = parentElement
        const elementSize = parentElement.getBoundingClientRect()
        this.options = parentElement.dataset
        canvas.width = this.options.outputWidth
        canvas.height = this.options.outputHeight
        this.rotation = 0
        this.canvasElement = canvas
        this._addControls()
        this.parentElement.append(canvas)
        this.canvas = this.__canvas = new fabric.Canvas(canvas, {
            isDrawingMode: false,
            selection: false,
            isDragging: false,
            enableRetinaScaling: false,
            centeredScaling: true,
            stateful: true,
            backgroundColor: '#fff'
        })
        var that = this
        this.canvas.on('mouse:down', function(e) { that.panStart(e) })
        this.canvas.on('mouse:move', function(e) { that.panMove(e) })
        this.canvas.on('mouse:up', function(e) { that.panEnd() })
        this.canvas.on('mouse:wheel', function(opt) { that.dynamicZoom(opt) })
        this.zoom = false
        this._setImage()
        this._addMargins()
    }

    _setImage() {
        fabric.util.loadImage(this.options.image, img => {
            this.image = new fabric.Image(img)
            this._sizeImageToCanvas()
            this.image.set({
                // Some options to make the image static
                selectable: false,
                evented: false,
                lockMovementX: true,
                lockMovementY: true,
                lockRotation: true,
                lockScalingX: true,
                lockScalingY: true,
                lockUniScaling: true,
                hasControls: false,
                imageSmoothing: true,
                hasBorders: false,
            })
            this.canvas.add(this.image)
            this.canvas.centerObject(this.image)
            this.image.setCoords()
            this.canvas.renderAll()
            this.importJSON()
        },  { crossOrigin: 'anonymous' })     
    }

    _sizeImageToCanvas() {
        this.scale = Math.max(this.canvas.width / this.image.width, this.canvas.height / this.image.height)
        this.image.scale(this.scale)

    }

    _addControls() {
        const wrap = document.createElement("div")
        this.parentElement.append(wrap)
        wrap.classList.add('qfq-image-adjust-buttons', 'btn-group')
        const buttons = this._getButtons()
        for (const button of buttons) {
            const element = this._createButton(button)
            wrap.append(element)
        }
        if(this.options.imgPreviewId) {
            const element = this.createElement({
                classList: ["btn","btn-default"],
                icon: {
                    classList: ["fas", "fa-camera-retro"]
                },
                text: "",
                click: this.showImage.bind(this)
            })
            wrap.append(element)
        }
    }

    _createButton(button) {
        const element = document.createElement("button")
        element.classList.add(...button.classList)
        const icon = document.createElement("i")
        if(button.icon.classList.length > 0) icon.classList.add(...button.icon.classList)
        element.textContent = button.text
        element.setAttribute("title", button.title)
        element.append(icon)
        element.addEventListener('click', (e) => { button.click(e) })
        return element
    }

    _addMargins() {
        const container = document.querySelector(".canvas-container")
        const margins = JSON.parse(this.options.darkenMargins)
        for(const [key, value] of Object.entries(margins)) {
            console.log(key, value)
            const margin = document.createElement("div")
            margin.classList.add("qfq-ia-margin-" + key)
            if(key === 'left' || key == 'right') {
                margin.style.width = value + "px"
                margin.style.height = this.canvas.height - margins.top - margins.bottom + "px"
                margin.style.top = margins.top + "px"
            } else {
                margin.style.height = value + "px"
                margin.style.width = this.canvas.width + "px"
            }
            container.append(margin)
        }
    }

    exportJSON() {
        const settings = {
            scale: this.scale,
            //coords: this.image._getCoords(true),
            //viewport: this.canvas.viewportTransform,
            left: this.image.left,
            top: this.image.top,
            rotation: this.rotation,
            flipX: this.image.flipX,
            flipY: this.image.flipY
        }
        const output = document.getElementById(this.options.fabricJsonId)
        if(output) output.value = JSON.stringify(settings)
    }

    importJSON() {
        const input = document.getElementById(this.options.fabricJsonId)
        if(input && input.value.length > 0) {
            const unescape = decodeURIComponent(input.value)
            console.log("Trying to parse settings", unescape)
            const settings = JSON.parse(unescape)
            console.log("Imported Settings", settings)
            this._applySettings(settings)
        }
    }

    _applySettings(settings) {
        this.scale = settings.scale
        this.image.rotate(settings.rotation)
        this.rotation = settings.rotation || 0
        this.image.left = settings.left || 0
        this.image.top = settings.top || 0
        this.image.flipX = settings.flipX
        this.image.flipY = settings.flipY
        //this.canvas.setViewportTransform(this.canvas.viewportTransform)
        this.image.scale(settings.scale)
        //this.image.aCoords = settings.coords
        //this.image.setCoords()
        this.canvas.renderAll()
    }

    flipX() {
        this.image.flipX = !this.image.flipX
        this.canvas.renderAll()
        this.changeHandler()
    }

    flipY() {
        this.image.flipY = !this.image.flipY
        this.canvas.renderAll()
        this.changeHandler()
    }

    rotate() {
        this.rotation += 90
        console.log(this.rotation)
        this.image.rotate(this.rotation)
        this.canvas.renderAll()
        this.changeHandler()
    }

    zoomIn(e) {
        const scale = this.scale + 0.05;
        this._setScale(scale)
    }

    zoomOut(e) {
        const scale = this.scale - 0.05;
        this._setScale(scale)
    }
    
    toggleZoom(e) {
        this.zoom = !this.zoom
        e.target.classList.toggle("active")
    }

    dynamicZoom(opt) {
        if(!this.zoom) return
        const delta = opt.e.deltaY * -0.0001
        const scale = this.scale + delta;
        this._setScale(scale)
        opt.e.preventDefault()
        opt.e.stopPropagation()
    }

    fitImageMin() {
        const scale = Math.min(this.canvas.width / this.image.width, this.canvas.height / this.image.height)
        this._setScale(scale, true)
    }

    fitImageMax() {
        const scale = Math.max(this.canvas.width / this.image.width, this.canvas.height / this.image.height)
        this._setScale(scale, true)
    }

    panLeft(e) {
        const pan = ""
    }

    panRight(e) {
        const pan = ""
    }

    panStart(opt) {
        const e = opt.e
        this.canvas.isDragging = true
        this.canvas.lastPosX = e.clientX
        this.canvas.lastPosY = e.clientY
    }

    panMove(opt) {
        if(!this.canvas.isDragging) return
        const e = opt.e
        /*
        const vpt = this.canvas.viewportTransform
        vpt[4] += e.clientX - this.canvas.lastPosX
        vpt[5] += e.clientY - this.canvas.lastPosY
        */
        this.image.left += e.clientX - this.canvas.lastPosX
        this.image.top += e.clientY - this.canvas.lastPosY
        this.canvas.requestRenderAll()
        this.canvas.lastPosX = e.clientX
        this.canvas.lastPosY = e.clientY
    }

    panEnd(e) {
        //this.canvas.setViewportTransform(this.canvas.viewportTransform);
        this.canvas.isDragging = false;
        this.changeHandler()
    }

    changeHandler() {
        this._exportImage(false)
        this.exportJSON()
        if (this.qfqPage.qfqForm) {
            this.qfqPage.qfqForm.eventEmitter.emitEvent('form.changed', this.n.EventEmitter.makePayload(this, null))
            this.qfqPage.qfqForm.changeHandler()
            this.qfqPage.qfqForm.form.formChanged = true
        } else {
            console.log("Error: Couldn't initialize qfqForm - not possible to send form.changed event")
        }
    }

    _setScale(scale, center = false) {
        if(scale > 2.0) scale = 2.0
        if(scale < 0.001) scale = 0.001
        this.image.scale(scale)
        this.scale = scale
        if(center) this.canvas.centerObject(this.image)
        this.image.setCoords()
        this.canvas.renderAll()
        this.changeHandler()
    }

    export() {
        console.log("called export")
        this._exportImage(false)
    }

    showImage() {
        console.log("called show image")
        this._exportImage(true)
    }

    _getButtons() {
        return [{
            classList: ["btn","btn-default"],
            icon: {
                classList: ["fa", "fa-search-plus"]
            },
            text: "",
            title: "Zoom in",
            click: this.zoomIn.bind(this)
        },
        {
            classList: ["btn","btn-default"],
            icon: {
                classList: ["fa", "fa-search-minus"]
            },
            text: "",
            title: "Zoom out",
            click: this.zoomOut.bind(this)
        },
        {
            classList: ["btn","btn-default"],
            icon: {
                classList: ["fa", "fa-search"]
            },
            text: "",
            title: "Toggle Zoom via Mousewheel",
            click: this.toggleZoom.bind(this)
        },
        {
            classList: ["btn","btn-default"],
            icon: {
                classList: ["fas", "fa-arrows-alt-h"]
            },
            text: "",
            title: "Flip image horizontal",
            click: this.flipX.bind(this)
        },
        {
            classList: ["btn","btn-default"],
            icon: {
                classList: ["fas", "fa-arrows-alt-v"]
            },
            text: "",
            title: "Flip image vertical",
            click: this.flipY.bind(this)
        },
        {
            classList: ["btn","btn-default"],
            icon: {
                classList: ["fas", "fa-compress-alt"]
            },
            text: "",
            title: "Scale image to display whole image",
            click: this.fitImageMin.bind(this)
        },
        {
            classList: ["btn","btn-default"],
            icon: {
                classList: ["fas", "fa-expand-alt"]
            },
            text: "",
            title: "Scale image to fill canvas",
            click: this.fitImageMax.bind(this)
        },
        {
            classList: ["btn","btn-default"],
            icon: {
                classList: ["fas", "fa-redo"]
            },
            text: "",
            title: "Rotate image clockwise",
            click: this.rotate.bind(this)
        }]
    }

    _exportImage(asImage) {
        const dataURL = this.canvas.toDataURL({
            format: this.options.outputFormat,
            quality: this.options.outputQualityJpeg
        })
        
        const selector = asImage ? this.options.imgPreviewId : this.options.base64Id
        const output = document.getElementById(selector)
        if(output) {
            if(asImage) {
                output.src = dataURL
            } else {
                output.value = dataURL
            }
            
        } else {
            console.log("Target " + (asImage ? "img tag" : "input") + " not found", this.options)
        }
    }

}

/* init without qfq 
document.addEventListener("DOMContentLoaded", function() {
    const imageEditors = document.querySelectorAll(".qfq-image-adjust")
    console.log("Image Adjust Elements", imageEditors)
    imageEditors.forEach(editor => {
        console.log("Current Editor", editor)
        const imageAdjust = new ImageAdjust()
        imageAdjust.initialize(editor, {})
    })
})
*/