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', ` +
+
+ Loading... +
+
+`) + +const tabPlugin = document.querySelector('#tab-plugin') +if (tabPlugin) { + linkTabContents(tabPlugin) +} + +const plugin = document.querySelector('#plugin') +plugin.innerHTML = ` +
+ + + + +
+

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}
    +
    ${warning}${note}Source: ${extractFilename(plugin.url)}
    +
    + ${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 = ` +
    +
    +
    +

    Paste the plugin's code here

    + +
    +
    + +
    +
    + + +
    +
    +`; + +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(); + } + }; + }); + }); + } +}