src/web/frontend/generate.js

/**
 * @file
 * @brief Prompt-builder page: the special-token cloud, toggleable per-setting sidebar, preset / expansion save, share link, and generate.
 */

// Settings
let settings = {};
let lists = [];
let state = {};

// Save state
/**
 * Save setting.
 * @param {string} path
 * @param {*} value
 */
function saveSetting(path, value) {
  // Save into state
  if (value == "") state[path] = undefined;
  else state[path] = value;

  // Save into local storage
  localStorage.setItem("generateSettings", JSON.stringify(state));
}

// Converts to start case but fixes numbering
/**
 * Start case.
 * @param {string} text
 * @returns {*}
 */
function startCase(text) {
  let ret = _.startCase(text);

  // Replace D with Danbooru (More readable)
  ret = ret.replace(/^D /m, "Danbooru ");

  // Replace Danbooru with anime to be more readable
  ret = ret.replace("Danbooru", "Anime");

  ret = ret.replace("Dhigh", "Digipa High");
  ret = ret.replace("Dmed", "Digipa Medium");
  ret = ret.replace("Dlow", "Digipa Low");

  // Makes it too long, skip, was to add to readability
  // ret = ret.replace("Danbooru Character C", "Danbooru Character Trademark");
  // ret = ret.replace("Danbooru Character Nc", "Danbooru Character OC");

  // 3 D Print V 1 => 3D Print V1
  ret = ret.replaceAll(/(\d) (\w)/gm, "$1$2");
  ret = ret.replaceAll(/(\w) (\d)/gm, "$1$2");

  return ret;
}

/**
 * Return val to setting.
 */
function returnValToSetting() {
  // Ignore if invalid
  if ($(this).is(":invalid")) return;

  // Get cmd(s)
  const paths = $(this).data("command").split(",");

  // Get the value
  let val;

  if ($(this).is('[type="checkbox"]')) val = $(this).prop("checked").toString();
  else val = $(this).val();

  // Convert value to an array of 1 or more elements only if this is a text input or textarea
  // and it's either implicitly or explicitly requested
  if (
    (paths.length > 1 || $(this).data("join") != undefined) &&
    ($(this).is("input[type='text']") || $(this).is("textarea"))
  ) {
    if ($(this).data("join") != undefined) val = val.split($(this).data("join"));
    else val = val.split("-");
  }

  // Otherwise just make into an array
  else val = [val];

  // Type conversion from string to proper primitive values
  for (let i = 0; i < val.length; i++) {
    // Try to convert to null
    if (val[i] == "null") {
      val[i] = null;
    }

    // Try t convert to undefined
    else if (val[i] == "undefined") {
      val[i] = undefined;
    }

    // Try to convert to a boolean
    else if (val[i] == "true" || val[i] == "false") {
      val[i] = val[i] == "true";
    }

    // Try to convert to number
    else if (!isNaN(Number(val[i]))) {
      val[i] = Number(val[i]);
    }

    // Try to convert it to a float
    else if (val[i].endsWith("%") && !isNaN(Number.parseFloat(val[i]))) {
      val[i] = Number.parseFloat(val[i]);
      val[i] = +(val[i] * 0.01).toFixed(2);
    }

    // Leave as a string
  }

  // Save

  // 1 path to 1 value
  if (paths.length == 1 && val.length == 1) {
    saveSetting(paths[0], val[0]);
    return;
  }

  // 1 path to an array value
  else if (paths.length == 1 && val.length > 1) {
    saveSetting(paths[0], val);
    return;
  }

  // 1 value per path
  else if (val.length == paths.length) {
    for (let i = 0; i < paths.length; i++) {
      // Grab path
      const path = paths[i];

      // Grab value
      const value = val[i];

      // Save
      saveSetting(path, value);
    }

    return;
  }

  console.error("Can't classify how to save data back, paths", paths, "values", val);
}

