MediaWiki:Gadget-wikt.auto-complete.js

Remarque: après avoir sauvegardé, vous devez vider le cache de votre navigateur pour que les changements prennent effet. Mozilla, cliquez sur Actualiser (ou ctrl-r). Internet Explorer / Opera: ctrl-f5. Safari: cmd-r. Konqueror ctrl-r.

/**
 * (en)
 * This gadget adds template value suggestion when pressing Ctrl+Space.
 * Upon pressing the keys, if the cursor is inside a supported template,
 * a list of values suggestions will pop up. If some text has already
 * been typed, only values that start with this text will be suggested.
 * The list is updated whenever a key is pressed.
 * ----------------------------------------------------------------------
 * (fr)
 * Ce gadget ajoute la suggestion de valeurs pour certains modèles
 * lorsque l’utilisateur appuie sur Ctrl+Espace. Après avoir appuyé sur
 * ces touches, si le curseur est dans un modèle supporté, une liste de
 * suggestions de valeurs apparait. Si du texte a déjà été entré, seules
 * les valeurs commençant par le texte en question seront suggérées. La
 * liste est mise à jour à chaque fois que l’utilisateur appuie sur une
 * touche.
 * ----------------------------------------------------------------------
 * [[Catégorie:JavaScript du Wiktionnaire|auto-complete]]
 * <nowiki>
 */
