diff --git a/ui/index.html b/ui/index.html
index 72a3dde2..77337505 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -16,6 +16,7 @@
+
@@ -590,13 +591,13 @@
-
+
@@ -609,6 +610,7 @@ async function init() {
await loadUIPlugins()
await loadModifiers()
await getSystemInfo()
+ await initPlugins()
SD.init({
events: {
diff --git a/ui/media/css/plugins.css b/ui/media/css/plugins.css
new file mode 100644
index 00000000..2b8bf370
--- /dev/null
+++ b/ui/media/css/plugins.css
@@ -0,0 +1,288 @@
+.plugins-table {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.plugins-table > div {
+ background: var(--background-color2);
+ display: flex;
+ padding: 0px 4px;
+}
+
+.plugins-table > div > div {
+ padding: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.plugins-table small {
+ color: rgb(153, 153, 153);
+}
+
+.plugins-table > div > div:nth-child(1) {
+ font-size: 20px;
+ width: 45px;
+}
+
+.plugins-table > div > div:nth-child(2) {
+ flex: 1;
+ flex-direction: column;
+ text-align: left;
+ justify-content: center;
+ align-items: start;
+ gap: 4px;
+}
+
+.plugins-table > div > div:nth-child(3) {
+ text-align: right;
+}
+
+.plugins-table > div:first-child {
+ border-radius: 12px 12px 0px 0px;
+}
+
+.plugins-table > div:last-child {
+ border-radius: 0px 0px 12px 12px;
+}
+
+.notifications-table {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.notifications-table > div {
+ background: var(--background-color2);
+ display: flex;
+ padding: 0px 4px;
+}
+
+.notifications-table > div > div {
+ padding: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.notifications-table small {
+ color: rgb(153, 153, 153);
+}
+
+.notifications-table > div > div:nth-child(1) {
+ flex: 1;
+ flex-direction: column;
+ text-align: left;
+ justify-content: center;
+ align-items: start;
+ gap: 4px;
+}
+
+.notifications-table > div > div:nth-child(2) {
+ width: auto;
+}
+
+.notifications-table > div:first-child {
+ border-radius: 12px 12px 0px 0px;
+}
+
+.notifications-table > div:last-child {
+ border-radius: 0px 0px 12px 12px;
+}
+
+.notification-error {
+ color: red;
+}
+
+DIV.no-notification {
+ padding-top: 16px;
+ font-style: italic;
+}
+
+.plugin-manager-intro {
+ margin: 0 0 16px 0;
+}
+
+#plugin-filter {
+ box-sizing: border-box;
+ width: 100%;
+ margin: 4px 0 6px 0;
+ padding: 10px;
+}
+
+#refresh-plugins {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 0px;
+}
+
+#refresh-plugins a {
+ cursor: pointer;
+}
+
+#refresh-plugins a:active {
+ transition-duration: 0.1s;
+ position: relative;
+ top: 1px;
+ left: 1px;
+}
+
+.plugin-installed-locally {
+ font-style: italic;
+ font-size: small;
+}
+
+.plugin-source {
+ font-size: x-small;
+}
+
+.plugin-warning {
+ color: orange;
+ font-size: smaller;
+}
+
+.plugin-warning.hide {
+ display: none;
+}
+
+.plugin-warning ul {
+ list-style: square;
+ margin: 0 0 8px 16px;
+ padding: 0;
+}
+
+.plugin-warning li {
+ margin-left: 8px;
+ padding: 0;
+}
+
+/* MODAL DIALOG */
+#pluginDialog-input-dialog {
+ position: fixed;
+ z-index: 1000;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: none;
+}
+
+.pluginDialog-dialog-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(32, 33, 36, 50%);
+}
+
+.pluginDialog-dialog-box {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 80%;
+ max-width: 600px;
+ background: var(--background-color2);
+ border: solid 1px var(--background-color3);
+ border-radius: 6px;
+ box-shadow: 0px 0px 30px black;
+}
+
+.pluginDialog-dialog-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px;
+}
+
+.pluginDialog-dialog-header h2 {
+ margin: 0;
+}
+
+.pluginDialog-dialog-close-button {
+ font-size: 24px;
+ font-weight: bold;
+ line-height: 1;
+ border: none;
+ background-color: transparent;
+ cursor: pointer;
+}
+
+.pluginDialog-dialog-close-button:hover {
+ color: #555;
+}
+
+.pluginDialog-dialog-content {
+ padding: 0 16px 0 16px;
+}
+
+.pluginDialog-dialog-content textarea {
+ width: 100%;
+ height: 300px;
+ border-radius: var(--input-border-radius);
+ padding: 4px;
+ accent-color: var(--accent-color);
+ background: var(--input-background-color);
+ border: var(--input-border-size) solid var(--input-border-color);
+ color: var(--input-text-color);
+ font-size: 9pt;
+ resize: none;
+}
+
+.pluginDialog-dialog-buttons {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px;
+}
+
+.pluginDialog-dialog-buttons button {
+ margin-left: 8px;
+ padding: 8px 16px;
+ font-size: 16px;
+ border-radius: 4px;
+ /*background: var(--accent-color);*/
+ /*border: var(--primary-button-border);*/
+ /*color: rgb(255, 221, 255);*/
+ background-color: #3071a9;
+ border: none;
+ cursor: pointer;
+}
+
+.pluginDialog-dialog-buttons button:hover {
+ /*background: hsl(var(--accent-hue), 100%, 50%);*/
+ background-color: #428bca;
+}
+
+/* NOTIFICATION CENTER */
+#plugin-notification-button {
+ float: right;
+ margin-top: 30px;
+}
+
+#plugin-notification-button:hover {
+ background: unset;
+}
+
+#plugin-notification-button:active {
+ transition-duration: 0.1s;
+ position: relative;
+ top: 1px;
+ left: 1px;
+}
+
+.plugin-notification-pill {
+ background-color: red;
+ border-radius: 50%;
+ color: white;
+ font-size: 10px;
+ font-weight: bold;
+ height: 12px;
+ line-height: 12px;
+ position: relative;
+ right: -8px;
+ text-align: center;
+ top: -20px;
+ width: 12px;
+}
diff --git a/ui/media/js/plugins.js b/ui/media/js/plugins.js
index 85cc48d4..fb3e8b07 100644
--- a/ui/media/js/plugins.js
+++ b/ui/media/js/plugins.js
@@ -1,5 +1,8 @@
const PLUGIN_API_VERSION = "1.0"
+const PLUGIN_CATALOG = 'https://raw.githubusercontent.com/easydiffusion/easydiffusion-plugins/main/plugins.json'
+const PLUGIN_CATALOG_GITHUB = 'https://github.com/easydiffusion/easydiffusion-plugins/blob/main/plugins.json'
+
const PLUGINS = {
/**
* Register new buttons to show on each output image.
@@ -78,3 +81,950 @@ async function loadUIPlugins() {
console.log("error fetching plugin paths", e)
}
}
+
+
+/* PLUGIN MANAGER */
+/* plugin tab */
+document.querySelector('.tab-container')?.insertAdjacentHTML('beforeend', `
+
+ Plugins
+
+`)
+
+document.querySelector('#tab-content-wrapper')?.insertAdjacentHTML('beforeend', `
+
+`)
+
+const tabPlugin = document.querySelector('#tab-plugin')
+if (tabPlugin) {
+ linkTabContents(tabPlugin)
+}
+
+const plugin = document.querySelector('#plugin')
+plugin.innerHTML = `
+
+
+
+
+
+
Notifications
+
The latest plugin updates are listed below
+
+
No new notifications
+
+
+
Plugin Manager
+
Changes take effect after reloading the page
+
+
+
`
+const pluginsTable = document.querySelector("#plugin-manager-section .plugins-table")
+const pluginNotificationTable = document.querySelector("#plugin-notification-list .notifications-table")
+const pluginNoNotification = document.querySelector("#plugin-notification-list .no-notification")
+
+/* notification center */
+const pluginNotificationButton = document.getElementById("plugin-notification-button");
+const pluginNotificationList = document.getElementById("plugin-notification-list");
+const notificationPill = document.getElementById("notification-pill");
+const pluginManagerSection = document.getElementById("plugin-manager-section");
+let pluginNotifications;
+
+// Add event listener to show/hide the action center
+pluginNotificationButton.addEventListener("click", function () {
+ // Hide the notification pill when the action center is opened
+ notificationPill.style.display = "none"
+ pluginNotifications.lastUpdated = Date.now()
+
+ // save the notifications
+ setStorageData('notifications', JSON.stringify(pluginNotifications))
+
+ renderPluginNotifications()
+
+ if (pluginNotificationList.style.display === "none") {
+ pluginNotificationList.style.display = "block"
+ pluginManagerSection.style.display = "none"
+ } else {
+ pluginNotificationList.style.display = "none"
+ pluginManagerSection.style.display = "block"
+ }
+})
+
+document.addEventListener("tabClick", (e) => {
+ if (e.detail.name == 'plugin') {
+ pluginNotificationList.style.display = "none"
+ pluginManagerSection.style.display = "block"
+ }
+})
+
+async function addPluginNotification(pluginNotifications, messageText, error) {
+ const now = Date.now()
+ pluginNotifications.entries.unshift({ date: now, text: messageText, error: error }); // add new entry to the beginning of the array
+ if (pluginNotifications.entries.length > 50) {
+ pluginNotifications.entries.length = 50 // limit array length to 50 entries
+ }
+ pluginNotifications.lastUpdated = now
+ notificationPill.style.display = "block"
+ // save the notifications
+ await setStorageData('notifications', JSON.stringify(pluginNotifications))
+}
+
+function timeAgo(inputDate) {
+ const now = new Date();
+ const date = new Date(inputDate);
+ const diffInSeconds = Math.floor((now - date) / 1000);
+ const units = [
+ { name: 'year', seconds: 31536000 },
+ { name: 'month', seconds: 2592000 },
+ { name: 'week', seconds: 604800 },
+ { name: 'day', seconds: 86400 },
+ { name: 'hour', seconds: 3600 },
+ { name: 'minute', seconds: 60 },
+ { name: 'second', seconds: 1 }
+ ];
+
+ for (const unit of units) {
+ const unitValue = Math.floor(diffInSeconds / unit.seconds);
+ if (unitValue > 0) {
+ return `${unitValue} ${unit.name}${unitValue > 1 ? 's' : ''} ago`;
+ }
+ }
+
+ return 'just now';
+}
+
+function convertSeconds(seconds) {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const remainingSeconds = seconds % 60;
+
+ let timeParts = [];
+ if (hours === 1) {
+ timeParts.push(`${hours} hour`);
+ } else if (hours > 1) {
+ timeParts.push(`${hours} hours`);
+ }
+ if (minutes === 1) {
+ timeParts.push(`${minutes} minute`);
+ } else if (minutes > 1) {
+ timeParts.push(`${minutes} minutes`);
+ }
+ if (remainingSeconds === 1) {
+ timeParts.push(`${remainingSeconds} second`);
+ } else if (remainingSeconds > 1) {
+ timeParts.push(`${remainingSeconds} seconds`);
+ }
+
+ return timeParts.join(', and ');
+}
+
+function renderPluginNotifications() {
+ pluginNotificationTable.innerHTML = ''
+
+ if (pluginNotifications.entries?.length > 0) {
+ pluginNoNotification.style.display = "none"
+ pluginNotificationTable.style.display = "block"
+ }
+ else {
+ pluginNoNotification.style.display = "block"
+ pluginNotificationTable.style.display = "none"
+ }
+ for (let i = 0; i < pluginNotifications.entries?.length; i++) {
+ const date = pluginNotifications.entries[i].date
+ const text = pluginNotifications.entries[i].text
+ const error = pluginNotifications.entries[i].error
+ const newRow = document.createElement('div')
+
+ newRow.innerHTML = `
+ ${text}
+ ${timeAgo(date)}
+ `;
+ pluginNotificationTable.appendChild(newRow)
+ }
+}
+
+/* search box */
+function filterPlugins() {
+ let search = pluginFilter.value.toLowerCase();
+ let searchTerms = search.split(' ');
+ let labels = pluginsTable.querySelectorAll("label.plugin-name");
+
+ for (let i = 0; i < labels.length; i++) {
+ let label = labels[i].innerText.toLowerCase();
+ let match = true;
+
+ for (let j = 0; j < searchTerms.length; j++) {
+ let term = searchTerms[j].trim();
+ if (term && label.indexOf(term) === -1) {
+ match = false;
+ break;
+ }
+ }
+
+ if (match) {
+ labels[i].closest('.plugin-container').style.display = "flex";
+ } else {
+ labels[i].closest('.plugin-container').style.display = "none";
+ }
+ }
+}
+
+// Call debounce function on filterImageModifierList function with 200ms wait time. Thanks JeLuf!
+const debouncedFilterPlugins = debounce(filterPlugins, 200);
+
+// add the searchbox
+pluginsTable.insertAdjacentHTML('beforebegin', ``)
+const pluginFilter = document.getElementById("plugin-filter") // search box
+
+// Add the debounced function to the keyup event listener
+pluginFilter.addEventListener('keyup', debouncedFilterPlugins);
+
+// select the text on focus
+pluginFilter.addEventListener('focus', function (event) {
+ pluginFilter.select()
+});
+
+// empty the searchbox on escape
+pluginFilter.addEventListener('keydown', function (event) {
+ if (event.key === 'Escape') {
+ pluginFilter.value = '';
+ filterPlugins();
+ }
+});
+
+// focus on the search box upon tab selection
+document.addEventListener("tabClick", (e) => {
+ if (e.detail.name == 'plugin') {
+ pluginFilter.focus()
+ }
+})
+
+// refresh link
+pluginsTable.insertAdjacentHTML('afterend', `Refresh plugins
+ (Plugin developers, add your plugins to plugins.json)
`)
+const refreshPlugins = document.getElementById("refresh-plugins")
+refreshPlugins.addEventListener("click", async function (event) {
+ event.preventDefault()
+ await initPlugins(true)
+})
+
+function showPluginToast(message, duration = 5000, error = false, addNotification = true) {
+ if (addNotification === true) {
+ addPluginNotification(pluginNotifications, message, error)
+ }
+ try {
+ showToast(message, duration, error)
+ } catch (error) {
+ console.error('Error while trying to show toast:', error);
+ }
+}
+
+function matchPluginFileNames(fileName1, fileName2) {
+ const regex = /^(.+?)(?:-\d+(\.\d+)*)?\.plugin\.js$/;
+ const match1 = fileName1.match(regex);
+ const match2 = fileName2.match(regex);
+
+ if (match1 && match2 && match1[1] === match2[1]) {
+ return true; // the two file names match
+ } else {
+ return false; // the two file names do not match
+ }
+}
+
+function extractFilename(filepath) {
+ // Normalize the path separators to forward slashes and make the file names lowercase
+ const normalizedFilePath = filepath.replace(/\\/g, "/").toLowerCase();
+
+ // Strip off the path from the file name
+ const fileName = normalizedFilePath.substring(normalizedFilePath.lastIndexOf("/") + 1);
+
+ return fileName
+}
+
+function checkFileNameInArray(paths, filePath) {
+ // Strip off the path from the file name
+ const fileName = extractFilename(filePath);
+
+ // Check if the file name exists in the array of paths
+ return paths.some(path => {
+ // Strip off the path from the file name
+ const baseName = extractFilename(path);
+
+ // Check if the file names match and return the result as a boolean
+ return matchPluginFileNames(fileName, baseName);
+ });
+}
+
+function isGitHub(url) {
+ return url.startsWith("https://raw.githubusercontent.com/") === true
+}
+
+/* fill in the plugins table */
+function getIncompatiblePlugins(pluginId) {
+ const enabledPlugins = plugins.filter(plugin => plugin.enabled && plugin.id !== pluginId);
+ const incompatiblePlugins = enabledPlugins.filter(plugin => plugin.compatIssueIds?.includes(pluginId));
+ const pluginNames = incompatiblePlugins.map(plugin => plugin.name);
+ if (pluginNames.length === 0) {
+ return null;
+ }
+ const pluginNamesList = pluginNames.map(name => `${name}`).join('');
+ return ``;
+}
+
+async function initPluginTable(plugins) {
+ pluginsTable.innerHTML = ''
+ plugins.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
+ plugins.forEach(plugin => {
+ const name = plugin.name
+ const author = plugin.author ? ', by ' + plugin.author : ''
+ const version = plugin.version ? ' (version: ' + plugin.version + ')' : ''
+ const warning = getIncompatiblePlugins(plugin.id) ? `This plugin might conflict with:${getIncompatiblePlugins(plugin.id)}` : ''
+ const note = plugin.description ? `${plugin.description.replaceAll('\n', '
')}` : `No description`;
+ const icon = plugin.icon ? `` : '';
+ const newRow = document.createElement('div')
+ const localPluginFound = checkFileNameInArray(localPlugins, plugin.url)
+
+ newRow.innerHTML = `
+ ${icon}
+
+
+ ${localPluginFound ? "Installed locally" :
+ (plugin.localInstallOnly ? 'Download and
install manually' :
+ (isGitHub(plugin.url) ?
+ '' :
+ ''
+ )
+ )
+ }
+
`;
+ newRow.classList.add('plugin-container')
+ //console.log(plugin.id, plugin.localInstallOnly)
+ pluginsTable.appendChild(newRow)
+ const pluginManualInstall = pluginsTable.querySelector('#plugin-' + plugin.id + '-install')
+ updateManualInstallButtonCaption()
+
+ // checkbox event handler
+ const pluginToggle = pluginsTable.querySelector('#plugin-' + plugin.id)
+ if (pluginToggle !== null) {
+ pluginToggle.checked = plugin.enabled // set initial state of checkbox
+ pluginToggle.addEventListener('change', async () => {
+ const container = pluginToggle.closest(".plugin-container");
+ const warningElement = container.querySelector(".plugin-warning");
+
+ // if the plugin got enabled, download the plugin's code
+ plugin.enabled = pluginToggle.checked
+ if (plugin.enabled) {
+ const pluginSource = await getDocument(plugin.url);
+ if (pluginSource !== null) {
+ // Store the current scroll position before navigating away
+ const currentPosition = window.pageYOffset;
+ initPluginTable(plugins)
+ // When returning to the page, set the scroll position to the stored value
+ window.scrollTo(0, currentPosition);
+ warningElement?.classList.remove("hide");
+ plugin.code = pluginSource
+ loadPlugins([plugin])
+ console.log(`Plugin ${plugin.name} installed`);
+ showPluginToast("Plugin " + plugin.name + " installed");
+ }
+ else {
+ plugin.enabled = false
+ pluginToggle.checked = false
+ console.error(`Couldn't download plugin ${plugin.name}`);
+ showPluginToast("Failed to install " + plugin.name + " (Couldn't fetch " + extractFilename(plugin.url) + ")", 5000, true);
+ }
+ } else {
+ warningElement?.classList.add("hide");
+ // Store the current scroll position before navigating away
+ const currentPosition = window.pageYOffset;
+ initPluginTable(plugins)
+ // When returning to the page, set the scroll position to the stored value
+ window.scrollTo(0, currentPosition);
+ console.log(`Plugin ${plugin.name} uninstalled`);
+ showPluginToast("Plugin " + plugin.name + " uninstalled");
+ }
+ await setStorageData('plugins', JSON.stringify(plugins))
+ })
+ }
+
+ // manual install event handler
+ if (pluginManualInstall !== null) {
+ pluginManualInstall.addEventListener('click', async () => {
+ pluginDialogOpenDialog(inputOK, inputCancel)
+ pluginDialogTextarea.value = plugin.code ? plugin.code : ''
+ pluginDialogTextarea.select()
+ pluginDialogTextarea.focus()
+ })
+ }
+ // Dialog OK
+ async function inputOK() {
+ let pluginSource = pluginDialogTextarea.value
+ // remove empty lines and trim existing lines
+ plugin.code = pluginSource
+ if (pluginSource.trim() !== '') {
+ plugin.enabled = true
+ console.log(`Plugin ${plugin.name} installed`);
+ showPluginToast("Plugin " + plugin.name + " installed");
+ }
+ else {
+ plugin.enabled = false
+ console.log(`No code provided for plugin ${plugin.name}, disabling the plugin`);
+ showPluginToast("No code provided for plugin " + plugin.name + ", disabling the plugin");
+ }
+ updateManualInstallButtonCaption()
+ await setStorageData('plugins', JSON.stringify(plugins))
+ }
+ // Dialog Cancel
+ async function inputCancel() {
+ plugin.enabled = false
+ console.log(`Installation of plugin ${plugin.name} cancelled`);
+ showPluginToast("Cancelled installation of " + plugin.name);
+ }
+ // update button caption
+ function updateManualInstallButtonCaption() {
+ if (pluginManualInstall !== null) {
+ pluginManualInstall.innerHTML = plugin.code === undefined || plugin.code.trim() === '' ? 'Install' : 'Edit'
+ }
+ }
+ })
+ prettifyInputs(pluginsTable)
+ filterPlugins()
+}
+
+/* version management. Thanks Madrang! */
+const parseVersion = function (versionString, options = {}) {
+ if (typeof versionString === "undefined") {
+ throw new Error("versionString is undefined.");
+ }
+ if (typeof versionString !== "string") {
+ throw new Error("versionString is not a string.");
+ }
+ const lexicographical = options && options.lexicographical;
+ const zeroExtend = options && options.zeroExtend;
+ let versionParts = versionString.split('.');
+ function isValidPart(x) {
+ const re = (lexicographical ? /^\d+[A-Za-z]*$/ : /^\d+$/);
+ return re.test(x);
+ }
+
+ if (!versionParts.every(isValidPart)) {
+ throw new Error("Version string is invalid.");
+ }
+
+ if (zeroExtend) {
+ while (versionParts.length < 4) {
+ versionParts.push("0");
+ }
+ }
+ if (!lexicographical) {
+ versionParts = versionParts.map(Number);
+ }
+ return versionParts;
+};
+
+const versionCompare = function (v1, v2, options = {}) {
+ if (typeof v1 == "undefined") {
+ throw new Error("vi is undefined.");
+ }
+ if (typeof v2 === "undefined") {
+ throw new Error("v2 is undefined.");
+ }
+
+ let v1parts;
+ if (typeof v1 === "string") {
+ v1parts = parseVersion(v1, options);
+ } else if (Array.isArray(v1)) {
+ v1parts = [...v1];
+ if (!v1parts.every(p => typeof p === "number" && p !== NaN)) {
+ throw new Error("v1 part array does not only contains numbers.");
+ }
+ } else {
+ throw new Error("v1 is of an unexpected type: " + typeof v1);
+ }
+
+ let v2parts;
+ if (typeof v2 === "string") {
+ v2parts = parseVersion(v2, options);
+ } else if (Array.isArray(v2)) {
+ v2parts = [...v2];
+ if (!v2parts.every(p => typeof p === "number" && p !== NaN)) {
+ throw new Error("v2 part array does not only contains numbers.");
+ }
+ } else {
+ throw new Error("v2 is of an unexpected type: " + typeof v2);
+ }
+
+ while (v1parts.length < v2parts.length) {
+ v1parts.push("0");
+ }
+ while (v2parts.length < v1parts.length) {
+ v2parts.push("0");
+ }
+
+ for (let i = 0; i < v1parts.length; ++i) {
+ if (v2parts.length == i) {
+ return 1;
+ }
+ if (v1parts[i] == v2parts[i]) {
+ continue;
+ } else if (v1parts[i] > v2parts[i]) {
+ return 1;
+ } else {
+ return -1;
+ }
+ }
+ return 0;
+};
+
+function filterPluginsByMinEDVersion(plugins, EDVersion) {
+ const filteredPlugins = plugins.filter(plugin => {
+ if (plugin.minEDVersion) {
+ return versionCompare(plugin.minEDVersion, EDVersion) <= 0;
+ }
+ return true;
+ });
+
+ return filteredPlugins;
+}
+
+function extractVersionNumber(elem) {
+ const versionStr = elem.innerHTML;
+ const regex = /v(\d+\.\d+\.\d+)/;
+ const matches = regex.exec(versionStr);
+ if (matches && matches.length > 1) {
+ return matches[1];
+ } else {
+ return null;
+ }
+}
+const EasyDiffusionVersion = extractVersionNumber(document.querySelector('#top-nav > #logo'))
+
+/* PLUGIN MANAGEMENT */
+let plugins
+let localPlugins
+let initPluginsInProgress = false
+
+async function initPlugins(refreshPlugins = false) {
+ let pluginsLoaded
+ if (initPluginsInProgress === true) {
+ return
+ }
+ initPluginsInProgress = true
+
+ const res = await fetch('/get/ui_plugins')
+ if (!res.ok) {
+ console.error(`Error HTTP${res.status} while loading plugins list. - ${res.statusText}`)
+ }
+ else {
+ localPlugins = await res.json()
+ }
+
+ if (refreshPlugins === false) {
+ // load the notifications
+ pluginNotifications = await getStorageData('notifications')
+ if (typeof pluginNotifications === "string") {
+ try {
+ pluginNotifications = JSON.parse(pluginNotifications)
+ } catch (e) {
+ console.error("Failed to parse pluginNotifications", e);
+ pluginNotifications = {};
+ pluginNotifications.entries = [];
+ }
+ }
+ if (pluginNotifications !== undefined) {
+ if (pluginNotifications.entries && pluginNotifications.entries.length > 0 && pluginNotifications.entries[0].date && pluginNotifications.lastUpdated <= pluginNotifications.entries[0].date) {
+ notificationPill.style.display = "block";
+ }
+ } else {
+ pluginNotifications = {};
+ pluginNotifications.entries = [];
+ }
+
+ // try and load plugins from local cache
+ plugins = await getStorageData('plugins')
+ if (plugins !== undefined) {
+ plugins = JSON.parse(plugins)
+
+ // remove duplicate entries if any (should not happen)
+ plugins = deduplicatePluginsById(plugins)
+
+ // remove plugins that don't meet the min ED version requirement
+ plugins = filterPluginsByMinEDVersion(plugins, EasyDiffusionVersion)
+
+ // remove from plugins the entries that don't have mandatory fields (id, name, url)
+ plugins = plugins.filter((plugin) => { return plugin.id !== '' && plugin.name !== '' && plugin.url !== ''; });
+
+ // populate the table
+ initPluginTable(plugins)
+ await loadPlugins(plugins)
+ pluginsLoaded = true
+ }
+ else {
+ plugins = []
+ pluginsLoaded = false
+ }
+ }
+
+ // update plugins asynchronously (updated versions will be available next time the UI is loaded)
+ if (refreshAllowed()) {
+ let pluginCatalog = await getDocument(PLUGIN_CATALOG)
+ if (pluginCatalog !== null) {
+ let parseError = false;
+ try {
+ pluginCatalog = JSON.parse(pluginCatalog);
+ console.log('Plugin catalog successfully downloaded');
+ } catch (error) {
+ console.error('Error parsing plugin catalog:', error);
+ parseError = true;
+ }
+
+ if (!parseError) {
+ await downloadPlugins(pluginCatalog, plugins, refreshPlugins)
+
+ // update compatIssueIds
+ updateCompatIssueIds()
+
+ // remove plugins that don't meet the min ED version requirement
+ plugins = filterPluginsByMinEDVersion(plugins, EasyDiffusionVersion)
+
+ // remove from plugins the entries that don't have mandatory fields (id, name, url)
+ plugins = plugins.filter((plugin) => { return plugin.id !== '' && plugin.name !== '' && plugin.url !== ''; });
+
+ // remove from plugins the entries that no longer exist in the catalog
+ plugins = plugins.filter((plugin) => { return pluginCatalog.some((p) => p.id === plugin.id) });
+
+ if (pluginCatalog.length > plugins.length) {
+ const newPlugins = pluginCatalog.filter((plugin) => {
+ return !plugins.some((p) => p.id === plugin.id);
+ });
+
+ newPlugins.forEach((plugin, index) => {
+ setTimeout(() => {
+ showPluginToast(`New plugin "${plugin.name}" is available.`);
+ }, (index + 1) * 1000);
+ });
+ }
+
+ let pluginsJson;
+ try {
+ pluginsJson = JSON.stringify(plugins); // attempt to parse plugins to JSON
+ } catch (error) {
+ console.error('Error converting plugins to JSON:', error);
+ }
+
+ if (pluginsJson) { // only store the data if pluginsJson is not null or undefined
+ await setStorageData('plugins', pluginsJson)
+ }
+
+ // refresh the display of the plugins table
+ initPluginTable(plugins)
+ if (pluginsLoaded && pluginsLoaded === false) {
+ loadPlugins(plugins)
+ }
+ }
+ }
+ }
+ else {
+ if (refreshPlugins) {
+ showPluginToast('Plugins have been refreshed recently, refresh will be available in ' + convertSeconds(getTimeUntilNextRefresh()), 5000, true, false)
+ }
+ }
+ initPluginsInProgress = false
+}
+
+function updateMetaTagPlugins(plugin) {
+ // Update the meta tag with the list of loaded plugins
+ let metaTag = document.querySelector('meta[name="plugins"]');
+ if (metaTag === null) {
+ metaTag = document.createElement('meta');
+ metaTag.name = 'plugins';
+ document.head.appendChild(metaTag);
+ }
+ const pluginArray = [...(metaTag.content ? metaTag.content.split(',') : []), plugin.id];
+ metaTag.content = pluginArray.join(',');
+}
+
+function updateCompatIssueIds() {
+ // Loop through each plugin
+ plugins.forEach(plugin => {
+ // Check if the plugin has `compatIssueIds` property
+ if (plugin.compatIssueIds !== undefined) {
+ // Loop through each of the `compatIssueIds`
+ plugin.compatIssueIds.forEach(issueId => {
+ // Find the plugin with the corresponding `issueId`
+ const issuePlugin = plugins.find(p => p.id === issueId);
+ // If the corresponding plugin is found, initialize its `compatIssueIds` property with an empty array if it's undefined
+ if (issuePlugin) {
+ if (issuePlugin.compatIssueIds === undefined) {
+ issuePlugin.compatIssueIds = [];
+ }
+ // If the current plugin's ID is not already in the `compatIssueIds` array, add it
+ if (!issuePlugin.compatIssueIds.includes(plugin.id)) {
+ issuePlugin.compatIssueIds.push(plugin.id);
+ }
+ }
+ });
+ } else {
+ // If the plugin doesn't have `compatIssueIds` property, initialize it with an empty array
+ plugin.compatIssueIds = [];
+ }
+ });
+}
+
+function deduplicatePluginsById(plugins) {
+ const seenIds = new Set();
+ const deduplicatedPlugins = [];
+
+ for (const plugin of plugins) {
+ if (!seenIds.has(plugin.id)) {
+ seenIds.add(plugin.id);
+ deduplicatedPlugins.push(plugin);
+ } else {
+ // favor dupes that have enabled == true
+ const index = deduplicatedPlugins.findIndex(p => p.id === plugin.id);
+ if (index >= 0) {
+ if (plugin.enabled) {
+ deduplicatedPlugins[index] = plugin;
+ }
+ }
+ }
+ }
+
+ return deduplicatedPlugins;
+}
+
+async function loadPlugins(plugins) {
+ for (let i = 0; i < plugins.length; i++) {
+ const plugin = plugins[i];
+ if (plugin.enabled === true && plugin.localInstallOnly !== true) {
+ const localPluginFound = checkFileNameInArray(localPlugins, plugin.url);
+ if (!localPluginFound) {
+ try {
+ // Indirect eval to work around sloppy plugin implementations
+ const indirectEval = { eval };
+ console.log("Loading plugin " + plugin.name);
+ indirectEval.eval(plugin.code);
+ console.log("Plugin " + plugin.name + " loaded");
+ await updateMetaTagPlugins(plugin); // add plugin to the meta tag
+ } catch (err) {
+ showPluginToast("Error loading plugin " + plugin.name + " (" + err.message + ")", null, true);
+ console.error("Error loading plugin " + plugin.name + ": " + err.message);
+ }
+ } else {
+ console.log("Skipping plugin " + plugin.name + " (installed locally)");
+ }
+ }
+ }
+}
+
+async function getFileHash(url) {
+ const regex = /^https:\/\/raw\.githubusercontent\.com\/(?[^/]+)\/(?[^/]+)\/(?[^/]+)\/(?.+)$/;
+ const match = url.match(regex);
+ if (!match) {
+ console.error('Invalid GitHub repository URL.');
+ return Promise.resolve(null);
+ }
+ const owner = match.groups.owner;
+ const repo = match.groups.repo;
+ const branch = match.groups.branch;
+ const filePath = match.groups.filePath;
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`;
+
+ try {
+ const response = await fetch(apiUrl);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}, url: ${apiUrl}`);
+ }
+ const data = await response.json();
+ return data.sha;
+ } catch (error) {
+ console.error('Error fetching data from url:', apiUrl, 'Error:', error);
+ return null;
+ }
+}
+
+// only allow two refresh per hour
+function getTimeUntilNextRefresh() {
+ const lastRuns = JSON.parse(localStorage.getItem('lastRuns') || '[]');
+ const currentTime = new Date().getTime();
+ const numRunsLast60Min = lastRuns.filter(run => currentTime - run <= 60 * 60 * 1000).length;
+
+ if (numRunsLast60Min >= 2) {
+ return 3600 - Math.round((currentTime - lastRuns[lastRuns.length - 1]) / 1000);
+ }
+
+ return 0;
+}
+
+function refreshAllowed() {
+ const timeUntilNextRefresh = getTimeUntilNextRefresh();
+
+ if (timeUntilNextRefresh > 0) {
+ console.log(`Next refresh available in ${timeUntilNextRefresh} seconds`);
+ return false;
+ }
+
+ const lastRuns = JSON.parse(localStorage.getItem('lastRuns') || '[]');
+ const currentTime = new Date().getTime();
+ lastRuns.push(currentTime);
+ localStorage.setItem('lastRuns', JSON.stringify(lastRuns));
+ return true;
+}
+
+async function downloadPlugins(pluginCatalog, plugins, refreshPlugins) {
+ // download the plugins as needed
+ for (const plugin of pluginCatalog) {
+ //console.log(plugin.id, plugin.url)
+ const existingPlugin = plugins.find(p => p.id === plugin.id);
+ // get the file hash in the GitHub repo
+ let sha
+ if (isGitHub(plugin.url) && existingPlugin?.enabled === true) {
+ sha = await getFileHash(plugin.url)
+ }
+ if (plugin.localInstallOnly !== true && isGitHub(plugin.url) && existingPlugin?.enabled === true && (refreshPlugins || (existingPlugin.sha !== undefined && existingPlugin.sha !== null && existingPlugin.sha !== sha) || existingPlugin?.code === undefined)) {
+ const pluginSource = await getDocument(plugin.url);
+ if (pluginSource !== null && pluginSource !== existingPlugin.code) {
+ console.log(`Plugin ${plugin.name} updated`);
+ showPluginToast("Plugin " + plugin.name + " updated", 5000);
+ // Update the corresponding plugin
+ const updatedPlugin = {
+ ...existingPlugin,
+ icon: plugin.icon ? plugin.icon : "fa-puzzle-piece",
+ id: plugin.id,
+ name: plugin.name,
+ description: plugin.description,
+ url: plugin.url,
+ localInstallOnly: Boolean(plugin.localInstallOnly),
+ version: plugin.version,
+ code: pluginSource,
+ author: plugin.author,
+ sha: sha,
+ compatIssueIds: plugin.compatIssueIds
+ };
+ // Replace the old plugin in the plugins array
+ const pluginIndex = plugins.indexOf(existingPlugin);
+ if (pluginIndex >= 0) {
+ plugins.splice(pluginIndex, 1, updatedPlugin);
+ } else {
+ plugins.push(updatedPlugin);
+ }
+ }
+ }
+ else if (existingPlugin !== undefined) {
+ // Update the corresponding plugin's metadata
+ const updatedPlugin = {
+ ...existingPlugin,
+ icon: plugin.icon ? plugin.icon : "fa-puzzle-piece",
+ id: plugin.id,
+ name: plugin.name,
+ description: plugin.description,
+ url: plugin.url,
+ localInstallOnly: Boolean(plugin.localInstallOnly),
+ version: plugin.version,
+ author: plugin.author,
+ compatIssueIds: plugin.compatIssueIds
+ };
+ // Replace the old plugin in the plugins array
+ const pluginIndex = plugins.indexOf(existingPlugin);
+ plugins.splice(pluginIndex, 1, updatedPlugin);
+ }
+ else {
+ plugins.push(plugin);
+ }
+ }
+}
+
+async function getDocument(url) {
+ try {
+ let response = await fetch(url === PLUGIN_CATALOG ? PLUGIN_CATALOG : url, { cache: "no-cache" });
+ if (!response.ok) {
+ throw new Error(`Response error: ${response.status} ${response.statusText}`);
+ }
+ let document = await response.text();
+ return document;
+ } catch (error) {
+ showPluginToast("Couldn't fetch " + extractFilename(url) + " (" + error + ")", null, true);
+ console.error(error);
+ return null;
+ }
+}
+
+/* MODAL DIALOG */
+const pluginDialogDialog = document.createElement("div");
+pluginDialogDialog.id = "pluginDialog-input-dialog";
+pluginDialogDialog.style.display = "none";
+
+pluginDialogDialog.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+document.body.appendChild(pluginDialogDialog);
+
+const pluginDialogOverlay = document.querySelector(".pluginDialog-dialog-overlay");
+const pluginDialogOkButton = document.getElementById("pluginDialog-input-ok");
+const pluginDialogCancelButton = document.getElementById("pluginDialog-input-cancel");
+const pluginDialogCloseButton = document.querySelector(".pluginDialog-dialog-close-button");
+const pluginDialogTextarea = document.getElementById("pluginDialog-input-textarea");
+let callbackOK
+let callbackCancel
+
+function pluginDialogOpenDialog(inputOK, inputCancel) {
+ pluginDialogDialog.style.display = "block";
+ callbackOK = inputOK
+ callbackCancel = inputCancel
+}
+
+function pluginDialogCloseDialog() {
+ pluginDialogDialog.style.display = "none";
+}
+
+function pluginDialogHandleOkClick() {
+ const userInput = pluginDialogTextarea.value;
+ // Do something with the user input
+ callbackOK()
+ pluginDialogCloseDialog();
+}
+
+function pluginDialogHandleCancelClick() {
+ callbackCancel()
+ pluginDialogCloseDialog();
+}
+
+function pluginDialogHandleOverlayClick(event) {
+ if (event.target === pluginDialogOverlay) {
+ pluginDialogCloseDialog();
+ }
+}
+
+function pluginDialogHandleKeyDown(event) {
+ if ((event.key === "Enter" && event.ctrlKey) || event.key === "Escape") {
+ event.preventDefault();
+ if (event.key === "Enter" && event.ctrlKey) {
+ pluginDialogHandleOkClick();
+ } else {
+ pluginDialogCloseDialog();
+ }
+ }
+}
+
+pluginDialogTextarea.addEventListener("keydown", pluginDialogHandleKeyDown);
+pluginDialogOkButton.addEventListener("click", pluginDialogHandleOkClick);
+pluginDialogCancelButton.addEventListener("click", pluginDialogHandleCancelClick);
+pluginDialogCloseButton.addEventListener("click", pluginDialogCloseDialog);
+pluginDialogOverlay.addEventListener("click", pluginDialogHandleOverlayClick);
diff --git a/ui/media/js/utils.js b/ui/media/js/utils.js
index 111a12e1..4c65bd00 100644
--- a/ui/media/js/utils.js
+++ b/ui/media/js/utils.js
@@ -845,6 +845,7 @@ function createTab(request) {
})
}
+
/* TOAST NOTIFICATIONS */
function showToast(message, duration = 5000, error = false) {
const toast = document.createElement("div")
@@ -927,3 +928,130 @@ function confirm(msg, title, fn) {
},
})
}
+
+
+/* STORAGE MANAGEMENT */
+// Request persistent storage
+async function requestPersistentStorage() {
+ if (navigator.storage && navigator.storage.persist) {
+ const isPersisted = await navigator.storage.persist();
+ console.log(`Persisted storage granted: ${isPersisted}`);
+ }
+}
+requestPersistentStorage()
+
+// Open a database
+async function openDB() {
+ return new Promise((resolve, reject) => {
+ let request = indexedDB.open("EasyDiffusionSettingsDatabase", 1);
+ request.addEventListener("upgradeneeded", function () {
+ let db = request.result;
+ db.createObjectStore("EasyDiffusionSettings", { keyPath: "id" });
+ });
+ request.addEventListener("success", function () {
+ resolve(request.result);
+ });
+ request.addEventListener("error", function () {
+ reject(request.error);
+ });
+ });
+}
+
+// Function to write data to the object store
+async function setStorageData(key, value) {
+ return openDB().then(db => {
+ let tx = db.transaction("EasyDiffusionSettings", "readwrite");
+ let store = tx.objectStore("EasyDiffusionSettings");
+ let data = { id: key, value: value };
+ return new Promise((resolve, reject) => {
+ let request = store.put(data);
+ request.addEventListener("success", function () {
+ resolve(request.result);
+ });
+ request.addEventListener("error", function () {
+ reject(request.error);
+ });
+ });
+ });
+}
+
+// Function to retrieve data from the object store
+async function getStorageData(key) {
+ return openDB().then(db => {
+ let tx = db.transaction("EasyDiffusionSettings", "readonly");
+ let store = tx.objectStore("EasyDiffusionSettings");
+ return new Promise((resolve, reject) => {
+ let request = store.get(key);
+ request.addEventListener("success", function () {
+ if (request.result) {
+ resolve(request.result.value);
+ } else {
+ // entry not found
+ resolve();
+ }
+ });
+ request.addEventListener("error", function () {
+ reject(request.error);
+ });
+ });
+ });
+}
+
+// indexedDB debug functions
+async function getAllKeys() {
+ return openDB().then(db => {
+ let tx = db.transaction("EasyDiffusionSettings", "readonly");
+ let store = tx.objectStore("EasyDiffusionSettings");
+ let keys = [];
+ return new Promise((resolve, reject) => {
+ store.openCursor().onsuccess = function (event) {
+ let cursor = event.target.result;
+ if (cursor) {
+ keys.push(cursor.key);
+ cursor.continue();
+ } else {
+ resolve(keys);
+ }
+ };
+ });
+ });
+}
+
+async function logAllStorageKeys() {
+ try {
+ let keys = await getAllKeys();
+ console.log("All keys:", keys);
+ for (const k of keys) {
+ console.log(k, await getStorageData(k))
+ }
+ } catch (error) {
+ console.error("Error retrieving keys:", error);
+ }
+}
+
+// USE WITH CARE - THIS MAY DELETE ALL ENTRIES
+async function deleteKeys(keyToDelete) {
+ let confirmationMessage = keyToDelete
+ ? `This will delete the template with key "${keyToDelete}". Continue?`
+ : "This will delete ALL templates. Continue?";
+ if (confirm(confirmationMessage)) {
+ return openDB().then(db => {
+ let tx = db.transaction("EasyDiffusionSettings", "readwrite");
+ let store = tx.objectStore("EasyDiffusionSettings");
+ return new Promise((resolve, reject) => {
+ store.openCursor().onsuccess = function (event) {
+ let cursor = event.target.result;
+ if (cursor) {
+ if (!keyToDelete || cursor.key === keyToDelete) {
+ cursor.delete();
+ }
+ cursor.continue();
+ } else {
+ // refresh the dropdown and resolve
+ resolve();
+ }
+ };
+ });
+ });
+ }
+}