async function updateInsertMenu() {
  // Gather files
  let dynPrompts = await ajaxGet("/api/files/dynamic-prompts");
  if (dynPrompts == null || dynPrompts == undefined) dynPrompts = [];

  let expansions = await ajaxGet("/api/files/expansions");
  if (expansions == null || expansions == undefined) expansions = [];

  let lists = await ajaxGet("/api/files/lists");
  if (lists == null || lists == undefined) lists = [];

  let randomKeywords = await ajaxGet("/api/images/random-keywords");
  if (randomKeywords == null || randomKeywords == undefined) randomKeywords = [];

  // ------------------------------------------------------------------------
  // Dynamic prompts full
  // ------------------------------------------------------------------------

  for (let i = 0; i < dynPrompts.fullRegular.length; i++) {
    if (i == 0)
      $("#keyword-cloud").append(
        `<span title="Full dynamically generated prompts around a theme, these stand on their own and don't usually need extra prompt keywords">Full Dynamic Prompts</span>`,
      );

    $("#keyword-cloud").append(
      `<button data-value="#${dynPrompts.fullRegular[i]}">${startCase(dynPrompts.fullRegular[i])}</button>`,
    );
  }

  // ------------------------------------------------------------------------
  // Expansions
  // ------------------------------------------------------------------------

  for (let i = 0; i < expansions.length; i++) {
    if (i == 0)
      $("#keyword-cloud").append(
        `<span title="Inserts a string of text from the expansion file (Same text everytime). Expansion files can contain dynamic prompts and lists though to offer randomiation">Expansions</span>`,
      );

    $("#keyword-cloud").append(
      `<button data-value="<${expansions[i]}>">${startCase(expansions[i])}</button>`,
    );
  }

  // ------------------------------------------------------------------------
  // Dynamic prompts partial
  // ------------------------------------------------------------------------

  for (let i = 0; i < dynPrompts.partialRegular.length; i++) {
    if (i == 0)
      $("#keyword-cloud").append(
        `<span title="Partial prompts, these are meant to compliment other parts of a prompt but don't stand well on their own">Partial Dynamic Prompts</span>`,
      );

    $("#keyword-cloud").append(
      `<button data-value="#${dynPrompts.partialRegular[i]}">${startCase(dynPrompts.partialRegular[i])}</button>`,
    );
  }

  // ------------------------------------------------------------------------
  // Lists
  // ------------------------------------------------------------------------

  for (let i = 0; i < lists.length; i++) {
    if (i == 0)
      $("#keyword-cloud").append(
        `<span title="Replaced by a single random entry from the list file">Lists</span>`,
      );

    $("#keyword-cloud").append(
      `<button data-value="{${lists[i]}}">${startCase(lists[i])}</button>`,
    );
  }

  // ------------------------------------------------------------------------
  // Dynamic prompts user-submitted
  // ------------------------------------------------------------------------

  for (let i = 0; i < dynPrompts.userFiles.length; i++) {
    if (i == 0)
      $("#keyword-cloud").append(
        `<span title="Full user-submitted dynamically generated prompts around a theme, these stand on their own and don't usually need extra prompt keywords">User Dynamic Prompts</span>`,
      );

    $("#keyword-cloud").append(
      `<button data-value="#${dynPrompts.userFiles[i]}">${startCase(dynPrompts.userFiles[i])}</button>`,
    );
  }

  // ------------------------------------------------------------------------
  // Dynamic prompts V1
  // ------------------------------------------------------------------------

  for (let i = 0; i < dynPrompts.v1Files.length; i++) {
    if (i == 0)
      $("#keyword-cloud").append(
        `<span disabled title="Full legacy dynamically generated prompts around a theme">V1 Dynamic Prompts</span>`,
      );

    $("#keyword-cloud").append(
      `<button data-value="#${dynPrompts.v1Files[i]}">${startCase(dynPrompts.v1Files[i])}</button>`,
    );
  }

  // ------------------------------------------------------------------------
  // Random Keywords
  // ------------------------------------------------------------------------

  for (let i = 0; i < randomKeywords.length; i++) {
    if (i == 0)
      $("#keyword-cloud").append(
        `<span disabled title="Random keywords from images you've already generated">Random Existing Keywords</span>`,
      );

    $("#keyword-cloud").append(
      `<button data-value="${randomKeywords[i]}">${startCase(randomKeywords[i])}</button>`,
    );
  }

  $("#keyword-cloud").append(`<span title="Special prompt features">Special Features</span>`);
  $("#keyword-cloud").append(
    `<button data-value="{salt}" title="Forces salt/frame number to be inserted at this location in the prompt">Force salt here</button>`,
  );
}