$(function () {
  "use strict";

  if (!["edit", "submit"].includes(mw.config.get("wgAction"))) {
    return;
  }

  console.log("Chargement de Gadget-wikt.auto-complete.js…");

  class GadgetAutoComplete {
    static NAME = "Auto-complétion de modèles";
    static VERSION = "1.1.1";

    /** Maximum number of suggestions to display. */
    static #MAX_SUGGESTIONS = 100;

    /**
     * Associates templates to a list of values to suggest.
     * @type {Object<string, string[]>}
     */
    #templates = {};
    /** Indicates whether the gadget is making suggestions to the user. */
    #suggestMode = false;
    /** The suggestions jQuery component. */
    #$suggestionBox = null;
    /**
     * The list of suggestions for the current value.
     * @type {string[][]}
     */
    #suggestions = [];
    /** The index of the selected suggestion in the list. */
    #suggestionIndex = 1;
    /** The position the cursor is supposed to be at after inserting a suggestion. */
    #cursorPosition = -1;

    /**
     * Initializes this gadget by registering callbacks on
     * the document body and inserting the suggestions box.
     */
    constructor() {
      $(document.body).keydown(event => {
        if (this.#suggestMode) {
          if (["ArrowUp", "ArrowDown", "Tab"].includes(event.code)) {
            // Prevent caret from moving and focus from changing
            event.preventDefault();
          }
        }
      });

      $(document.body).keyup(event => {
        // Register cursor position for insertion
        this.#cursorPosition = wikt.edit.getCursorLocation();

        if (!this.#suggestMode) {
          if (event.ctrlKey && event.code === "Space") {
            if (this.#suggest()) {
              this.#enableSuggestions(true);
            }
          }
        } else {
          let cursorPos = this.#cursorPosition;

          if (event.code === "Escape" || event.key === "}") {
            this.#enableSuggestions(false);
          } else if (event.code === "ArrowUp") { // FIXME fait nimp avec CodeMirror
            this.#selectSuggestion(this.#suggestionIndex - 1);
          } else if (event.code === "ArrowDown") { // FIXME fait nimp avec CodeMirror
            this.#selectSuggestion(this.#suggestionIndex + 1);
          } else if (event.code === "Tab") {
            var text = this.#insertSuggestion();
            cursorPos += text.length;
          } else if (!this.#suggest()) {
            this.#enableSuggestions(false);
          }

          if (["ArrowUp", "ArrowDown", "Tab"].includes(event.code)) {
            wikt.edit.setCursorLocation(cursorPos);
          }
        }
      });

      $(document.body).click(event => {
        // Check if click target is inside the suggestions box.
        if ($(event.target).closest(this.#$suggestionBox).length) {
          let editBox;
          if (wikt.edit.isCodeMirrorEnabled()) {
            editBox = wikt.edit.getCodeMirror();
          } else {
            editBox = wikt.edit.getEditBox();
          }
          if (editBox) {
            editBox.focus();
          }
          wikt.edit.setCursorLocation(this.#cursorPosition);
        } else {
          this.#enableSuggestions(false);
        }
      });

      this.#$suggestionBox = $('<div id="autocomplete-suggestion-box"><span class="autocomplete-no-suggestions">Aucune suggestion</span><ul></ul></div>');
      $("body").append(this.#$suggestionBox);
      this.#$suggestionBox.hide();
    }

    /**
     * Declares parameter values for the given template.
     * @param templateName {string} Template’s name.
     * @param templateParameters {string[]} Template’s parameter values.
     */
    addTemplateParameters(templateName, templateParameters) {
      this.#templates[templateName] = templateParameters;
    }

    /**
     * Enables/disables suggestions.
     * @param enable {boolean} True to enable; false to disable.
     */
    #enableSuggestions(enable) {
      this.#suggestMode = enable;
      if (this.#suggestMode) {
        this.#$suggestionBox.show();
      } else {
        this.#$suggestionBox.hide();
      }
    }

    /**
     * Fetches the current template the cursor is in then adds the relevent suggestions to the list.
     * @return {boolean} True if the cursor is in a template and there are suggestions for it.
     */
    #suggest() {
      const cursorPosition = wikt.edit.getCursorLocation();
      const text = wikt.edit.getText().substring(0, cursorPosition);
      const templateStart = text.lastIndexOf("{{");
      const templateEnd = text.lastIndexOf("}}");

      if (templateStart > templateEnd || templateStart >= 0 && templateEnd === -1) {
        const template = text.substring(templateStart + 2);
        const templateName = template.substring(0, template.indexOf("|"));

        if (templateName && this.#templates[templateName]) {
          this.#updateSuggestions(
              templateName,
              template.substring(template.lastIndexOf("|") + 1),
              this.#templates[templateName]
          );
          return true;
        }
      }

      return false;
    }

    /**
     * Adds to the list the available suggestions for the current template and value.
     * @param templateName {string} The template the cursor is in.
     * @param value {string} The value on the left of the cursor.
     * @param values {string[]} The available values for the template and the given value.
     */
    #updateSuggestions(templateName, value, values) {
      const $list = this.#$suggestionBox.find("ul");
      $list.empty();
      this.#suggestions.splice(0, this.#suggestions.length); // Clear array

      var filteredValues = values.filter(function (v) {
        // noinspection JSUnresolvedFunction
        return v.toLowerCase().startsWith(value.toLowerCase());
      });

      filteredValues.forEach((v, i) => {
        if (i >= GadgetAutoComplete.#MAX_SUGGESTIONS) {
          return;
        }

        var prefix = v.substr(0, value.length);
        var rest = v.substr(value.length);
        var $item = $('<li><span class="autocomplete-highlight">{0}</span>{1}</li>'.format(prefix, rest));

        $item.click(() => {
          this.#selectSuggestion(i);
          this.#insertSuggestion();
        });
        $list.append($item);
        this.#suggestions.push([prefix, rest]);
      });

      var $message = this.#$suggestionBox.find("span:not(.autocomplete-highlight)");
      if (filteredValues.length) {
        this.#selectSuggestion(0);
        $message.hide();
      } else {
        this.#suggestionIndex = -1;
        $message.show();
      }
    }

    #selectSuggestion(i) {
      if (this.#suggestions.length !== 0) {
        const m = this.#suggestions.length;
        this.#suggestionIndex = ((i % m) + m) % m; // True modulo operation
        this.#$suggestionBox.find("ul li").removeClass("autocomplete-selected");
        this.#$suggestionBox.find("ul li:nth-child({0})".format(this.#suggestionIndex + 1))
            .addClass("autocomplete-selected");
      }
    }

    #insertSuggestion() {
      if (this.#suggestionIndex !== -1) {
        const cursorPosition = wikt.edit.getCursorLocation();
        const suggestionPrefix = this.#suggestions[this.#suggestionIndex][0];
        const suggestionSuffix = this.#suggestions[this.#suggestionIndex][1];

        // Replace already typed text by the start of the selected suggestion.
        wikt.edit.replaceText(this.#cursorPosition - suggestionPrefix.length, this.#cursorPosition, suggestionPrefix);
        // Insert the rest of the suggestion on the right of the cursor.
        wikt.edit.insertText(cursorPosition, suggestionSuffix);
        this.#cursorPosition += suggestionSuffix.length;
        this.#enableSuggestions(false);

        return suggestionSuffix;
      }

      return "";
    }
  }

  const ac = new GadgetAutoComplete();
  window.gadget_autoComplete = ac;

  /**
   * Converts the given string representing a LUA table to a JSON object.
   * @param rawLua {string} The string containing the LUA table.
   * @return {Object} The corresponding JSON object.
   */
  function luaDataPageToJson(rawLua) {
    const startToken = "-- $Table start$\n";
    const startIndex = rawLua.indexOf(startToken);
    const endIndex = rawLua.indexOf("-- $Table end$");
    const rawParams = rawLua.substring(startIndex === -1 ? 0 : startIndex + startToken.length, endIndex === -1 ? rawLua.length : endIndex)
        .replaceAll(/--.+$/gm, "") // Remove comments
        .replaceAll(/^local\s+.+?=\s*(?={)/gm, "") // Remove assignment
        .replaceAll(/^(\s*)\[(['"])(.+?)\2]\s*=/gm, '$1"$3":') // Convert Lua keys syntax to JS
        .replaceAll(/(')(.*?[^\\])\1/g, '"$2"') // Convert ' to "
        .replaceAll(/\\'/g, "'") // Unescape single quotes
        .replaceAll(/(\w+)\s*=/g, '"$1":') // Add quotes on unquoted keys
        .replaceAll(/,(?=\s*})/gm, "") // Remove trailing commas
        .replaceAll(/{([^:]*?)}/gm, "[$1]") // Convert Lua lists to JS
        .replaceAll(/^\s*return.+$/gm, ""); // Remove "return" statement
    return JSON.parse(rawParams);
  }

  /**
   * Utility function that adds support to the given template that uses a single data module.
   * @param templateName {string} Template’s name without "Template:".
   * @param moduleName {string} Name of the module used by the template without "Module:".
   *                   Must have a /data subpage returning a table holding the authorized values.
   */
  function addTemplate(templateName, moduleName) {
    $.get(
        "https://fr.wiktionary.org/wiki/Module:{0}/data?action=raw".format(encodeURIComponent(moduleName)),
        data => {
          try {
            ac.addTemplateParameters(templateName, Object.keys(luaDataPageToJson(data)));
          } catch (e) {
            console.log("An error occured while parsing LUA table for [[Module:{0}/data]] ([[Template:{1}]])".format(moduleName, templateName));
          }
        }
    );
  }

  // [[Modèle:langue]], [[Module:langues/data]]
  $.get(
      "https://fr.wiktionary.org/wiki/MediaWiki:Gadget-translation editor.js/langues.json?action=raw",
      data => {
        try {
          ac.addTemplateParameters("langue", Object.keys(JSON.parse(data)));
        } catch (e) {
          console.log("An error occured while parsing JSON for [[MediaWiki:Gadget-translation editor.js/langues.json]] ([[Template:langue]]): " + e);
        }
      }
  );
  // [[Modèle:lexique]], [[Module:lexique/data]]
  addTemplate("lexique", "lexique");
  // [[Modèle:info lex]], [[Module:lexique/data]]
  addTemplate("info lex", "lexique");
  // [[Modèle:S]], [[Module:section article/data]], [[Module:types de mots/data]]
  $.get(
      "https://fr.wiktionary.org/wiki/Module:section_article/data?action=raw",
      data => {
        try {
          const sectionIds = Object.keys(luaDataPageToJson(data)["texte"]);
          $.get(
              "https://fr.wiktionary.org/wiki/Module:types_de_mots/data?action=raw",
              data => {
                try {
                  const wordTypes = Object.keys(luaDataPageToJson(data)["texte"]);
                  ac.addTemplateParameters("S", sectionIds.concat(wordTypes));
                } catch (e) {
                  console.log("An error occured while parsing LUA table for [[Module:types de mots/data]] ([[Template:S]]): " + e);
                }
              }
          );
        } catch (e) {
          console.log("An error occured while parsing LUA table for [[Module:section article/data]] ([[Template:S]]): " + e);
        }
      }
  );
});
// </nowiki>