/**
 * Insert selected.
 */
function insertSelected() {
  const selectedValue = $(this).data("value");

  let curText = $("#page-search").val().trim();
  if (curText == "") curText = selectedValue;
  else curText = `${curText}, ${selectedValue}`;

  $("#page-search").val(curText);
}

/**
 * Preset selected.
 */
function presetSelected() {
  const selectedValue = $(this).val();

  let curText = $('[data-command="presets"]').val().trim();
  if (curText == "") curText = selectedValue;
  else curText = `${curText},${selectedValue}`;

  $('[data-command="presets"]').val(curText);
  $(this).prop("selectedIndex", 0);
}

async function updateSearchSuggestion() {
  const suggestion = await ajaxGet("/api/prompt-suggestion");
  $("#page-search").attr("placeholder", suggestion);
}

// Set the interval to run every 30 seconds
setInterval(updateSearchSuggestion, 15 * 1000);

/**
 * Populate settings list.
 */
function populateSettingsList() {
  // Loop through all div elements with the "option" class
  $(".option").each(function () {
    // Get the label child element
    const label = $(this).children("label");

    // Get the textarea, input, select, or checkbox child element
    let formElement = $(this).children().filter(":input").first();

    // It may be contained in .number-stepper
    if (formElement.is("button.remove"))
      formElement = $(this).children().filter(".number-stepper").children().filter("input");

    // Get the text of the label element
    let labelText = label.text();

    // Get the value of the form element
    const formElementValue = formElement.data("command");

    // Title
    const labelTitle = label.attr("title");

    // Add the label text as an option to the select menu
    $("#add-settings").append(
      `<option title="${labelTitle}" value="${formElementValue}">${labelText}</option>`,
    );
  });
}

/**
 * Fill list data.
 * @param {object} el
 */
function fillListData(el) {
  // Stop if this isn't a select box with the data-lists attribute
  if (!$(el).is("select[data-lists]")) return;

  // Empty out children
  $(el).empty();

  for (let i = 0; i < lists.length; i++) {
    let display = startCase(lists[i]);
    if (display == "False") display = "Random";

    $(el).append(`<option value="${lists[i]}">${display}</option>`);
  }
}

/**
 * Fill setting value.
 * @param {object} el
 */
function fillSettingValue(el) {
  // Stop if this isn't an element with the data-path attribute
  if (!$(el).is("[data-path]")) return;

  const path = $(el).data("path").split(",");

  let value = _.at(settings, path);

  if (value == null || value == undefined || value.length == 0) return;

  if ($(el).data("percent") != undefined) {
    for (let i = 0; i < value.length; i++) {
      value[i] = +(value[i] * 100).toFixed(2) + "%";
    }
  }

  let joinStr = "-";

  if ($(el).data("join") != undefined) {
    joinStr = $(el).data("join");

    for (let i = 0; i < value.length; i++) {
      if (Array.isArray(value[i])) value[i] = value[i].join(joinStr);
    }
  }

  // console.log(path, value);

  if ($(el).is('[type="checkbox"]')) $(el).prop("checked", value[0]);
  else $(el).val(value.join(joinStr));
}

/**
 * Reset save preset.
 */
function resetSavePreset() {
  // Enable or disable button
  $("#save-preset").hide();
  $("#preset-name").hide();

  $("#preset-name-val").val("");

  const enabledOverrides = $(".option[data-active='true'] [data-path]");
  const enabledArgs = $(".option[data-active='true'] [data-command]:not([data-path])");
  if (enabledOverrides.length > 0 && enabledArgs.length == 0) $("#save-preset").show();
}

/**
 * Save preset.
 */
function savePreset() {
  // Get all active options that are settings
  const enabledOverrides = $(".option[data-active='true'] [data-path]");

  // Save state
  // This pulls all the values
  saveState();

  // Object to save as a preset
  const presetObj = {};

  // Loop through active settings
  enabledOverrides.each(function () {
    // Ignore if invalid
    if ($(this).is(":invalid")) return;

    // Get settings path
    const paths = $(this).attr("data-path").split(".");

    // Ensure path is 2 parts
    if (paths.length != 2) {
      console.error("Path is not 2 parts, cannot save preset", paths);
      return;
    }

    // Convert paths to named parts
    const settingFile = paths[0];
    const setting = paths[1];

    // Get command to pull from saved state
    const command = $(this).attr("data-command");

    // Ensure command is present
    if (command == undefined || command == null || command == "") {
      console.error("Command is not present", command);
      return;
    }

    // Get value
    const value = state[command];

    // Save into preset object
    if (presetObj[settingFile] == undefined) presetObj[settingFile] = {};

    presetObj[settingFile][setting] = value;
  });

  const fileName = $("#preset-name-val").val().trim().replaceAll(" ", "-").toLowerCase();

  // Do nothing if no presetObj or file name
  if (_.isEmpty(presetObj) || fileName == "") {
    console.error("No preset to save");
    return;
  }

  // Post it
  $.ajax({
    type: "POST",
    url: `/api/preset/save`,
    data: JSON.stringify({ presetObj, fileName }),
    contentType: "application/json",
    success: function (data) {
      window.location.reload();
    },
    error: function (error) {
      console.log("Error:");
      console.log(error);
    },
  });
}

/**
 * Show setting on select.
 */
function showSettingOnSelect() {
  $("#add-settings").change(function () {
    // Get selected value
    const selectedValue = $(this).val();

    // Skip if invalid option
    if (selectedValue == "-1") return;

    const inputEl = $(`[data-command="${selectedValue}"]`).first();

    // Fill list data if applicable
    fillListData(inputEl);

    // Fill-in settings value if applicable
    fillSettingValue(inputEl);

    // Set active to true
    if (inputEl.parent().is(".option")) inputEl.parent().attr("data-active", true);
    else {
      inputEl.parent().parent().attr("data-active", true);
    }

    // Disable selected option
    $(this).find("option:selected").prop("disabled", true);

    // Reset selected index
    $(this).prop("selectedIndex", 0);

    // Update save preset visibility
    resetSavePreset();
  });
}

/**
 * Remove setting on click.
 */
function removeSettingOnClick() {
  $("button.remove").click(function () {
    // Get values
    let siblingInput = $(this).siblings(":input").first();

    if (siblingInput.length == 0)
      siblingInput = $(this).siblings(".number-stepper").first().children().filter("input");

    const command = siblingInput.data("command");

    // Set option to inactive
    $(this).parent().attr("data-active", "false");

    // Clear value
    siblingInput.val("");

    // Re-enable setting in select menu
    $(`#add-settings option[value="${command}"]`).prop("disabled", false);

    // Update save preset visibility
    resetSavePreset();
  });
}

async function downloadSettings() {
  const _settings = await ajaxGet("/api/settings");
  if (_settings != null && _settings != undefined) settings = _settings;

  const _lists = await ajaxGet("/api/files/lists");
  if (_lists != null && _lists != undefined) lists = _lists;
}

/**
 * Add remove button.
 */
function addRemoveButton() {
  $(".option").attr("data-active", "false");
  $(".option").append(`<button class="remove">Remove</button>`);
}

/**
 * Label settings.
 */
function labelSettings() {
  $(".option").each(function () {
    // Get the label child element
    const label = $(this).children("label");

    // Get the textarea, input, select, or checkbox child element
    let formElement = $(this).children().filter(":input").first();

    // It may be contained in .number-stepper
    if (formElement.is("button.remove"))
      formElement = $(this).children().filter(".number-stepper").children().filter("input");

    const isSetting = formElement.is("[data-path]");

    if (!isSetting) return;

    // Get the text of the label element
    let labelText = label.text();

    if (isSetting) labelText = `&#x2662 ${labelText}`;

    label.html(labelText);
  });
}

/**
 * On minus stepper click.
 * @param {object} input
 */
function onMinusStepperClick(input) {
  const currentValue = +input.val();
  const step = +input.attr("step");
  input.val(currentValue - step);

  if (input.val < step) input.val(step);

  $(input).trigger("change");
}

/**
 * On plus stepper click.
 * @param {object} input
 */
function onPlusStepperClick(input) {
  const currentValue = +input.val();
  const step = +input.attr("step");
  input.val(currentValue + step);

  $(input).trigger("change");
}

/**
 * Setup number steppers.
 */
function setupNumberSteppers() {
  $(".number-stepper").each(function () {
    const minusBtn = $(this).children("button:first-child");
    const input = $(this).children("input");
    const plusBtn = $(this).children("button:nth-child(3)");

    minusBtn.click(onMinusStepperClick.bind(undefined, input));
    plusBtn.click(onPlusStepperClick.bind(undefined, input));
  });
}

/**
 * On number change wstep.
 */
function onNumberChangeWStep() {
  // Get value
  let value = +$(this).val();
  const step = +$(this).attr("step");

  // If value is multiple of 64, then stop here
  const mult64Check = value % step;
  if (mult64Check === 0) return;

  // Convert partial multiple of 64 to percent
  const mult64Percent = mult64Check / step;

  // Round up or down to nearest multiple of 64
  if (mult64Percent >= 0.5) value += step - mult64Check;
  else value -= mult64Check;

  // Set value
  $(this).val(value);
}

async function updatePresetMenu() {
  // Gather files
  const presets = await ajaxGet("/api/files/presets");
  if (presets == null) return;

  for (let i = 0; i < presets.length; i++) {
    $("#preset-insert").append(`<option value="${presets[i]}">${startCase(presets[i])}</option>`);
  }
}

/**
 * Insert stored prompt.
 */
function insertStoredPrompt() {
  // Get stored prompt in browser
  let prompt = localStorage.getItem("prompt");

  // Check to see if it exists and isn't an empty string
  if (prompt == "" || prompt == null || prompt == undefined) return;

  // Trim it
  prompt = prompt.trim();

  // Do empty string check again
  if (prompt == "") return;

  // Set it to the textbox
  $("#page-search").val(prompt);
}

/**
 * Insert settings.
 * @param {object} obj
 * @param {boolean} useAll
 */
function insertSettings(obj, useAll) {
  // Skip if null or undefined
  if (obj == null || obj == undefined) return;

  try {
    // Convert to JSON if string
    if (typeof obj == "string") obj = JSON.parse(obj);
  } catch (err) {
    console.error(err);

    // Skip if error
    return;
  }

  // Skip if not an object
  if (!_.isPlainObject(obj)) return;

  // Apply settings
  _.forOwn(obj, function (value, key) {
    // Get setting with key
    let control = $(`[data-command="${key}"]`);

    if (key == "prompt") control = $("#page-search");

    // Make sure it's found
    if (control.length == 0) {
      console.error("data-command not found", key);
      return;
    }

    // Pick out first one
    control = $(control).first();

    // Get option group
    const optionGroup = $(control).parents(".option").first();

    // If it has the data-skip, then skip if not told to useAll
    if ($(control).is("[data-skip]") && !useAll) return;

    // Do nothing for empty values
    if (value == "" && $(control).is("[data-command]")) {
      // Fill list data if applicable
      fillListData(control);

      // Fill-in settings value if applicable
      fillSettingValue(control);
    } else if (value != "") {
      // Set value to stored value
      if ($(control).is('[type="checkbox"]')) $(control).prop("checked", value);
      else $(control).val(value);
    }

    // Enable option group
    $(optionGroup).attr("data-active", "true");

    // Disable option in menu
    $(`#add-settings option[value="${key}"]`).prop("disabled", true);
  });
}

/**
 * Save state.
 */
function saveState() {
  // Clear saved state
  state = {};
  localStorage.setItem("generateSettings", "{}");

  // Save current settings
  $(`.option[data-active="true"] [data-command]`).each(returnValToSetting);

  // Save prompt
  if ($("#page-search").val().trim() != "") {
    saveSetting("prompt", $("#page-search").val().trim());
  }

  localStorage.setItem("prompt", $("#page-search").val().trim());
}

/**
 * Generate.
 */
function generate() {
  // Save the current state
  saveState();

  // Post it
  $.ajax({
    type: "POST",
    url: `/api/generate-full`,
    data: JSON.stringify(state),
    contentType: "application/json",
    success: function (data) {},
    error: function (error) {
      console.log("Error:");
      console.log(error);
    },
  });

  displayProgress(true);
}

/**
 * Save expansion.
 */
function saveExpansion() {
  const prompt = $("#page-search").val().trim();
  const fileName = $("#expansion-name-val").val().trim().replaceAll(" ", "-").toLowerCase();

  // Do nothing if no prompt or file name
  if (prompt == "" || fileName == "") return;

  // Save settings
  saveState();

  // Post it
  $.ajax({
    type: "POST",
    url: `/api/expansion/save`,
    data: JSON.stringify({ prompt, fileName }),
    contentType: "application/json",
    success: function (data) {
      window.location.reload();
    },
    error: function (error) {
      console.log("Error:");
      console.log(error);
    },
  });
}

/**
 * Perform random generate.
 */
function performRandomGenerate() {
  // Get random placeholder text
  let text = $("#page-search").attr("placeholder");

  // Set it as search text
  $("#page-search").val(text);

  // Generate
  generate();
}

/**
 * Copy share link.
 */
function copyShareLink() {
  // First save state
  saveState();

  // Copy state
  const _state = _.cloneDeep(state);

  // Copy prompt and negative prompt
  const prompt = _state.prompt;
  const negativePrompt = _state["negative-prompt"];

  // Remove
  delete _state.prompt;
  delete _state["negative-prompt"];

  // Convert rest of state to url parameters

  // Then get the url to copy
  // Add useAll param to allow all settings to transfer over
  // Add urlOnly to exclude bringing in existing existing saved state
  let text = `http://localhost:7861/generate?useAll=true&urlOnly=true&${new URLSearchParams(_state).toString()}`;

  // Add prompt and negative prompt back urlencoded
  if (prompt) text += `&prompt=${encodeURIComponent(prompt)}`;

  if (negativePrompt) text += `&negative-prompt=${encodeURIComponent(negativePrompt)}`;

  // Then copy it to clipboard
  const tempInput = $('<input type="text"/>');
  $("body").append(tempInput);
  tempInput.val(text).select();
  document.execCommand("copy");
  tempInput.remove();
}

$(document).ready(async function () {
  // Add remove buttons to all options
  addRemoveButton();

  // Prefix all settings that correspon to a setting with a diamond
  // Only setting overrides can have a preset
  labelSettings();

  // Configure number steppers
  setupNumberSteppers();

  // Update preset menu
  await updatePresetMenu();

  // Populate insert menu with files
  await updateInsertMenu();

  // Update search suggestion
  await updateSearchSuggestion();

  // Download settings
  await downloadSettings();

  // Populate setting options into the select menu
  populateSettingsList();

  // Enable selection menu to activate settings
  showSettingOnSelect();

  // Remove settings on click
  removeSettingOnClick();

  // Listen for insert menu change
  $("#keyword-cloud button").click(insertSelected);

  // Listen for preset menu change
  $("#preset-insert").change(presetSelected);

  // Listen for number stepper
  $("input[step]").change(onNumberChangeWStep);

  // Insert stored data

  // Then URL parameters to override
  const params = getUrlParameters();

  // Decode prompt in case it has special characters in it
  if (params.prompt) params.prompt = decodeURIComponent(params.prompt);

  if (params["negative-prompt"])
    params["negative-prompt"] = decodeURIComponent(params["negative-prompt"]);

  // Retrieve useAll and urlOnly from url parameters and ensure removed
  let useAll = params.useAll == "true";
  let urlOnly = params.urlOnly == "true";
  delete params.useAll;
  delete params.urlOnly;

  // Settings first

  // Saved state first if allowed
  if (!urlOnly) insertSettings(localStorage.getItem("generateSettings"), useAll);

  // Then URL
  insertSettings(params, useAll);

  // Generate
  $("#generate").click(generate);
  $("#random").click(performRandomGenerate);
  $("#share").click(copyShareLink);

  $("input,select,textarea").change(saveState);
  $("button").click(saveState);

  $("#expansion").click(() => {
    $("#prompt-buttons").hide();
    $("#expansion-name").show();
  });

  $("#expansion-cancel").click(() => {
    $("#prompt-buttons").show();
    $("#expansion-name").hide();
  });

  $("#expansion-save").click(saveExpansion);

  $("#save-preset").click(() => {
    $("#save-preset").hide();
    $("#preset-name").show();
  });

  $("#preset-cancel").click(() => {
    $("#save-preset").show();
    $("#preset-name").hide();
  });

  $("#preset-save").click(savePreset);

  // Enable or disable the save preset button
  resetSavePreset();
});