Author SHA1 Message Date
nuked88 f98a370424 Add optional extras to the official ComfyUI sidebar 2026-10-01 06:00:17 +02:00
Nuked88 bddcfb6dc7 Merge branch 'main' of https://github.com/Nuked88/ComfyUI-N-Sidebar
rease
2025-07-06 13:34:39 +02:00
Nuked88 8f9a91e2b1 🟢 app/js/sidebar_node.js
🛠️ Update theme setting retrieval and default theme assignment
2025-07-06 13:31:02 +02:00
Nuked88 10d853465c 🟢 app/panels/assets_downloader/api_civitai.py
🛠️ __init__.py -> Commented out full_url print statement
🛠️ app/panels/assets_downloader/api_civitai.py -> Added new API client class with search and transform functions
🛠️ app/panels/assets_downloader/api_civitai.py -> Built query with filters, build_request method
🛠️ app/panels/assets_downloader/api_civitai.py -> Implemented search method with error handling
🛠️ app/panels/assets_downloader/api_civitai.py -> Transformed search results into official API structure
🛠️ app/panels/assets_downloader/api_civitai.py -> Included code to convert hashData and URLs for images
🛠️ app/panels/assets_downloader/api_civitai.py -> Example usage: perform search and transform response
🔴 app/panels/assets_downloader/assets_downloader.jsb -> Modified baseModel filter logic for local filtering and request filtering
🔴 pyproject.toml -> Updated project version from 1.6.3 to 1.6.5
2025-07-06 13:29:15 +02:00
Nuked 2874d7bc77 Merge pull request #124 from Amorano/main
Update sidebar_node.js
2025-06-04 20:29:26 +02:00
Alexander G. Morano 4e1d589b7d Update sidebar_node.js
Patches the sidebar to work with Jovi_Colorizer extension once again. Pulls title colors for node entries and colors them with said color entry in the sidebar.
2025-06-04 14:17:16 -04:00
Nuked88 dff702527d 🟢 app/panels/assets_downloader/assets_downloader.html
- Added multiple base model options for filtering.

🛠️ pyproject.toml -> Updated project version to 1.6.3.
2025-06-01 18:31:55 +02:00
Nuked88 7ffc3e9e2a 🟢 assets_downloader.html for added base model filter dropdowns
🛠️ assets_downloader.jsb -> integrated base model filter into search logic
🛠️ assets_downloader.jsb -> updated event handling for new filters
🛠️ assets_downloader.css -> adjusted width for base model filter element
🔴 pyproject.toml for version bump to 1.6.2
2025-05-26 20:59:48 +02:00
Nuked88 6961609640 🟢 __init__.py for package management and version checks
🛠️ __init__.py -> Improved package version retrieval and installation logic
🛠️ settings.js -> Changed default sidebar settings and layout options
🔴 pyproject.toml -> Updated project version to 1.6.1
2025-05-26 20:09:36 +02:00
Nuked 0791426027 Merge pull request #122 from Nuked88/dev
Merge with dev
2025-05-24 16:28:55 +02:00
Nuked c88ff4def9 Merge pull request #121 from ComfyNodePRs/update-publish-yaml
Update Github Action for Publishing to Comfy Registry
2025-05-24 15:42:31 +02:00
Nuked b362bff243 Merge pull request #118 from ComfyNodePRs/update-publish-yaml
Update Github Action for Publishing to Comfy Registry
2025-05-24 15:42:08 +02:00
snomiao 5ada54681b chore(publish): update workflow for node publishing
- Add permissions to write issues in the workflow
- Set condition to run job only for 'Nuked88' repository owner
- Update action version from 'main' to 'v1' for stability and consistency
2025-05-09 04:29:43 +00:00
snomiao 3dcb2af2ef chore(publish): update workflow for node publishing with permissions and version check 2025-01-20 21:23:34 +00:00
41 changed files with 1645 additions and 13768 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Test sidebar extras
on:
push:
branches: [feat/native-sidebar-extras]
pull_request:
paths: ["backend.py", "__init__.py", "web/**", "tests/**", ".github/workflows/test.yml"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: pip install aiohttp
- run: node tests/core.test.mjs
- run: python -m unittest discover -s tests -p 'test_*.py' -v
+41 -206
View File
@@ -1,232 +1,67 @@
# ComfyUI N-Sidebar Extras
![Drag and Drop Nodes](./images/header.png)
Version 2 extends ComfyUI’s official sidebar through `app.extensionManager.registerSidebarTab`. It is still a custom node: no patch to ComfyUI or frontend build is required.
This branch targets the current sidebar API and was developed against frontend **1.52.7**. The old floating sidebar, layout editor, search, pinned-node UI and keyboard shortcuts have been removed because ComfyUI provides those features.
| Extra | What it adds | Default |
| --- | --- | --- |
| Template collections | Named, colored, ordered collections of native node templates; insert and inspect them from the sidebar | On |
| External workflows | Read-only libraries outside the native user workflow directory, with virtual collections and node previews | On |
| Workflow inspector | Node-type counts, missing-node detection, subgraph inspection, locate nodes on the canvas, JSON report export | On |
| Civitai downloader | Search, choose a model version and file, download into ComfyUI model folders, progress, pause/resume/cancel | On |
| Import & export | Migrate legacy bookmarks/categories into the official node library, preserve template/workflow collections, export extra settings | On |
| Diagnostics | Read-only server, RAM and GPU information from ComfyUI’s existing system stats endpoint | Off |
[![YouTube](./images/YouTube.svg)](https://www.youtube.com/channel/UCnu819ZX2xiusPpbQ4KzSmA)
# ComfyUI-N-Sidebar
A simple sidebar for ComfyUI.
For what i know nobody did it, so i did it.
Maybe you don't need it. I need it >.<
# Updates
- 08-09-2024
- Custom shortcuts!
Model integrity controls, collection previews and missing-node highlighting also have separate switches. Module switches take effect immediately and stop their panel’s polling when hidden. Disabling the downloader panel does not cancel a server download; use Cancel first when needed.
- 04-09-2024
- Changed the resize system of the n-sidebar (it can now be resized directly from its sides NOTE: the top side wilL only move the bar)
- Added the ability to directly download CIVITAI models into chosen folders (this includes Checkpoint, Textual Inversion, Hypernetwork, Aesthetic Gradient, LORA, LoCon, DoRA, Controlnet, Upscaler, Motion Module, VAE, Poses, Wildcards, Workflows). [More Info](#assets-downloader).
- Added the ability to import/export your settings.
- EXPERIMENTAL: You can now embed ComfyUI's native bar within the n-sidebar
- EXPERIMENTAL: Added a small console (useful if you don’t have direct access to the machine). If you want to remove this feature, simply delete the `terminal` folder within `ComfyUI-N-Sidebar/app/panels`
- EXPERIMENTAL: Added the ability to edit the layout of the bar, this functionality is highly unstable and its use may lead to unexpected behaviors
- Redesigned configuration menu
- Fixed some bugs
## Installation
- 01-08-2024
- A lot of people are having trouble with the CSS class change in the last update because of a browser cache issue: the old CSS file keeps loading from disk instead of the new one with the latest changes (thanks, modern browsers!). To fix this, I've changed the name of the CSS file, which SHOULD force browsers to load the new one. If this doesn't fix the issue, I'm not sure what will 😭.
- Added the new option `Show at Startup` in the settings. This lets you have the sidebar open at startup (if it's not in fixed mode).
- Changed the refresh icon so that it displays correctly in Chromium browsers on macOS.
- Minor change to the CSS
Inside `ComfyUI/custom_nodes`:
- 30-07-2024
- Changed the name of the sidebar class to prevent a conflict with the `comfyui-search-navigation` node.
```sh
git clone --branch feat/native-sidebar-extras https://github.com/Nuked88/ComfyUI-N-Sidebar.git
```
<details>
<summary>Previous Updates</summary>
Restart ComfyUI and reload the browser. Open **Settings → N-Sidebar** to enable or disable each module. The extension uses ComfyUI’s existing `aiohttp`; it never installs packages at startup. Python 3.10 or newer is required.
- 21-07-2024
- Fixed a cache bug on the templates panel
For an existing checkout, back up `app/settings.json` and switch to this branch. No automatic data migration runs. Open **N · Import & export** and select **Import old server settings**, or choose an old settings JSON file. Import merges bookmarks, keeps missing node names, and saves a per-user backup in `n-sidebar-before-import.json` before changing data. Repeated imports do not duplicate bookmarks.
- 19-07-2024
- Added OSX shortcut support
Extra collections are stored per ComfyUI user in `n-sidebar-extras.json`. Templates continue to use native `comfy.templates.json`. Save templates with ComfyUI’s existing **Save Selected as Template** canvas menu, then refresh the template collections panel. Old LiteGraph templates are converted when inserted; the source template is not rewritten.
- 05-07-2024
- Fixed bug on dragging templates
- Added multi user support on templates
## External workflow libraries
Set `NSIDEBAR_WORKFLOW_ROOTS` in the ComfyUI process environment, with one absolute directory per line. Existing `sb_wf_path` entries in `app/settings.json` are also recognized. In Docker, use paths visible inside the container. Refresh the panel after changing the libraries.
- 04-06-2024
- Fixed some bugs
- Changed icon bar positioning to the border of the screen
- Implemented the DAMN subcategory view on the node panel!!! xD (you can disable it in the settings if you don't want it)
- Added the ability to disable the "Auto Show" feature
- Added the ability to view folders inside the workflow panel
- Added some tooltips for better UX
- Changed the Custom Node Categories icon (I didn't like the old one)
- Added a little icon in custom categories and folders/subcategories
- Added the ability to move workflows in custom categories via the context menu
- Added the ability to open workflows in another TAB
Only valid ComfyUI workflow JSON files within those roots are listed and served. Library files are read-only: **Open in tab** loads a native workflow tab; save through ComfyUI to create your own copy. Collections do not move or rename library files. Legacy name-based workflow collections are supported; new memberships use the complete library path.
NOTE: If you had some custom categories in the workflow panel, at the first start they will be migrated to the new system (since I did a full rewrite to support the folders). It is possible that you need to refresh the page more than once to see them again. Also, a backup of the settings.json will be created before the migration in case anything goes wrong.
## Civitai downloads
NOTE2: I hope I did not break anything T_T
Network requests happen only when you search, look up a version or start a download. Set `CIVITAI_TOKEN` on the server for files requiring authentication. The token is not returned to the browser or included in exports. The selected API file URL is used; arbitrary download URLs are not accepted.
Choose a destination from ComfyUI’s registered model paths; the local model directory is selected by default when registered. Existing files are never overwritten. Partial files are removed on cancellation or error. Completed downloads are checked against Civitai’s SHA256 when available and receive a `.n-sidebar.json` sidecar. **Scan downloaded models** checks those sidecars; **Verify SHA256** detects same-size corruption. Existing models without v2 sidecars remain in the native model library.
- 25-05-2024
- Fixed some bugs
- Added Templates support**
- Added ability categorize custom categories
- Added preview (list of used nodes)
- Added Workflow support!
- Added ability to rename workflows
- Added ability to remove workflows
- Added ability to categorize workflows
- Added preview (list of used nodes), (disabled by default)
One active download is allowed at a time. Pause applies to the current stream; it is not a persistent resumable download and a prolonged pause can expire the remote connection. Restarting ComfyUI interrupts active downloads. Supported destinations are checkpoints, LoRA/LoCon/DoRA, embeddings, VAE, ControlNet, upscalers, hypernetworks and motion modules.
** i did not enabled the **rename** and **delete** because it's conflicing with the TemplateManager
The old remote shell console is replaced by optional read-only diagnostics. Bulk Civitai metadata rebuilding, arbitrary model deletion and the old floating layout are not included.
## Validation
- 21-04-2024
- Rewritten a lot of the code to improve performance and readability, and slightly adjusted the UI to ensure compatibility with future updates.
- ADDED CUSTOM CATEGORIES
- Added custom setting panel
- Re-Implemented the 'original' search function to support translated versions of ComfyUI (requires testing)
- Added LEFT and RIGHT positioning options
- Added "Auto Hide" feature
- Now you have the option to use the default node ordering in ComfyUI instead of the alphabetical one
- Added the possibility to perform a Soft or Factory Reset of the configuration
- Now most configuration settings are stored in the settings.json file.
- Fixed some bugs
- Added some other bugs for sure!
```sh
node tests/core.test.mjs
python -m unittest discover -s tests -p 'test_*.py' -v
```
- 31-03-2024
- Added fuzzy search (work in reverse too!) (enabled by default) - You can disable it in the settings
- Added description of the node under the preview (Visible only if the description is set in the node)
- Added possibility to collapse pinned node/custom nodes sections
- Added possibility to set the bottom space for the sidebar
- Added the scroll to top button
- Fixed some bugs
- Started migration from cookies to localstorage
- 🐰 Happy Easter! 🐰
</details>
The Python tests need `aiohttp` (already supplied by ComfyUI). They cover route validation, symlink/path confinement, legacy token filtering, corruption detection, download hashes, concurrent starts, pause and cancellation.
# Installation
The optional browser integration test uses the actual running frontend and Playwright:
1. Clone the repository:
`git clone https://github.com/Nuked88/ComfyUI-N-Sidebar.git`
to your ComfyUI `custom_nodes` directory
2. Enjoy!
```sh
python tests/browser_smoke.py --url http://127.0.0.1:8188
```
NOTE: If you choose to use a different method to install the ComfyUI-N-Sidebar, please ensure that you rename the folder to `ComfyUI-N-Sidebar`.
It exercises desktop and Android-style touch viewports, native tab registration, template insertion, previews, external workflow opening, legacy migration, downloader controls and module switches. Browser writes and Civitai requests are intercepted, so it does not install the extension, modify production settings or download models. Screenshots are saved under `/tmp/n-sidebar-artifacts`.
# Uninstall
- Delete the `ComfyUI-N-Sidebar` folder in `custom_nodes`
# Update
1. Navigate to the cloned repo e.g. `custom_nodes/ComfyUI-N-Sidebar`
2. `git pull`
# Settings
The most important settings are stored in `custom_nodes/ComfyUI-N-Sidebar/settings.json`
# Keyboard Shortcuts
- `Alt+Z` or `Option+Z` to toggle show/hide sidebar
- `Alt+X` or `Option+X` to focus on the search field
- `Alt+G` or `Option+G` to open the settings
# How To Set Workflows Folder
1. Open the settings with the ⚙ icon or use the shortcut `Alt+G` (`Option+G` on Mac).
2. In **WORKFLOW PATHS**, set the folder path to where your workflows are located. If you want to set multiple paths, just put one folder path per line!
3. Restart ComfyUI
# Features
### 🖱️Drag and Drop Nodes🖱️
![Drag and Drop Nodes](./images/dd.gif)
### 📌Pin Your Favorite Node📌
![Pin Your Favorite Node](./images/pin.gif)
### 🔍Search within your nodes📄
![Search within your nodes](./images/search_nodes.gif)
### 🔍Search within categories📂
![Search within categories](./images/search_categories.gif)
### 🔍Fuzzy Search🔄
![Fuzzy Search](./images/fuzzysearch.gif)
This feature is enabled by default, you can disable it in the settings. I've used fts_fuzzy_match.js by [Forrest Smith](https://github.com/forrestthewoods/lib_fts)
### 🎨 Custom Categories 📂
![Custom Categories](./images/custom_categories.gif)
### ➕Expand/Collapse Categories/Sidebar➖
![Expand/Collapse Categories and Sidebar](./images/expand_collapse.gif)
### 🔁Reorder Nodes🔁
![Reorder Nodes](./images/pin_reorder.gif)
<br>
**Note**: The node will be placed **before** the element on which it is dragged!
### 👁 Preview Node 👁
![Preview Node](./images/preview.gif)
### 📄 Templates Support 📄
![Templates Support](./images/templates.gif)
#### 📄 Workflow Support 📄
![Workflow Support](./images/workflows.gif)
### 🎨 ComfyUI Themes Support 🎨
![Themes Support ](./images/theme.gif)
### 🎨 New Settings Panel 🎨
![ComfyUI Settings](./images/settings.png)
### ASSETS DOWNLOADER
![ComfyUI Assets](./images/assets.gif)
This new panel allows you to download CIVITAI models (Checkpoint, TextualInversion, Hypernetwork, AestheticGradient, LORA, LoCon, DoRA, Controlnet, Upscaler, MotionModule, VAE, Poses, Wildcards, Workflows).
The various models will be downloaded into the folders set in the settings.
There is also the option to rebuild the "My Library" section (the dots at the top right corner):
once the reference folders are set in the settings, by launching the rebuild, the already existing models will be identified and added to the library (if they exist in the CivitAI database).
NOTE: you can set your own API key for models that are not publicly available.
### Todo:
- [x] Reordering pinned nodes
- [x] Node preview (i don't think it will be an image)
- [x] Color integration with Jovimetrix
- [x] Better search
- [x] Custom Categories!!
- [x] Workflows
- [x] Templates
- [x] Export and Import Settings
- [x] Custom Shortcuts
- [ ] Touch Support
### Known Issues:
- After you drag a template onto the workflow, if you drag any other file from your PC that does not include a workflow, it will paste the last template you dragged again.
## Contributing
Feel free to contribute to this project by reporting issues or suggesting improvements. Open an issue or submit a pull request on the GitHub repository.
## Donations
If you'd like to support the project, consider making a donation ❤️
<br>
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/C0C0AJECJ)
<a href="https://patreon.com/DreamingAIChannel"><img src="./images/patreon_badge.png" alt="Support me on Patreon" height="27" /></a>
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
## Support and license
Created by [Nuked88](https://github.com/Nuked88). Support the project on [Ko-fi](https://ko-fi.com/C0C0AJECJ) or [Patreon](https://patreon.com/DreamingAIChannel). Licensed under [MIT](LICENSE).
+5 -1403
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
-171
View File
@@ -1,171 +0,0 @@
<div class="sidebar" id="sidebar" style="width:{{sidebar_width}};">
<div class="dragHandleTB" id="dragHandleT"></div>
<div class="sidebar-header" id="sidebar-header" data-content="panel_head_home">
<input type="text" id="searchInputSB" placeholder="Search..." autocomplete="off">
<span class="clearIcon">&times;</span>
<span class="searchCategoryIcon" title="Category Only">C</span>
</div>
<div id="sidebar_views">
<div class="view_button right" id="switch_settings" data-tooltip="Settings">⚙</div>
<div class="view_button right" id="switch_sidebar" data-tooltip="Toggle Sidebar"><i class="pi pi-bars"></i></div>
<div class="view_button right" id="switch_home" data-content="home" data-tooltip="Home"><i class="pi pi-home"></i></div>
<div class="view_button" id="official_button"></div>
</div>
<div class="content_sidebar" id="panel_home" data-expanded="false" data-content="panel_content_home">
<div id="spacer"></div>
<label class="sb_label" id="sb_label_pinned">PINNED</label>
<ul id="sidebarBookmarks"></ul>
<label class="sb_label" id="sb_label_custom">CUSTOM NODES<button class="expand_node left" data-tooltip="Expand/Collapse">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" enable-background="new 0 0 52 52"
xml:space="preserve">
<path
d="M48,9.5C48,8.7,47.3,8,46.5,8h-41C4.7,8,4,8.7,4,9.5v3C4,13.3,4.7,14,5.5,14h41c0.8,0,1.5-0.7,1.5-1.5V9.5z" />
<path d="M48,39.5c0-0.8-0.7-1.5-1.5-1.5h-41C4.7,38,4,38.7,4,39.5v3C4,43.3,4.7,44,5.5,44h41c0.8,0,1.5-0.7,1.5-1.5
V39.5z" />
<path d="M30,29h4.5c0.8,0,1.5-0.7,1.5-1.5v-3c0-0.8-0.7-1.5-1.5-1.5H30c-0.6,0-1-0.4-1-1v-4.5c0-0.8-0.7-1.5-1.5-1.5
h-3c-0.8,0-1.5,0.7-1.5,1.5V22c0,0.6-0.4,1-1,1h-4.5c-0.8,0-1.5,0.7-1.5,1.5v3c0,0.8,0.7,1.5,1.5,1.5H22c0.6,0,1,0.4,1,1v4.5
c0,0.8,0.7,1.5,1.5,1.5h3c0.8,0,1.5-0.7,1.5-1.5V30C29,29.4,29.4,29,30,29z" />
<rect class="expand_node" style="opacity:0" x="0" y="0" width="52" height="52" />
</svg>
</button></label>
<ul id="sidebarCustomNodes"></ul>
</div>
<div class="dragHandle" id="dragHandle"></div>
<div id="previewDiv"></div>
<!-- settings -->
<div id="sb-modal-backdrop-settings" class="sb-modal-backdrop-settings sb_hidden"></div>
<div id="sb_settingsDiv_export" class="sb_hidden">
<h1>Export Settings</h1>
<div class="setting"><label>Pinned Nodes</label><input type="checkbox" checked></div>
<div class="setting"><label>Custom Categories</label><input type="checkbox" checked></div>
<div class="setting"><label>Workflows Categories</label><input type="checkbox" checked></div>
<div class="setting"><label>Templates Categories</label><input type="checkbox" checked></div>
<div class="setting"><label>CivitAI API KEY</label><input type="checkbox" checked></div>
<div class="setting"><label>Model Templates</label><input type="checkbox" checked></div>
<div class="setting"><label>Other Settings</label><input type="checkbox" checked></div>
<div id="sb_buttonsContainer"><button id="sb_runExportButton">Export</button></div>
</div>
<div id="sb_settingsDiv_import" class="sb_hidden">
<h1>Import Settings</h1>
<div class="setting"><label>Pinned Nodes</label><input type="checkbox" checked></div>
<div class="setting"><label>Custom Categories</label><input type="checkbox" checked></div>
<div class="setting"><label>Workflows Categories</label><input type="checkbox" checked></div>
<div class="setting"><label>Templates Categories</label><input type="checkbox" checked></div>
<div class="setting"><label>CivitAI API KEY</label><input type="checkbox" checked></div>
<div class="setting"><label>Model Templates</label><input type="checkbox" checked></div>
<div class="setting"><label>Other Settings</label><input type="checkbox" checked></div>
<div id="sb_buttonsContainer">
<button id="sb_selectFileButton">Select File</button>
<input type="file" id="importFileInput" style="display: none;">
<button id="sb_runImportButton">Import</button>
</div>
</div>
<div id="sb_settingsDiv_shortcut" class="sb_hidden">
<h1>Shortcut Settings</h1>
<div class="setting">
<label>Show/hide Sidebar (<span class="current-shortcut" data-action="action1"></span>)</label>
<input type="text" class="sb-shortcut" data-action="action1" placeholder="Press Ctrl/Alt/Shift + Letter/Number" readonly>
</div>
<div class="setting">
<label>Quick Search (<span class="current-shortcut" data-action="action2"></span>) </label>
<input type="text" class="sb-shortcut" data-action="action2" placeholder="Press Ctrl/Alt/Shift + Letter/Number" readonly>
</div>
<div class="setting">
<label>Open Settings (<span class="current-shortcut" data-action="action3"></span>)</label>
<input type="text" class="sb-shortcut" data-action="action3" placeholder="Press Ctrl/Alt/Shift + Letter/Number" readonly>
</div>
<div id="sb_buttonsContainer">
<button id="sb_saveAllShortcuts">Save Shortcuts</button>
<button id="sb_resetToDefault">Reset to Default</button>
</div>
</div>
<style>
#g_section, #w_section, #t_section, #m_section {
margin-top: 3vh;
height: auto;
background: var(--bg-color);
}
#g_section h3, #w_section h3, #t_section h3, #m_section h3 {
font-size: 12px;
line-height: 27px;
font-weight: 600;
text-align: center;
color: var(--content-fg);
border-bottom: 2px solid var(--border-color);
}
</style>
<div id="sb_settingsDiv" class="sb_hidden">
<div id="tabContainer" class="n-sb-tab-container">
<div id="tabSettings" class="n-sb-tab n-sb-active">SideBar Settings</div>
<div id="tabCustomize" class="n-sb-tab">Customize</div>
</div>
<!-- SideBar Settings Content -->
<div id="settingsContainer" class="n-sb-tab-content n-sb-active">
<div id="sb_buttonsContainer">
<button id="sb_resetButton">Soft Reset</button>
<button id="sb_factoryResetButton">Factory Reset</button>
<button id="sb_exportButton">Export Config</button>
<button id="sb_importButton">Import Config</button>
<button id="sb_shortcutButton">Shortcuts</button>
</div>
<div id="g_section"><h3>General Settings</h3></div>
<div id="w_section"><h3>Workflow Panel</h3></div>
<div id="m_section"><h3>Assets Downloader Panel</h3></div>
</div>
<!-- Customize Content -->
<div id="sbCustomizeContainer" class="n-sb-tab-content">
<center style="color:red">NOTE: THIS SECTION IS UNDER DEVELOPMENT, PLEASE USE AT YOUR OWN RISK.</center>
<div id="n-sb-section-list">
<!-- Le sezioni verranno inserite qui dinamicamente -->
</div>
<div id="sb_buttonsContainerCustomize">
<button id="add-section-btn">Add Section</button>
<button id="save-btn">Save Layout</button>
<button id="restore-btn">Restore Layout</button>
</div>
<!-- Add your customize settings UI here -->
</div>
</div>
<!-- end settings -->
<a href="#" id="sb_scrollToTopButton">
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 330 330" xml:space="preserve">
<path id="XMLID_224_"
d="M325.606,229.393l-150.004-150C172.79,76.58,168.974,75,164.996,75c-3.979,0-7.794,1.581-10.607,4.394
l-149.996,150c-5.858,5.858-5.858,15.355,0,21.213c5.857,5.857,15.355,5.858,21.213,0l139.39-139.393l139.397,139.393
C307.322,253.536,311.161,255,315,255c3.839,0,7.678-1.464,10.607-4.394C331.464,244.748,331.464,235.251,325.606,229.393z" />
</svg>
</a>
<div id="customMenu" class="custom-menu">
<ul> </ul>
</div>
<div class="dragHandleTB" id="dragHandleV"></div>
</div>
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100; fill:var(--input-text);" xml:space="preserve"><g><path d="M29,82h13V63.1c0-1.7,1.3-3,3-3h10.3c1.7,0,3,1.3,3,3V82H71c1.7,0,3-1.3,3-3V43.2c0-0.8-0.3-1.6-0.9-2.1l-21-21 c-1.2-1.2-3.1-1.2-4.2,0l-21,21c-0.6,0.6-0.9,1.3-0.9,2.1V79C26,80.7,27.3,82,29,82z"/></g></svg>

Before

Width:  |  Height:  |  Size: 439 B

-282
View File
@@ -1,282 +0,0 @@
// LICENSE
//
// This software is dual-licensed to the public domain and under the following
// license: you are granted a perpetual, irrevocable license to copy, modify,
// publish, and distribute this file as you see fit.
//
// VERSION
// 0.1.0 (2016-03-28) Initial release
//
// AUTHOR
// Forrest Smith
//
// CONTRIBUTORS
// J�rgen Tjern� - async helper
// Anurag Awasthi - updated to 0.2.0
const SEQUENTIAL_BONUS = 25; // bonus for adjacent matches
const SEPARATOR_BONUS = 30; // bonus if match occurs after a separator
const CAMEL_BONUS = 30; // bonus if match is uppercase and prev is lower
const FIRST_LETTER_BONUS = 15; // bonus if the first letter is matched
const LEADING_LETTER_PENALTY = -5; // penalty applied for every letter in str before the first match
const MAX_LEADING_LETTER_PENALTY = -15; // maximum penalty for leading letters
const UNMATCHED_LETTER_PENALTY = -1;
/**
* Returns true if each character in pattern is found sequentially within str
* @param {*} pattern string
* @param {*} str string
*/
function fuzzyMatchSimple(pattern, str) {
let patternIdx = 0;
let strIdx = 0;
const patternLength = pattern.length;
const strLength = str.length;
while (patternIdx != patternLength && strIdx != strLength) {
const patternChar = pattern.charAt(patternIdx).toLowerCase();
const strChar = str.charAt(strIdx).toLowerCase();
if (patternChar == strChar) ++patternIdx;
++strIdx;
}
return patternLength != 0 && strLength != 0 && patternIdx == patternLength
? true
: false;
}
/**
* Does a fuzzy search to find pattern inside a string.
* @param {*} pattern string pattern to search for
* @param {*} str string string which is being searched
* @returns [boolean, number] a boolean which tells if pattern was
* found or not and a search score
*/
function fuzzyMatch(pattern, str) {
const recursionCount = 0;
const recursionLimit = 10;
const matches = [];
const maxMatches = 256;
return fuzzyMatchRecursive(
pattern,
str,
0 /* patternCurIndex */,
0 /* strCurrIndex */,
null /* srcMatces */,
matches,
maxMatches,
0 /* nextMatch */,
recursionCount,
recursionLimit
);
}
function fuzzyMatchRecursive(
pattern,
str,
patternCurIndex,
strCurrIndex,
srcMatces,
matches,
maxMatches,
nextMatch,
recursionCount,
recursionLimit
) {
let outScore = 0;
// Return if recursion limit is reached.
if (++recursionCount >= recursionLimit) {
return [false, outScore];
}
// Return if we reached ends of strings.
if (patternCurIndex === pattern.length || strCurrIndex === str.length) {
return [false, outScore];
}
// Recursion params
let recursiveMatch = false;
let bestRecursiveMatches = [];
let bestRecursiveScore = 0;
// Loop through pattern and str looking for a match.
let firstMatch = true;
while (patternCurIndex < pattern.length && strCurrIndex < str.length) {
// Match found.
if (
pattern[patternCurIndex].toLowerCase() === str[strCurrIndex].toLowerCase()
) {
if (nextMatch >= maxMatches) {
return [false, outScore];
}
if (firstMatch && srcMatces) {
matches = [...srcMatces];
firstMatch = false;
}
const recursiveMatches = [];
const [matched, recursiveScore] = fuzzyMatchRecursive(
pattern,
str,
patternCurIndex,
strCurrIndex + 1,
matches,
recursiveMatches,
maxMatches,
nextMatch,
recursionCount,
recursionLimit
);
if (matched) {
// Pick best recursive score.
if (!recursiveMatch || recursiveScore > bestRecursiveScore) {
bestRecursiveMatches = [...recursiveMatches];
bestRecursiveScore = recursiveScore;
}
recursiveMatch = true;
}
matches[nextMatch++] = strCurrIndex;
++patternCurIndex;
}
++strCurrIndex;
}
const matched = patternCurIndex === pattern.length;
if (matched) {
outScore = 100;
// Apply leading letter penalty
let penalty = LEADING_LETTER_PENALTY * matches[0];
penalty =
penalty < MAX_LEADING_LETTER_PENALTY
? MAX_LEADING_LETTER_PENALTY
: penalty;
outScore += penalty;
//Apply unmatched penalty
const unmatched = str.length - nextMatch;
outScore += UNMATCHED_LETTER_PENALTY * unmatched;
// Apply ordering bonuses
for (let i = 0; i < nextMatch; i++) {
const currIdx = matches[i];
if (i > 0) {
const prevIdx = matches[i - 1];
if (currIdx == prevIdx + 1) {
outScore += SEQUENTIAL_BONUS;
}
}
// Check for bonuses based on neighbor character value.
if (currIdx > 0) {
// Camel case
const neighbor = str[currIdx - 1];
const curr = str[currIdx];
if (
neighbor !== neighbor.toUpperCase() &&
curr !== curr.toLowerCase()
) {
outScore += CAMEL_BONUS;
}
const isNeighbourSeparator = neighbor == "_" || neighbor == " ";
if (isNeighbourSeparator) {
outScore += SEPARATOR_BONUS;
}
} else {
// First letter
outScore += FIRST_LETTER_BONUS;
}
}
// Return best result
if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) {
// Recursive score is better than "this"
matches = [...bestRecursiveMatches];
outScore = bestRecursiveScore;
return [true, outScore];
} else if (matched) {
// "this" score is better than recursive
return [true, outScore];
} else {
return [false, outScore];
}
}
return [false, outScore];
}
/**
* Strictly optional utility to help make using fts_fuzzy_match easier for large data sets
* Uses setTimeout to process matches before a maximum amount of time before sleeping
*
* To use:
* const asyncMatcher = new ftsFuzzyMatchAsync(fuzzyMatch, "fts", "ForrestTheWoods",
* function(results) { console.log(results); });
* asyncMatcher.start();
*
* @param {*} matchFn function Matching function - fuzzyMatchSimple or fuzzyMatch.
* @param {*} pattern string Pattern to search for.
* @param {*} dataSet array Array of string in which pattern is searched.
* @param {*} onComplete function Callback function which is called after search is complete.
*/
function ftsFuzzyMatchAsync(matchFn, pattern, dataSet, onComplete) {
const ITEMS_PER_CHECK = 1000; // performance.now can be very slow depending on platform
const results = [];
const max_ms_per_frame = 1000.0 / 30.0; // 30FPS
let dataIndex = 0;
let resumeTimeout = null;
// Perform matches for at most max_ms
function step() {
clearTimeout(resumeTimeout);
resumeTimeout = null;
var stopTime = performance.now() + max_ms_per_frame;
for (; dataIndex < dataSet.length; ++dataIndex) {
if (dataIndex % ITEMS_PER_CHECK == 0) {
if (performance.now() > stopTime) {
resumeTimeout = setTimeout(step, 1);
return;
}
}
var str = dataSet[dataIndex];
var result = matchFn(pattern, str);
// A little gross because fuzzy_match_simple and fuzzy_match return different things
if (matchFn === fuzzyMatchSimple && result == true) results.push(str);
else if (matchFn === fuzzyMatch && result[0] == true) results.push([result[1], str]);
}
onComplete(results);
return null;
}
// Abort current process
this.cancel = function() {
if (resumeTimeout !== null) clearTimeout(resumeTimeout);
};
// Must be called to start matching.
// I tried to make asyncMatcher auto-start via "var resumeTimeout = step();"
// However setTimout behaving in an unexpected fashion as onComplete insisted on triggering twice.
this.start = function() {
step();
};
// Process full list. Blocks script execution until complete
this.flush = function() {
max_ms_per_frame = Infinity;
step();
};
}
export { fuzzyMatch, fuzzyMatchSimple, ftsFuzzyMatchAsync };
-676
View File
@@ -1,676 +0,0 @@
//load folder name
function getNameFolderSync() {
const xhr = new XMLHttpRequest();
xhr.open("GET", "sidebar/current", false); // false = sincrona (blasfemia)
xhr.send(null);
if (xhr.status === 200) {
return JSON.parse(xhr.responseText);
} else {
throw new Error("Errore nella richiesta");
}
}
const nameFolder = getNameFolderSync();
const cnPath = `../extensions/${nameFolder}/`;
function addSidebarStyles(cssPath) {
const timestamp = new Date().getTime();
const linkElement = document.createElement("link");
linkElement.rel = "stylesheet";
linkElement.type = "text/css";
linkElement.href =`${cnPath}${cssPath}?v=${timestamp}`
document.head.appendChild(linkElement);
}
function sidebarAddNode(name, text, x, y) {
const node = LiteGraph.createNode(name, text)
if (node) {
const pos =
[
x,
y
]
node.pos = pos;
app.graph.add(node)
}
}
function sdExpandAll(forceExpand = 0) {
const categoryItems = document.querySelectorAll(".content_sidebar .sidebarCategory");
const side_bar_status = document.querySelector(".content_sidebar").dataset.expanded;
const expand_nodes = document.querySelectorAll(".expand_node");
let display_value = "true";
if (side_bar_status === "true" && forceExpand === 0) {
display_value = "none";
expand_nodes.forEach(node => {
if (node.tagName.toLowerCase() === "button") {
node.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52" enable-background="new 0 0 52 52" xml:space="preserve">
<path d="M48,9.5C48,8.7,47.3,8,46.5,8h-41C4.7,8,4,8.7,4,9.5v3C4,13.3,4.7,14,5.5,14h41c0.8,0,1.5-0.7,1.5-1.5V9.5z"
/>
<path d="M48,39.5c0-0.8-0.7-1.5-1.5-1.5h-41C4.7,38,4,38.7,4,39.5v3C4,43.3,4.7,44,5.5,44h41c0.8,0,1.5-0.7,1.5-1.5
V39.5z"/>
<path d="M30,29h4.5c0.8,0,1.5-0.7,1.5-1.5v-3c0-0.8-0.7-1.5-1.5-1.5H30c-0.6,0-1-0.4-1-1v-4.5c0-0.8-0.7-1.5-1.5-1.5
h-3c-0.8,0-1.5,0.7-1.5,1.5V22c0,0.6-0.4,1-1,1h-4.5c-0.8,0-1.5,0.7-1.5,1.5v3c0,0.8,0.7,1.5,1.5,1.5H22c0.6,0,1,0.4,1,1v4.5
c0,0.8,0.7,1.5,1.5,1.5h3c0.8,0,1.5-0.7,1.5-1.5V30C29,29.4,29.4,29,30,29z"/>
<rect class="expand_node" style="opacity:0" x="0" y="0" width="52" height="52" />
</svg>`;
}
});
document.querySelector(".content_sidebar").dataset.expanded = "false";
} else {
display_value = "block";
expand_nodes.forEach(node => {
if (node.tagName.toLowerCase() === "button") {
node.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" enable-background="new 0 0 52 52" xml:space="preserve" >
<path d="M48,9.5C48,8.7,47.3,8,46.5,8h-41C4.7,8,4,8.7,4,9.5v3C4,13.3,4.7,14,5.5,14h41c0.8,0,1.5-0.7,1.5-1.5V9.5z"></path>
<path d="M48,39.5c0-0.8-0.7-1.5-1.5-1.5h-41C4.7,38,4,38.7,4,39.5v3C4,43.3,4.7,44,5.5,44h41c0.8,0,1.5-0.7,1.5-1.5
V39.5z"></path>
<path d="M34.5,29c0.8,0,1.5-0.7,1.5-1.5v-3c0-0.8-0.7-1.5-1.5-1.5h-17c-0.8,0-1.5,0.7-1.5,1.5v3
c0,0.8,0.7,1.5,1.5,1.5H34.5z"></path>
<rect class="expand_node" style="opacity:0" x="0" y="0" width="52" height="52" />
</svg>`;
}
});
document.querySelector(".content_sidebar").dataset.expanded = "true";
}
categoryItems.forEach(function (categoryItem) {
const displayNamesList = categoryItem.querySelector("ul");
if (displayNamesList) {
displayNamesList.style.display = display_value;
}
});
}
function handleSearch(categorySearchToggle,searchSection,searchIdInput) {
return new Promise((resolve, reject) => {
const options = {
keys: ['textContent','category'],
threshold: 0.5,
useExtendedSearch: true
};
const searchTerm = document.getElementById(searchIdInput).value.toLowerCase();
const categoryItems = document.querySelectorAll(".sidebarCategory li");
const categories = document.querySelectorAll(searchSection+" .sidebarCategory");
const listItems = document.querySelectorAll(searchSection+" .sidebarItem");
/*reset*/
categoryItems.forEach(category => {
const subItems = category.querySelectorAll("li");
category.style.display = "block";
subItems.forEach(sub => {
sub.style.display = "block";
});
});
const sidebarItems = categorySearchToggle ? categories : listItems;
sidebarItems.forEach(item => {
let itemText = item.textContent.toLowerCase();
if (categorySearchToggle) {
itemText = Array.from(item.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE)
.map(node => node.textContent.trim())
.join(' ').toLowerCase();
}
//fts_fuzzy_match(searchTerm, cleanText(itemText))
const isInSearchTerm = itemText.includes(searchTerm);
item.style.display = isInSearchTerm ? "block" : "none";
});
/* hide empty categories */
categories.forEach(category => {
const subItems = category.querySelectorAll("li");
const areAllHidden = Array.from(subItems).every(subItem => subItem.style.display === "none");
category.style.display = areAllHidden ? "none" : category.style.display;
});
sdExpandAll(1);
resolve(searchTerm);
});
}
//PREVIEW
function safeObjectKeys(obj) {
try {
return Object.keys(obj);
} catch (error) {
//console.error('Error while trying to get object keys:', error);
return [];
}
}
function createNodePreview(nodeID) {
let description = "";
let rows = "";
let last_rows = "";
let error = '';
const data = LiteGraph.registered_node_types;
try{
description = data[nodeID].nodeData.description;
}catch(err){
description = "";
}
try{
let inputs = data[nodeID].nodeData.input; //divided between optional and required
let outputs_name = data[nodeID].nodeData.output_name;
let outputs = data[nodeID].nodeData.output;
//console.log(category, inputs, outputs);
//create array with objectkeys from input and output
const inputArray = [];
const outputArray = [];
const inputKeysRequired = safeObjectKeys(inputs.required)
const inputKeysOptional = safeObjectKeys(inputs.optional)
for (let i = 0; i < inputKeysRequired.length; i++) {
try{
let thirdV = null;
let secondV = inputs.required[inputKeysRequired[i]][0];
if (Array.isArray(secondV)){
//array
secondV = inputs.required[inputKeysRequired[i]][0][0];
thirdV = inputs.required[inputKeysRequired[i]][0][0];
}
let isArr = false
try {
if( typeof inputs.required[inputKeysRequired[i]][0] === 'object'){
isArr = true
}
}
catch(err) {
console.log("error",err)
}
if (inputs.required[inputKeysRequired[i]][1]!=undefined && Object.keys(inputs.required[inputKeysRequired[i]][1]).length > 0){
if (!isArr){
//object
thirdV = inputs.required[inputKeysRequired[i]][1].default;
}
else{
thirdV = inputs.required[inputKeysRequired[i]][0][0];
}
}
inputArray.push([inputKeysRequired[i],secondV,thirdV]);
}catch(err){
console.log("error",err)
}
}
try{
for (let i = 0; i < inputKeysOptional.length; i++) {
try{
let thirdV = null;
let secondV = inputs.optional[inputKeysOptional[i]][0];
if (Array.isArray(secondV)){
//array
secondV = inputs.optional[inputKeysOptional[i]][0][0];
thirdV = inputs.optional[inputKeysOptional[i]][0][0];
}
let isArr = false
try {
if( typeof inputs.optional[inputKeysOptional[i]][0] === 'object'){
isArr = true
}
}
catch(err) {
console.log("error",err)
}
if (inputs.optional[inputKeysOptional[i]][1]!=undefined && Object.keys(inputs.optional[inputKeysOptional[i]][1]).length > 0){
if (!isArr){
//object
thirdV = inputs.optional[inputKeysOptional[i]][1].default;
}
else{
thirdV = inputs.optional[inputKeysOptional[i]][0][0];
}
}
inputArray.push([inputKeysOptional[i],secondV,thirdV]);
}catch(err){
console.log("error",err)
}
}
}catch(err){
console.log(err)
}
for (let i = 0; i < outputs.length; i++) {
if (outputs_name[i] != undefined) {
outputArray.push([outputs[i],outputs_name[i]]);
}
else{
outputArray.push(outputs[i],outputs[i]);
}
}
let length_loop = outputArray.length;
if (inputArray.length> outputArray.length){
length_loop = inputArray.length;
}
for (let i = 0; i < length_loop; i++) {
const inputList= inputArray[i] ? inputArray[i] : null;
const outputList= outputArray[i] ? outputArray[i] : null;
let inputName = "";
let inputType = "";
let inputValue = null;
let outputType = "";
let outputName = "";
if (inputList != null){
inputName = inputList[0];
inputType = inputList[1];
inputValue = inputList[2];
}else
{
inputName = "";
inputType = "sb_hidden";
inputValue = null;
}
if (outputList != null){
outputType = outputList[0];
outputName = outputList[1];
}else
{
outputType = "sb_hidden";
outputName = "";
}
array_list_type = [
"BOOLEAN",
"CLIP",
"CLIP_VISION",
"CLIP_VISION_OUTPUT",
"CONDITIONING",
"CONTROL_NET",
"CONTROL_NET_WEIGHTS",
"FLOAT",
"GLIGEN",
"IMAGE",
"IMAGEUPLOAD",
"INT",
"LATENT",
"LATENT_KEYFRAME",
"MASK",
"MODEL",
"SAMPLER",
"SIGMAS",
"STRING",
"STYLE_MODEL",
"T2I_ADAPTER_WEIGHTS",
"TAESD",
"TIMESTEP_KEYFRAME",
"UPSCALE_MODEL",
"VAE",
]
if (inputValue != null){
if (inputType == "STRING"){
last_rows += `<div class="sb_row sb_row_string nodepreview long_field">
<div class="sb_col sb_arrow"></div>
<div class="sb_col">${inputName}</div>
<div class="sb_col middle-column"></div>
<div class="sb_col sb_inherit_orig">${inputValue}</div>
<div class="sb_col sb_arrow"></div>
</div>`;
}else{
last_rows += `<div class="sb_row nodepreview long_field">
<div class="sb_col sb_arrow">&#x25C0;</div>
<div class="sb_col ">${inputName}</div>
<div class="sb_col middle-column"></div>
<div class="sb_col sb_inherit">${inputValue}</div>
<div class="sb_col sb_arrow">&#x25B6;</div>
</div>`;
}
inputType = "sb_hidden";
inputName = "";
}
else{
if (inputType == "STRING"){
last_rows += `<div class="sb_row sb_row_string nodepreview long_field">
<div class="sb_col sb_arrow"></div>
<div class="sb_col">${inputName}</div>
<div class="sb_col middle-column"></div>
<div class="sb_col sb_inherit_orig"></div>
<div class="sb_col sb_arrow"></div>
</div>`;
}
}
if (inputType != "STRING"){
rows += `<div class="sb_row nodepreview">
<div class="sb_col"><div class="sb_dot ${inputType}"></div></div>
<div class="sb_col">${inputName}</div>
<div class="sb_col middle-column"> </div>
<div class="sb_col sb_inherit">${outputName}</div>
<div class="sb_col "><div class="sb_dot ${outputType}"></div></div>
</div>`;
}
}
//console.log( inputArray,outputArray);
////create preview
//const nodePreview = document.createElement("div");
//nodePreview.classList.add("node_preview");
//nodePreview.id = nodeID;
error = "";
}catch(err){
error = "NOT AVAILABLE";
rows = ``
last_rows = ``
}
return [`<div class="sb_table">
<div class="node_header"><div class="sb_dot headdot"></div>${nodeID}</div>
<div class="sb_preview_badge">PREVIEW ${error}</div>
${rows}
${last_rows}
</div>`,description];
}
async function getPreview(item, previewDiv, sidebad_view_width, contentFunction, configFunction = null) {
try {
item.addEventListener('mouseover', async function () {
let shouldShowPreview = true;
if (configFunction) {
shouldShowPreview = await configFunction();
}
if (shouldShowPreview && this.classList.contains('sidebarItem') && this.tagName === 'LI') {
let descriptionDiv = "";
const itemPosition = getElementPosition(this);
let previewDivTop = 0;
// Usa la funzione contentFunction per ottenere il contenuto del preview
const [previewContent, node_description] = await contentFunction(item);
if (node_description) {
descriptionDiv = "<div class='sb_description'>" + node_description + "</div>";
}
previewDiv.innerHTML = previewContent + descriptionDiv;
previewDiv.style.display = 'block';
const correction_offset = 45;
let sbtop = calcSBTop();
if (itemPosition.top - this.offsetHeight >= 0 && itemPosition.top + previewDiv.offsetHeight < document.body.offsetHeight) {
previewDivTop = itemPosition.top - this.offsetHeight - sbtop;
} else if (itemPosition.top - this.offsetHeight - previewDiv.offsetHeight <= 0) {
previewDivTop = 0 + correction_offset - sbtop;
} else {
previewDivTop = (itemPosition.top + this.offsetHeight) - previewDiv.offsetHeight - sbtop;
}
let sidebar_width = parseInt(getVar("sidebarWidth")) || 500;
previewDiv.style.top = `${previewDivTop}px`;
const previewDivLeft = sidebar_width - sidebad_view_width + correction_offset;
if (sbPosition == "left") {
previewDiv.style.left = `${previewDivLeft}px`;
} else {
previewDiv.style.right = `${previewDivLeft}px`;
}
}
});
item.addEventListener('mouseout', function () {
previewDiv.style.display = 'none';
});
} catch (error) {
console.log(error);
}
}
//change view
function switchTab(tabId) {
if (tabId != null) {
const tabContents = document.querySelectorAll('.content_sidebar');
const tabHead = document.querySelectorAll('.sidebar-header');
let switch_id= tabId.replace("panel", "switch");
const switchButton = document.getElementById(switch_id);
if (tabId=="panel_home"){
const tabIdHead = document.getElementById("sidebar-header");
tabIdHead.classList.remove('sb_hidden');
document.getElementById("switch_home").classList.add('sb_button_active');
}
else{
tabHead.forEach(content => {
content.classList.add('sb_hidden');
})
}
// Hide all tabs
tabContents.forEach(content => {
content.classList.add('sb_hidden');
let switch_id_disbled= content.id.replace("panel", "switch");
const switchButtonDisbled = document.getElementById(switch_id_disbled);
switchButtonDisbled.classList.remove('sb_button_active');
});
// Show the content of the clicked tab
document.getElementById(tabId).classList.remove('sb_hidden');
switchButton.classList.add('sb_button_active');
setVar('sb_current_tab', tabId);
}
}
// PINNED ITEMS
function savePinnedItems(pinnedItems) {
const pinnedItemsString = JSON.stringify(pinnedItems);
//setVar('pinnedItems', pinnedItemsString, 9999);
updateConfiguration('sb_pinnedItems',pinnedItemsString)
}
async function loadPinnedItems() {
const pinnedItemsString = await getConfiguration('sb_pinnedItems');//getVar('pinnedItems');
if (pinnedItemsString) {
return JSON.parse(pinnedItemsString);
}
return [];
}
function migrationSettings() {
const oldSettingsList = ["pinnedItems","Comfy.Settings.sidebar_noderadius_settings","Comfy.Settings.sidebar_barbottom","Comfy.Settings.sidebar_bartop","Comfy.Settings.sidebar_blur_settings","Comfy.Settings.sidebar_opacity_settings","Comfy.Settings.sidebar_font_settings","Comfy.Settings.sidebar_node_settings"];
const newSettingsList = ["sb_pinnedItems","sb_noderadius","sb_barbottom","sb_bartop","sb_blur","sb_opacity","sb_font","sb_node"];
oldSettingsList.forEach((setting, index) => {
try{
const newSetting = newSettingsList[index];
const oldSettingValue = getVar(setting);
if (oldSettingValue) {
if (setting != "pinnedItems") {
updateConfiguration(newSetting, parseFloat(oldSettingValue.replace('"',"").replace('"',"")));
} else {
updateConfiguration(newSetting, oldSettingValue);
}
renameVar(setting, "bk_"+setting)
}
}catch(err){
console.log(err)
}
})
// updateConfiguration('sb_pinnedItems',pinnedItemsString) ;
}
function toggleSHSB(force = undefined) {
const side_bars = document.querySelectorAll(".content_sidebar");
const main_sidebar = document.getElementById('sidebar');
const search_bar = document.getElementById('searchInputSB');
const scrollToTopButton = document.getElementById("sb_scrollToTopButton");
const clearIcon = document.querySelector(".clearIcon");
const searchCategoryIcon = document.querySelector(".searchCategoryIcon");
const switch_sidebar = document.getElementById('switch_sidebar');
const sidebar_views = document.getElementById("sidebar_views");
//search_bar.classList.toggle('closed',force);
side_bars.forEach(side_bar => {
//side_bar.classList.toggle('closed',force);
if (force !== undefined) {
if (force) {
setVar("sb_state", "closed");
side_bar.classList.add('closed');
clearIcon.classList.add('closed');
searchCategoryIcon.classList.add('closed');
search_bar.classList.add('closed');
scrollToTopButton.classList.add('closed');
sidebar_views.classList.add('full_rounded');
} else {
setVar("sb_state", "open");
side_bar.classList.remove('closed');
clearIcon.classList.remove('closed');
searchCategoryIcon.classList.remove('closed');
search_bar.classList.remove('closed');
scrollToTopButton.classList.remove('closed');
sidebar_views.classList.remove('full_rounded');
setVar("sb_minimized", "true");
}
} else {
if (side_bar.classList.contains('closed')) {
setVar("sb_state", "open");
side_bar.classList.remove('closed');
clearIcon.classList.remove('closed');
searchCategoryIcon.classList.remove('closed');
search_bar.classList.remove('closed');
sidebar_views.classList.remove('full_rounded');
scrollToTopButton.classList.remove('closed');
//fix for keyboard shortcuts
if (getVar("sb_minimized") == "true") {
switch_sidebar.style.filter = "brightness(0.8)";
}
} else {
if (getVar("sb_minimized") == "false") {
setVar("sb_state", "closed");
side_bar.classList.add('closed');
clearIcon.classList.add('closed');
searchCategoryIcon.classList.add('closed');
search_bar.classList.add('closed');
scrollToTopButton.classList.add('closed');
sidebar_views.classList.add('full_rounded');
switch_sidebar.style.filter = "brightness(1.0)";
} else {
switch_sidebar.style.filter = "brightness(0.8)";
}
}
}
});
if (getVar("sb_minimized") == "false") {
if (force == undefined) {
setVar("sb_state", "closed");
setVar("sb_minimized", true);
}
main_sidebar.style.width = '45px';
} else {
if (force == undefined) {
setVar("sb_state", "open");
setVar("sb_minimized", false);
main_sidebar.style.width = getVar("sidebarWidth") + 'px' || '300px';
} else if (force == true) {
setVar("sb_state", "closed");
main_sidebar.style.width = '45px';
} else {
setVar("sb_state", "open");
main_sidebar.style.width = getVar("sidebarWidth") + 'px' || '300px';
}
}
}
File diff suppressed because it is too large Load Diff
-630
View File
@@ -1,630 +0,0 @@
let new_menu_options = [];
let new_submenu_options = [];
let new_menu_options_callback = [];
let new_submenu_options_callback = [];
const settingsStyle = `
#comfy-settings-dialog input{
background: var(--comfy-input-bg);
color: var(--input-text);
border: 1px solid var(--border-color);
padding: 5px;
}
#comfy-settings-dialog tr > td:first-child {
text-align: left;
}
#comfy-settings-dialog select{
background: var(--bg-color);
color: var(--drag-text);
padding: 5px;
}
#comfy-settings-dialog {
background: var(--bg-color);
}
#comfy-settings-dialog::-webkit-scrollbar {
margin-top: 0.5rem;
height: 1rem;
width: .6rem;
top: 10px;
background-color: transparent;
}
#comfy-settings-dialog::-webkit-scrollbar:horizontal {
height: .5rem;
width: 2.5rem
}
#comfy-settings-dialog::-webkit-scrollbar-track {
background-color: var(--comfy-input-bg);
border-radius: 9999px
}
#comfy-settings-dialog::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
background-color: var(--border-color);
border: 0;
border-radius: 9999px;
}
#comfy-settings-dialog::-webkit-scrollbar-thumb:hover {
--tw-bg-opacity: 1;
background-color: var(--border-color);
}
.comfy-table td, .comfy-table th {
border: 0px solid var(--border-color);
padding: 8px;
}
`
async function api_get(url) {
var response = await api.fetchApi(url, { cache: "no-store" })
return await response.json()
}
function getDynamicCSSRule(selector, property) {
const styleId = 'dynamic-styles';
const customStyle = document.getElementById(styleId);
if (!customStyle) {
return null;
}
const existingRule = Array.from(customStyle.sheet.cssRules).find(rule => rule.selectorText === selector);
if (existingRule) {
return existingRule.style.getPropertyValue(property);
}
return null;
}
function addDynamicCSSRule(selector, property, value) {
const styleId = 'dynamic-styles';
let customStyle = document.getElementById(styleId);
if (!customStyle) {
customStyle = document.createElement('style');
customStyle.type = 'text/css';
customStyle.id = styleId;
document.head.appendChild(customStyle);
}
const existingRule = Array.from(customStyle.sheet.cssRules).find(rule => rule.selectorText === selector);
if (existingRule) {
existingRule.style.setProperty(property, value, 'important');
} else {
customStyle.sheet.insertRule(`${selector} { ${property}: ${value} !important; }`, customStyle.sheet.cssRules.length);
}
}
function addJavascriptVar(name, value) {
const scripta = document.createElement('script');
scripta.type = 'text/javascript';
scripta.innerHTML = "var "+name+" = "+value;
document.head.appendChild(scripta);
}
function getVar(name) {
var varValue = localStorage.getItem(name);
if (varValue === null) {
if (name=="pinnedItems") {
//get cookie pinnedItems
var cookies = document.cookie.split('; ');
cookies.forEach(cookie => {
const [cookieName, cookieValue] = cookie.split('=');
if (cookieName === name) {
setVar(name, cookieValue)
}
})
//delete cookie pinnedItems
document.cookie = name + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}
else {
return null;
}
}else {
return varValue;
}
return null;
}
function setVar(name, value) {
/*migration to localstorage*/
localStorage.setItem(name, value);
}
function removeVar(name) {
localStorage.removeItem(name);
}
function renameVar(oldName, newName) {
const oldValue = getVar(oldName);
setVar(newName, oldValue);
removeVar(oldName);
}
function getElementPosition(element) {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left
};
}
function reverseAndAppend(originalName) {
let words;
let lastWord;
let reversedWords;
let reversedString;
let finalName;
if (originalName.includes(' ')) {
const words = originalName.split(' ');
lastWord = words.pop();
reversedWords = words.reverse();
reversedString = reversedWords.join(' ');
finalName = originalName + ' ' + reversedString;
return finalName;
} else {
words = originalName.split(/(?=[A-Z])/);
lastWord = words.pop();
reversedWords = words.reverse();
reversedString = reversedWords.join('');
finalName = originalName + reversedString;
return finalName;
}
}
function cleanText(text) {
let cleanedText = text.replace(/[^a-zA-Z0-9\s\n\-]/g, '');
cleanedText = cleanedText.replace(/\s+/g, ' ');
cleanedText = cleanedText.replace(/\n/g, '');
return cleanedText;
}
function calcSBTop() {
let sb = document.getElementById('sidebar');
let sbtop = window.getComputedStyle(sb).getPropertyValue('top').replace('px', '');
if (getVar('Comfy.Settings.Comfy.UseNewMenu') === '"Top"') {
return parseInt(sbtop) + 35;
}
else{
return sbtop;
}
}
//CONTEXT MENU
function createContextMenu(event, subMenus,settingsData) {
event.preventDefault();
hideAllSubMenus();
const sbcontextMenu = document.getElementById('customMenu');
const menuOptions = document.getElementById('menu-options');
// Clear existing menu items
menuOptions.innerHTML = '';
// Populate main options
settingsData.menuctx_options.forEach((option, index) => {
const li = document.createElement('li');
li.textContent = option;
li.addEventListener('mouseenter', () => {
hideAllSubMenus();
showSubMenu(subMenus[index], li);
});
try {
if (settingsData.menuctx_opt_callback[index].indexOf(".") !== -1) {
// Handle sub menu option
var mainfunc = settingsData.menuctx_opt_callback[index].split(".")[0];
var subfunc = settingsData.menuctx_opt_callback[index].split(".")[1];
if (typeof (window[mainfunc][subfunc]) === 'function') {
li.addEventListener('click', () => {
// Handle sub menu click
window[mainfunc][subfunc](event);
});
}
}
else{
if (typeof (window[settingsData.menuctx_opt_callback[index]]) === 'function') {
li.addEventListener('click', () => {
// Handle main option click
window[settingsData.menuctx_opt_callback[index]](event);
});
}
}
} catch (error) {
console.log(error);
}
menuOptions.appendChild(li);
});
// Show context menu
sbcontextMenu.style.display = 'block';
let sbtop = calcSBTop();
sbcontextMenu.style.top = (event.clientY - sbtop) + 'px';
if (sbPosition === "left") {
sbcontextMenu.style.left = event.clientX + 'px';
} else {
// const sidebar_width = getVar("sidebarWidth");
//console.log(parseInt(sidebar_width))
//console.log(event)
//console.log(event.clientX - parseInt(sidebar_width) )
//temp bugfix
sbcontextMenu.style.left = event.layerX + 'px';
}
// Hide context menu on body click
document.body.addEventListener('click', hideContextMenu);
}
function showSubMenu(subMenu, element) {
try{
if (subMenu) {
subMenu.style.display = 'block';
const rect = element.getBoundingClientRect();
subMenu.style.top = element.offsetTop + 'px';
subMenuVisible = true;
}
} catch (error) {
console.log(error);
}
}
function hideAllSubMenus() {
const subMenus = document.querySelectorAll('.sub-menu');
subMenus.forEach(subMenu => {
subMenu.style.display = 'none';
});
subMenuVisible = false;
}
function hideContextMenu() {
const sbcontextMenu = document.getElementById('customMenu');
sbcontextMenu.style.display = 'none';
document.body.removeEventListener('click', hideContextMenu);
}
let subMenuVisible = false;
let lastTargetClicked = null;
async function setContextMenu(settingsData,class_menu_item,main=0,exluded_class=null,included_class = null) {
if (main==1){
settingsData.menuctx_options = settingsData.menuctx_options.concat(new_menu_options);
settingsData.menuctx_subOptions = settingsData.menuctx_subOptions.concat(new_submenu_options);
settingsData.menuctx_opt_callback = settingsData.menuctx_opt_callback.concat(new_menu_options_callback);
settingsData.menuctx_sub_opt_callback = settingsData.menuctx_sub_opt_callback.concat(new_submenu_options_callback);
}
const sbcontextMenu = document.getElementById('customMenu');
const menuOptions = document.createElement("ul");
menuOptions.id = "menu-options";
sbcontextMenu.appendChild(menuOptions);
const subMenus = [];
for (let i = 0; i < settingsData.menuctx_subOptions.length; i++) {
const subMenu = document.createElement("ul");
subMenu.id = `sub-menu-op${i + 1}`;
subMenu.classList.add("sub-menu");
if (settingsData.menuctx_subOptions[i].length == 0) {
subMenu.classList.add("cat_empty");
}
sbcontextMenu.appendChild(subMenu);
subMenus.push(subMenu);
for (let j = 0; j < settingsData.menuctx_subOptions[i].length; j++) {
const li = document.createElement("li");
li.textContent = settingsData.menuctx_subOptions[i][j];
subMenu.appendChild(li);
try {
if (settingsData.menuctx_sub_opt_callback[i][j].indexOf(".") !== -1) {
// Handle sub menu option
var mainfunc = settingsData.menuctx_sub_opt_callback[i][j].split(".")[0];
var subfunc = settingsData.menuctx_sub_opt_callback[i][j].split(".")[1];
if (typeof (window[mainfunc][subfunc]) === 'function') {
li.addEventListener('click', () => {
// Handle sub menu click
window[mainfunc][subfunc](lastTargetClicked,settingsData.menuctx_subOptions[i][j]);
});
}
}
else{
if (typeof (window[settingsData.menuctx_sub_opt_callback[i][j]]) === 'function') {
li.addEventListener('click', () => {
// Handle main option click
window[settingsData.menuctx_sub_opt_callback[i][j]](lastTargetClicked,settingsData.menuctx_subOptions[i][j]);
});
}
}
} catch (error) {
console.log(error);
}
}
}
const sidebarItems = document.querySelectorAll(class_menu_item);
//const sb =document.getElementById('panel_home');
for (const sidebarItem of sidebarItems) {
sidebarItem.addEventListener('contextmenu', (event) => {
if(!event.target.classList.contains(exluded_class)){
if (included_class != null && !event.target.classList.contains(included_class)){
return;
}
createContextMenu (event, subMenus,settingsData);
lastTargetClicked = event;
}
});
}
//ContextMenu
//sb.addEventListener("contextmenu", function(event) {
// event.preventDefault();
//
// const customMenu = document.getElementById("customMenu");
// customMenu.style.display = "block";
// customMenu.style.left = event.pageX + "px";
// customMenu.style.top = event.pageY + "px";
//});
//
window.addEventListener("click", function(event) {
const customMenu = document.getElementById("customMenu");
customMenu.style.display = "none";
});
}
// Function to check for special characters
function containsSpecialCharacters(name, level=0) {
// Define a regular expression pattern to match special characters
if (level == 0) {
var specialCharacters = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
} else if (level == 1) {
var specialCharacters = /[\*':"\\|,<>\/?]/g;
} else{
var specialCharacters = /['"]/gi;
}
// Return true if the name contains any special characters, false otherwise
return specialCharacters.test(name);
}
async function reloadCtxMenu() {
//load folder name
var nameRequest = await fetch('sidebar/current');
var nameFolder = await nameRequest.json();
const cnPath = `../extensions/${nameFolder}/`
const response3 = await fetch(cnPath +'settings.json');
const settingsData = await response3.json();
setContextMenu(settingsData,"#panel_home .sidebarItem",1);
}
function setNodeStatus(varname,node,status) {
try{//'sb_categoryNodeStatus'
const categoryNodeStatus = JSON.parse(localStorage.getItem(varname)) || {};
categoryNodeStatus[node] = [status];
localStorage.setItem(varname, JSON.stringify(categoryNodeStatus));
}catch(err){ }
}
function getNodeStatus(varname,node) {
try{
const categoryNodeStatus = JSON.parse(localStorage.getItem(varname)) || {};
return categoryNodeStatus[node] || "none";
}catch(err){ }
return "none";
}
function getNodesStatus() {
let categoryNodeStatus = JSON.parse(localStorage.getItem(varname)) || {};
return categoryNodeStatus;
}
// MODAL
function setModalContext(addon=null) {
if (addon==null) {
return `
<h2 class="sb-modal-title">Add New Category</h2>
<form id="sb-category-form" action="javascript:void(0);">
<input type="text" autocomplete="off" class="sb-input" id="sb-categoryName" name="sb-categoryName" placeholder="Category Name" required>
<button type="submit" class="sb-button" onclick="custom_categories.addCategory()">Add Category</button>
</form>
<button id="closeModalButton" class="sb-button">X</button>
`;
}
else {
return addon
}
}
function openModal(addon=null) {
// Create HTML elements for the modal
const modalBackdrop = document.createElement('div');
modalBackdrop.classList.add('sb-modal-backdrop');
const modal = document.createElement('div');
modal.classList.add('sb-category-dialog');
modal.innerHTML = setModalContext(addon)
// Add the modal to the DOM
document.body.appendChild(modalBackdrop);
document.body.appendChild(modal);
// Add an event listener for the close button
const closeModalButton = modal.querySelector('#closeModalButton');
closeModalButton.addEventListener('click', closeModal);
try{
// Focus the input field
inputElement = document.getElementById('sb-categoryName');
inputElement.focus();
const length = inputElement.value.length;
inputElement.setSelectionRange(length, length);
}catch(err){
}
}
// Function to close the modal
function closeModal() {
const modalBackdrop = document.querySelector('.sb-modal-backdrop');
const modal = document.querySelector('.sb-category-dialog');
try{
// Remove the modal from the DOM
modalBackdrop.parentNode.removeChild(modalBackdrop);
modal.parentNode.removeChild(modal);
}
catch(err){
console.log(err);
}
}
function rgbToHex(rgb) {
// Verifica se il colore è nel formato "rgb()"
if (rgb.indexOf('rgb') !== -1) {
// Esegue il parsing dei valori di rosso, verde e blu
const [r, g, b] = rgb.match(/\d+/g);
// Converte i valori in esadecimale e li concatena con "#"
return `#${parseInt(r, 10).toString(16).padStart(2, '0')}${parseInt(g, 10).toString(16).padStart(2, '0')}${parseInt(b, 10).toString(16).padStart(2, '0')}`;
}
return rgb; // Se il colore è già nel formato con "#", lo restituisce direttamente
}
function hexToRgb(hex) {
try{
// Rimuovi il carattere # se presente
hex = hex.replace(/^#/, '');
// Estrai i valori R, G e B
let r = parseInt(hex.substring(0, 2), 16);
let g = parseInt(hex.substring(2, 4), 16);
let b = parseInt(hex.substring(4, 6), 16);
// Restituisci il colore in formato RGB
return `${r}, ${g}, ${b}`;
}catch(err){
console.log(err)
return "#000000";
}
}
//click anywhere outside the modal to close it
document.body.addEventListener('click', function(event) {
const modalBackdrop = document.querySelector('.sb-modal-backdrop');
const modal = document.querySelector('.sb-category-dialog');
if (event.target === modalBackdrop) {
closeModal();
}
})
function showSettings() {
const sb_settingsDiv = document.getElementById("sb_settingsDiv");
const sb_modal_backdrop = document.getElementById("sb-modal-backdrop-settings");
sb_settingsDiv.classList.remove('sb_hidden');
sb_modal_backdrop.classList.remove('sb_hidden');
}
function showIESettings(operation) {
const sb_settingsDiv = document.getElementById("sb_settingsDiv_"+operation);
sb_settingsDiv.classList.remove('sb_hidden');
}
function isBottomEdgeVisible(el) {
var rect = el.getBoundingClientRect();
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
if (rect.bottom <= viewportHeight) {
return true;
}
return false;
}
function convertCanvasToOffset(canvas, pos, out) {
out = out || [0, 0];
out[0] = pos[0] / canvas.scale - canvas.offset[0];
out[1] = pos[1] / canvas.scale - canvas.offset[1];
return out;
};
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
@@ -1,128 +0,0 @@
<div id="tabContainer" class="n-sb-tab-container">
<div id="tabSettings2" class="n-sb-tab n-sb-active">Explore</div>
<div id="tabCustomize2" class="n-sb-tab" onclick="assets_downloader.loadLibrary();">My Library</div>
</div>
<div id="t1" class="n-sb-active">
<div id="assets_downloader_header">
<div class="h-header" id="h_search" data-content="panel_head_home">
<!-- Search Input -->
<div class="search-row">
<input type="text" id="searchTemplateInput" class="n-sb-searchmodels"
oninput="assets_downloader.searchT()" placeholder="Search..." autocomplete="off" class="">
</div>
<div class="filters-row">
<!-- NSFW Filter -->
<select id="nsfwFilter" onchange="assets_downloader.searchT()">
<option value="true">NSFW</option>
<option value="false" selected>SFW</option>
</select>
<!-- Type Filter -->
<select id="typeFilter" data-placeholder="Type(s)" multiple data-multi-select>
<option value="Checkpoint">Checkpoint</option>
<option value="TextualInversion">TextualInversion</option>
<option value="Hypernetwork">Hypernetwork</option>
<option value="AestheticGradient">AestheticGradient</option>
<option value="LORA">LORA</option>
<option value="LoCon">LoCon</option>
<option value="DoRA">DoRA</option>
<option value="Controlnet">Controlnet</option>
<option value="Upscaler">Upscaler</option>
<option value="MotionModule">MotionModule</option>
<option value="VAE">VAE</option>
<option value="Poses">Poses</option>
<option value="Wildcards">Wildcards</option>
<option value="Workflows">Workflows</option>
<option value="Other">Other</option>
</select>
<!-- Sort Option -->
<select id="sortOption" onchange="assets_downloader.searchT()">
<option value="">Sort By</option>
<option value="Highest Rated">Rates</option>
<option value="Most Downloaded">Downloads</option>
<option value="Newest">Newest</option>
</select>
<!-- Period Option -->
<select id="periodOption" onchange="assets_downloader.searchT()">
<option value="">All Time</option>
<option value="Year">Year</option>
<option value="Month">Month</option>
<option value="Week">Week</option>
<option value="Day">Day</option>
</select>
</div>
</div>
</div>
<div id="assets_downloader_main">
<div class="result-container" id="results-container">
<!-- Results will be injected here -->
</div>
</div>
</div>
<div id="t2">
<div class="sb-mymodels">
<div id="assets_downloader_header">
<div class="h-header" id="h_search" data-content="panel_head_home">
<!-- Search Input -->
<div class="search-row">
<input type="text" id="searchLibraryInput" class="n-sb-searchmodels"
oninput="assets_downloader.searchInLibrary()" placeholder="Search..." autocomplete="off"
class="">
<div class="sb-kebab-menu">
<div class="sb-kebab-icon" onclick="toggleMenu()">⋮</div>
<ul id="sb-menu" class="sb-menu">
<li id="sb-rebuild" onclick="assets_downloader.rebuildLibrary(0)">Rebuild Library</li>
<li id="sb-rebuild" onclick="assets_downloader.rebuildLibrary(1)">Rebuild Library From Zero</li>
</ul>
</div>
</div>
<div class="search-row">
<div class="sb-rebuild-bg-pgs">
<label>Building library...</label>
<div class="sb-rebuild-pgs"></div>
</div>
</div>
<div class="filters-row">
<!-- NSFW Filter -->
<select id="nsfwFilterLibrary" onchange="assets_downloader.searchInLibrary()">
<option value="true">NSFW</option>
<option value="false" selected>SFW</option>
</select>
<!-- Type Filter -->
<select id="typeFilterLibrary" data-placeholder="Type(s)" multiple data-library-multi-select>
<option value="Checkpoint">Checkpoint</option>
<option value="TextualInversion">TextualInversion</option>
<option value="Hypernetwork">Hypernetwork</option>
<option value="AestheticGradient">AestheticGradient</option>
<option value="LORA">LORA</option>
<option value="LoCon">LoCon</option>
<option value="DoRA">DoRA</option>
<option value="Controlnet">Controlnet</option>
<option value="Upscaler">Upscaler</option>
<option value="MotionModule">MotionModule</option>
<option value="VAE">VAE</option>
<option value="Poses">Poses</option>
<option value="Wildcards">Wildcards</option>
<option value="Workflows">Workflows</option>
<option value="Other">Other</option>
</select>
<!-- Sort Option -->
<select id="sortOptionLibrary" onchange="assets_downloader.searchInLibrary()">
<option value="">Sort By</option>
<option value="az">A-z</option>
<option value="za">Z-a</option>
<option value="Newest">Newest</option>
</select>
</div>
</div>
</div>
<div id="library-container"></div>
</div>
</div>
File diff suppressed because it is too large Load Diff
-761
View File
@@ -1,761 +0,0 @@
#assets_downloader_main,#assets_downloader_header,.sb-mymodels {
justify-content: center;
align-items: center;
height: 44px;
margin: 0;
}
#assets_downloader_main, .sb-mymodels {
height: auto;
padding-top: 0px;
padding-bottom: 150px;
}
#assets_downloader_header {
margin: 10px 0px;
font-size: 16px;
}
/* General styles for the header */
#assets_downloader_header {
display: flex;
align-items: center;
padding: 10px;
}
#assets_downloader_header .h-header {
display: flex;
align-items: center;
flex: 1;
position: relative;
width: 100%;
flex-direction: column;
}
.n-sb-searchmodels {
box-sizing: border-box;
width: 100%;
border-radius: 5px;
padding: 10px;
border: none;
user-select: none;
background: var(--comfy-input-bg);
color: var(--input-text);
line-height: 1.4;
border: 1px solid var(--border-color);
padding: 10px;
font-size: 16px;
border-radius: 4px;
margin-right: 10px;
}
#assets_downloader_header select {
padding: 10px;
font-size: 16px;
width : 145px;
border-radius: 4px;
border-color: var(--comfy-input-bg);
background: var(--comfy-input-bg);
}
/* Main container styles */
#assets_downloader_main {
padding: 4px;
}
/* Styles for the results container */
#results-container {
display: flex;
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically */
height: 100%; /* Ensure the container has defined height */
min-height: 200px; /* Minimum height for the container, adjust if needed */
position: relative;
}
/* Styles for the loading icon */
.pi-spinner-dotted {
font-size: 2rem; /* Size of the loading icon */
}
.result-container,#library-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.result-card {
background-color: var(--bg-color);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 287px;
padding: 8px;
height: 372px;
text-align: center;
position: relative;
}
.result-card img,
.result-card video {
max-width: 100%;
min-width: 100%;
height: 60%;
width: 100%;
border-radius: 8px;
/* height: auto; */
object-fit: cover;
object-position: center center;
transition: transform 400ms;
}
.result-card h3 {
margin: 10px 0;
font-size: 18px;
}
.result-card p {
color: #555;
margin: 5px 0;
}
.result-card .username {
font-weight: bold;
color: #007bff;
}
.download-button {
display: inline-block;
margin-top: 10px;
padding: 10px 20px;
background-color: #007bff;
color: #fff;
text-decoration: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
}
.download-button:hover {
background-color: #0056b3;
}
/* multiselect from: https://codeshack.io/multi-select-dropdown-html-javascript/ */
.multi-select {
display: flex;
box-sizing: border-box;
flex-direction: column;
position: relative;
width: 100%;
user-select: none;
}
.multi-select .multi-select-header {
border: 1px solid #dee2e6;
padding: 7px 30px 7px 12px;
overflow: hidden;
gap: 7px;
min-height: 43px;
}
.multi-select .multi-select-header::after {
content: "";
display: block;
position: absolute;
top: 50%;
right: 15px;
transform: translateY(-50%);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23949ba3' viewBox='0 0 16 16'%3E%3Cpath d='M8 13.1l-8-8 2.1-2.2 5.9 5.9 5.9-5.9 2.1 2.2z'/%3E%3C/svg%3E");
height: 12px;
width: 12px;
}
.multi-select .multi-select-header.multi-select-header-active {
border-color: var(--comfy-input-bg);
background: var(--comfy-input-bg);
}
.multi-select .multi-select-header.multi-select-header-active::after {
transform: translateY(-50%) rotate(180deg);
}
.multi-select .multi-select-header.multi-select-header-active + .multi-select-options {
display: flex;
}
.multi-select .multi-select-header .multi-select-header-placeholder {
color: var(--content-fg);
}
.multi-select .multi-select-header .multi-select-header-option {
display: inline-flex;
align-items: center;
background-color: var(--comfy-menu-bg);
font-size: 14px;
padding: 3px 8px;
border-radius: 4px;
text-wrap: nowrap;
}
.multi-select .multi-select-header .multi-select-header-max {
font-size: 14px;
color: var(--content-fg);
}
.multi-select .multi-select-options {
display: none;
box-sizing: border-box;
flex-flow: wrap;
position: absolute;
top: 100%;
/* left: 0; */
right: 0;
z-index: 999;
margin-top: 5px;
/* padding: 5px; */
background-color: var(--bg-color);
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
/* max-height: 200px; */
overflow-y: auto;
overflow-x: hidden;
width: fit-content;
}
.multi-select .multi-select-options::-webkit-scrollbar {
width: 5px;
}
.multi-select .multi-select-options::-webkit-scrollbar-track {
background: #f0f1f3;
}
.multi-select .multi-select-options::-webkit-scrollbar-thumb {
background: #cdcfd1;
}
.multi-select .multi-select-options::-webkit-scrollbar-thumb:hover {
background: #b2b6b9;
}
.multi-select .multi-select-options .multi-select-option, .multi-select .multi-select-options .multi-select-all {
padding: 4px 12px;
height: 42px;
}
.multi-select .multi-select-options .multi-select-option .multi-select-option-radio, .multi-select .multi-select-options .multi-select-all .multi-select-option-radio {
margin-right: 14px;
height: 16px;
width: 16px;
border: 1px solid #ced4da;
border-radius: 4px;
}
.multi-select .multi-select-options .multi-select-option .multi-select-option-text, .multi-select .multi-select-options .multi-select-all .multi-select-option-text {
box-sizing: border-box;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
font-size: 16px;
line-height: 20px;
}
.multi-select .multi-select-options .multi-select-option.multi-select-selected .multi-select-option-radio, .multi-select .multi-select-options .multi-select-all.multi-select-selected .multi-select-option-radio {
border-color: var(--border-color);
background-color: var(--bg-color);
}
.multi-select .multi-select-options .multi-select-option.multi-select-selected .multi-select-option-radio::after, .multi-select .multi-select-options .multi-select-all.multi-select-selected .multi-select-option-radio::after {
content: "";
display: block;
width: 3px;
height: 7px;
margin: 0.12em 0 0 0.27em;
border: solid #fff;
border-width: 0 0.15em 0.15em 0;
transform: rotate(45deg);
}
.multi-select .multi-select-options .multi-select-option.multi-select-selected .multi-select-option-text, .multi-select .multi-select-options .multi-select-all.multi-select-selected .multi-select-option-text {
color: var(--content-fg);
}
.multi-select .multi-select-options .multi-select-option:hover, .multi-select .multi-select-options .multi-select-option:active, .multi-select .multi-select-options .multi-select-all:hover, .multi-select .multi-select-options .multi-select-all:active {
background-color: var(--content-bg);
border-radius: 0;
}
.multi-select .multi-select-options .multi-select-all {
border-bottom: 1px solid #f1f3f5;
border-radius: 0;
}
.multi-select .multi-select-options .multi-select-search {
padding: 7px 10px;
border: 1px solid #dee2e6;
border-radius: 5px;
margin: 10px 10px 5px 10px;
width: 100%;
outline: none;
font-size: 16px;
}
.multi-select .multi-select-options .multi-select-search::placeholder {
color: #b2b5b9;
}
.multi-select .multi-select-header, .multi-select .multi-select-option, .multi-select .multi-select-all {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
align-items: center;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
width: 100%;
font-size: 16px;
color: var(--content-fg);
border-color: var(--comfy-input-bg);
background: var(--comfy-input-bg);
min-width: max-content;
}
#t1, #t2{
display: none;
}
#assets_downloader_header {
display: flex;
flex: 1;
position: relative;
width: calc(100% - 5px);
flex-direction: column;
justify-content: space-between;
align-items: center;
height: auto;
margin-top: 10px;
}
#assets_downloader_header input:focus, #assets_downloader_header select:focus, .multi-select .multi-select-header:active {
outline: var(--border-color) solid 2px;
}
#assets_downloader_main a, .sb-mymodels a {
text-decoration: none;
color: #fff;
}
.search-row {
width: 100%;
display: flex;
justify-content: center;
}
.n-sb-searchmodels {
padding: 10px;
font-size: 16px;
border-radius: 4px;
width: 100%; /* Usa tutta la larghezza disponibile nella riga */
max-width: 100%; /* Nessun limite alla larghezza massima */
}
.type-checkpoint {
position: absolute;
top: 0;
right: 0px;
padding: 5px;
background: var(--content-fg);
border-radius: 5px;
margin: 10px 10px 0px 0px !important;
opacity: 0.7;
text-transform: uppercase;
font-weight: bold;
font-size: smaller;
}
#assets_downloader_main .stats, .sb-mymodels .stats {
position: absolute;
bottom: 0;
background: #404040;
padding: 5px;
border-radius: 5px;
color: var(--content-fg);
right: 3px;
}
#assets_downloader_main .username, .sb-mymodels .username {
position: absolute;
bottom: 0px;
right: 0;
margin: 5px;
background: var(--comfy-menu-bg);
padding: 6px;
border-radius: 5px;
}
/* Filtri */
.filters-row {
width: 100%;
max-width: 600px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 3px;
margin-right: 15px;
}
#t1 .filters-row > * {
flex: 1 1 22%;
/* min-width: 120px; */
}
/* Stili per le selezioni */
#assets_downloader_header select {
padding: 10px;
font-size: 16px;
border-radius: 4px;
width: 100%;
max-width: 150px;
height: 42px;
margin-top: 10px;
}
/* Multi-select */
#assets_downloader_header .multi-select {
width: 145px; /* Larghezza massima per il multi-select */
margin-top: 10px;
}
/* Adattamento responsive */
@media (max-width: 768px) {
.filters-row {
flex-direction: column; /* I filtri si dispongono in colonna su schermi più piccoli */
align-items: center; /* Centratura orizzontale dei filtri su schermi più piccoli */
}
select, .multi-select,.n-sb-searchmodels {
width: 100%; /* Usa tutta la larghezza disponibile */
max-width: 100%; /* Nessun limite alla larghezza massima */
}
}
.sb-kebab-menu {
position: relative;
display: inline-block;
color: var(--content-fg);
margin-right: 10px;
display: flex;
align-items: center;
}
.sb-kebab-icon {
font-size: 24px;
cursor: pointer;
}
.sb-menu {
display: none;
position: absolute;
right: 0;
background-color:var(--bg-color);
box-shadow: 0px 8px 16px rgba(0,0,0,0.2);
list-style-type: none;
padding: 10px 0;
margin: 0;
border-radius: 4px;
z-index: 1;
width: max-content !important;
}
.sb-menu li {
padding: 8px 16px;
cursor: pointer;
}
.sb-menu li:hover {
background-color: var(--comfy-menu-bg);
}
.sb-rebuild-bg-pgs {
text-align: left;
width: inherit;
padding-right: 28px;
display: none;
}
.sb-rebuild-bg-pgs label {
font-size: 0.6rem;
line-height: 1.6;
position: absolute;
padding-left: 5px;
}
.sb-rebuild-pgs {
width: 0%;
background: #1da874a6;
border-radius: 1px;
height: 16px;
font-size: 0.7vh;
text-align: center;
animation: pulsate 1.5s ease-in-out infinite;
}
@keyframes pulsate {
0% {
background-color: #1da874a6; /* Colore iniziale */
}
50% {
background-color: #28a745; /* Colore più brillante o diverso */
}
100% {
background-color: #1da874a6; /* Torna al colore iniziale */
}
}
#assets_downloader_main .card, .sb-mymodels .card {
position: relative;
width: 220px;
height: 340px;
overflow: hidden;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background-size: cover;
background-position: center;
/*box-shadow: 0 0 7px 0px rgb(170 133 32 / 70%); */
cursor: pointer;
}
#assets_downloader_main .card img , .sb-mymodels .card img {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
#assets_downloader_main .header,#assets_downloader_main .footer , .sb-mymodels .header, .sb-mymodels .footer {
position: absolute;
width: 100%;
left: 0;
z-index: 2;
text-align: left;
color: white;
padding: 10px;
}
#assets_downloader_main .header, .sb-mymodels .header {
top: 0;
text-align: left;
width: -moz-available;
width: -webkit-fill-available;
}
#assets_downloader_main .footer, .sb-mymodels .footer {
bottom: 0;
}
#assets_downloader_main .content, .sb-mymodels .content {
/*position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
text-align: center;*/
color: white;
}
#assets_downloader_main .card .header .main-stripe, .sb-mymodels .card .header .main-stripe {
box-sizing: border-box;
-webkit-box-flex: 0;
}
#assets_downloader_main .card .header .sub-stripe, .sb-mymodels .card .header .sub-stripe, #assets_downloader_main .card .footer .sub-stripe, .sb-mymodels .card .footer .sub-stripe {
display: inline-flex;
-webkit-box-align: center;
align-items: center;
-webkit-box-pack: center;
justify-content: center;
width: auto;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.25px;
cursor: inherit;
text-overflow: ellipsis;
overflow: hidden;
background: rgba(0, 0, 0, 0.51);
border: 1px solid transparent;
color: rgb(254, 254, 254);
border-radius: 32px;
height: 26px;
font-size: 9px;
line-height: 18px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
text-decoration: none;
padding: 0px 10.6667px;
box-sizing: border-box;
}
#assets_downloader_main .card .header .main-stripe .separator, .sb-mymodels .card .header .sub-stripe .separator {
margin: -4px 8px;
border-left-color: rgba(255, 255, 255, 0.31);
border-right: 1px solid rgba(0, 0, 0, 0.2);
border-left-width: 1px;
border-left-style: solid;
}
#assets_downloader_main .card .card-main-text, .sb-mymodels .card .card-main-text {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
-webkit-tap-highlight-color: transparent;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
color: inherit;
font-size: 1.5vh;
text-decoration: none;
font-weight: 700;
filter: drop-shadow(rgba(0, 0, 0, 0.8) 1px 1px 1px);
line-height: 1.2;
margin-bottom: 5px;
}
#assets_downloader_main .stats-section, .sb-mymodels .stats-section{
-webkit-tap-highlight-color: transparent;
font-size: 11px;
line-height: 18px;
text-decoration: none;
padding: 0px 10.6667px;
box-sizing: border-box;
display: inline-flex;
-webkit-box-align: center;
align-items: center;
width: auto;
font-weight: 700;
letter-spacing: 0.25px;
cursor: inherit;
text-overflow: ellipsis;
overflow: hidden;
background: rgb(0 0 0 / 45%);
border: 1px solid transparent;
align-self: flex-start;
color: rgb(254, 254, 254);
border-radius: 32px;
height: 26px;
}
#assets_downloader_main .card .creator-name, .sb-mymodels .card .creator-name{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
-webkit-tap-highlight-color: transparent;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
color: #ffffff;
font-size: 0.70vw;
line-height: 1.55;
/* text-decoration: none; */
font-weight: 500;
/* background-clip: text; */
/* -webkit-text-fill-color: transparent; */
vertical-align: middle;
filter: drop-shadow(rgba(0, 0, 0, 0.8) 3px 1px 0px);
text-align: left;
padding: 0;
margin: 0px;
}
.card .model-image {
transition: transform 0.3s ease-in-out;
}
/* .card img:hover, .image-overlay:hover{
transform: scale(1.05);
}*/
.card .stat-item i {
padding: 0 5px;
}
/* Overlay con effetto vetro */
.card .image-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgb(14 14 14 / 0%) 0%, rgb(47 47 58 / 79%) 100%);
display: flex;
justify-content: center;
align-items: center;
color: white;
font-size: 24px;
text-align: center;
z-index: 99;
}
.card .progress-container {
width: 100%;
height: 100%;
background-color: #e0e0e0;
position: relative;
}
.card .progress-bar {
z-index: 98;
height: 100%;
background-color: #76c7c04f;
width: 0%;
transition: width 0.3s;
position: absolute;
}
.card .sub-stripe-status{
font-size: 1rem;
background: #00000073;
padding: 3px 10px;
border-radius: 11px;
display: none;
}
.sub-stripe-manager {
display: flex;
flex-direction: column; /* I div figli saranno disposti verticalmente */
align-items: flex-start; /* Allineamento a sinistra */
}
.sub-stripe-manager div {
padding: 2px 9px;
transition: filter 0.3s ease;
}
.sub-stripe-manager div:hover {
filter: brightness(180%); /* Aumenta la luminosità del 10% */
}
.sub-stripe-manager{
position: absolute;
right: 28px;
top: 0;
right: 0;
}
.card .sub-stripe-dw{
background: #ff4141;
border-bottom-left-radius: 6px;
}
.card .sub-stripe-ps{
background: #ff6720;
display: none;
filter: brightness(80%);
}
.card .sub-stripe-cn{
background: #ff4141;
border-bottom-left-radius: 6px;
display: none;
filter: brightness(80%);
}
.card .sub-stripe-complete{
background: #00c027;
display: none;
}
.card .sub-stripe-delete{
background: #ff4141;
border-bottom-left-radius: 6px;
display: none;
}
.sb-pi-toast-center {
padding-top: 5px;
}
@@ -1,11 +0,0 @@
<div id ="custom_categories_header">
<div class="h-header" id="h_search" data-content="panel_head_home">
<input type="text" id="searchInputCn" oninput="custom_categories.searchT(this)" placeholder="Search..." autocomplete="off" class="">
<span class="clearIconCustomCategory">×</span>
<span class="searchCategoryIconCustomCategory" title="Category Only">C</span>
</div>
<button id="openModalButton" onclick="custom_categories.openModal()" class="left" data-tooltip="Add New Category" >+</button>
</div>
<div id="custom_categories_main" >
</div>
@@ -1,817 +0,0 @@
//console.log(LiteGraph.registered_node_types);
var custom_categories = (function () {
let currentElement = null;
function createCustomHtml(type, name, elem = null) {
if (type == "rename") {
return `<h2 class="sb-modal-title">Rename Category</h2>
<form id="sb-category-form" action="javascript:void(0);">
<input type="text" autocomplete="off" class="sb-input" id="sb-categoryName" value="${name}" name="sb-categoryName" required>
<button type="submit" class="sb-button" onclick="custom_categories.renameCategory('${name}',document.getElementById('sb-categoryName').value)">Rename Category</button>
</form>
<button id="closeModalButton" class="sb-button">X</button>
`
} else if (type == "color") {
currentElement = elem;
return `<h2 class="sb-modal-title">Color ${name}</h2>
<div class="colorBlob">
<input class="colorBlobPicker" type="color" oninput="custom_categories.colorCategory('${name}',this.value)" value="${rgbToHex(elem.style.backgroundColor)}" id="colorPicker">
</div>
<button type="button" class="sb-button" onclick="custom_categories.resetColorCategory('${name}')">Reset Color</button>
<button type="button" class="sb-button" onclick="custom_categories.removeKeyConfig('sb_ColorCustomCategories') ">Reset All Colors</button>
<button id="closeModalButton" class="sb-button">X</button>
`
}
}
// Function to open the modal
//insert button in label with id panel_title_custom_categories
let categorySearchToggleCustom = false;
const panel_title_custom_categories = document.getElementById("panel_title_custom_categories");
const button_custom_categories = document.createElement("button");
button_custom_categories.classList = "expand_node";
button_custom_categories.title = "Expand/Collapse";
button_custom_categories.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" enable-background="new 0 0 52 52" xml:space="preserve">
<path d="M48,9.5C48,8.7,47.3,8,46.5,8h-41C4.7,8,4,8.7,4,9.5v3C4,13.3,4.7,14,5.5,14h41c0.8,0,1.5-0.7,1.5-1.5V9.5z"></path>
<path d="M48,39.5c0-0.8-0.7-1.5-1.5-1.5h-41C4.7,38,4,38.7,4,39.5v3C4,43.3,4.7,44,5.5,44h41c0.8,0,1.5-0.7,1.5-1.5
V39.5z"></path>
<path d="M34.5,29c0.8,0,1.5-0.7,1.5-1.5v-3c0-0.8-0.7-1.5-1.5-1.5h-17c-0.8,0-1.5,0.7-1.5,1.5v3
c0,0.8,0.7,1.5,1.5,1.5H34.5z"></path>
<rect class="expand_node" style="opacity:0" x="0" y="0" width="52" height="52"></rect>
</svg>`;
panel_title_custom_categories.appendChild(button_custom_categories);
button_custom_categories.addEventListener("click", (event) => {
const clickedElement = event.target;
const pinButton = clickedElement.tagName;
if ((pinButton == "rect") && clickedElement.classList.contains("expand_node")) {
sdExpandAll();
}
})
const clearIconCustomCategory = document.querySelector(".clearIconCustomCategory");
const itemSearchInput = document.getElementById("searchInputCn");
clearIconCustomCategory.addEventListener("click", async function () {
try {
itemSearchInput.value = "";
renderList("custom_categories_main");
} catch (error) {
console.error("Error occurred during search:", error);
}
});
function afterRender() {
customMenu = `
{
"menuctx_category": "Context Menu",
"menuctx_options": [
"Rename Category",
"Delete Category",
"Change Color"
],
"menuctx_subOptions": [
[
]
],
"menuctx_opt_callback": [
"custom_categories.renameCategoryCallback",
"custom_categories.deleteCategory",
"custom_categories.colorCategoryCallback"
],
"menuctx_sub_opt_callback": [
[
],
[
]
]
}`
customMenuItem = `
{
"menuctx_category": "Context Menu",
"menuctx_options": [
"Delete from Category"
],
"menuctx_subOptions": [
[
]
],
"menuctx_opt_callback": [
"custom_categories.removeNodeFromCategoryCallback"
],
"menuctx_sub_opt_callback": [
[
],
[
]
]
}`
customMenuJSON = JSON.parse(customMenu);
customMenuItemJSON = JSON.parse(customMenuItem);
setContextMenu(customMenuJSON, "#custom_categories_main .sidebarCategory", 0, "sidebarItem", "sidebarCategory");
setContextMenu(customMenuItemJSON, "#custom_categories_main .sidebarItem");
// ORDER CUSTOM CATEGORIES
const custom_categories_main = document.getElementById("custom_categories_main");
custom_categories_main.querySelectorAll(".sidebarCategory").forEach(function (item) {
item.addEventListener("dragstart", function (event) {
dragItem = event.target;
});
});
custom_categories_main.addEventListener("dragover", function (event) {
event.preventDefault();
});
custom_categories_main.addEventListener("drop", function (event) {
event.preventDefault();
if (dragItem) {
if (dragItem.parentElement === event.target.parentElement) {
custom_categories_main.insertBefore(dragItem, event.target);
reorderCategories(getCustomCategoryOrder());
}
dragItem = null;
}
});
// ORDER CUSTOM NODES
custom_categories_main.querySelectorAll(".sidebarItem").forEach(function (item) {
item.addEventListener("dragstart", function (event) {
dragItem = event.target;
});
});
}
function getCustomCategoryOrder() {
let cat = [];
const custom_categories_main = document.getElementById("custom_categories_main");
custom_categories_main.querySelectorAll(".sidebarCategory").forEach(function (item) {
cat.push(item.dataset.namecategory)
});
return cat
}
function getCustomNodeOrder(idcat) {
let node = [];
const objMain = document.getElementById(idcat);
objMain.querySelectorAll(".sidebarItem").forEach(function (item) {
node.push(item.dataset.type)
});
return node
}
async function renderList(elementID) {
const data = LiteGraph.registered_node_types;
const myPromise = new Promise(async (resolve, reject) => {
const categories = await readCategories();
//const pinnedItems = loadPinnedItems();
//console.log(categories);
const sidebarCustomNodes = document.getElementById(elementID);
//clear elementid
sidebarCustomNodes.innerHTML = "";
for (const category in categories) {
//data[objKey].title.toLowerCase();
let categoryName = categories[category]
const categoryItem = document.createElement("li");
categoryItem.classList.add("sidebarCategory");
categoryItem.dataset.namecategory = categoryName;
categoryItem.textContent = "⌬ " + categoryName;
categoryItem.draggable = true;
const currentColor = await getValueFromConfig("sb_ColorCustomCategories", categoryName);
if (currentColor) {
const value_perc = await getConfiguration("sb_opacity") || "1.0";
const rgbColor = hexToRgb(currentColor);
categoryItem.style.setProperty('background-color', `rgba(${rgbColor}, ${value_perc})`, 'important');
//categoryItem.style.backgroundColor = `rgba(${rgbColor}, ${value_perc})`;
}
const displayNamesList = document.createElement("ul");
displayNamesList.id = categoryName + "_ul";
displayNamesList.style.display = getNodeStatus('sb_categoryNodeStatus',categoryName);
categoryItem.appendChild(displayNamesList);
let displayName = category;
const listNodeForCategory = await getNodesForCategory(categoryName);
listNodeForCategory.forEach(displayName => {
try {
const displayNameItem = document.createElement("li");
displayNameItem.classList.add("sidebarItem");
displayNameItem.textContent = data[displayName].title;
displayNameItem.title = data[displayName].title;
displayNameItem.draggable = true;
displayNameItem.dataset.type = data[displayName].type;
displayNameItem.id = displayName//.replace(" ", "_");
/* Create Pin Button
const pinButton = document.createElement("button");
pinButton.classList.add("pinButton");
let add_class = "";
if (pinnedItems.includes(displayNameItem.dataset.type)) {
add_class = "pinned";
}
pinButton.innerHTML = `<svg class="svg_class" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path class="pin_normal ${add_class}" d="M19.1835 7.80516L16.2188 4.83755C14.1921 2.8089 13.1788 1.79457 12.0904 2.03468C11.0021 2.2748 10.5086 3.62155 9.5217 6.31506L8.85373 8.1381C8.59063 8.85617 8.45908 9.2152 8.22239 9.49292C8.11619 9.61754 7.99536 9.72887 7.86251 9.82451C7.56644 10.0377 7.19811 10.1392 6.46145 10.3423C4.80107 10.8 3.97088 11.0289 3.65804 11.5721C3.5228 11.8069 3.45242 12.0735 3.45413 12.3446C3.45809 12.9715 4.06698 13.581 5.28476 14.8L6.69935 16.2163L2.22345 20.6964C1.92552 20.9946 1.92552 21.4782 2.22345 21.7764C2.52138 22.0746 3.00443 22.0746 3.30236 21.7764L7.77841 17.2961L9.24441 18.7635C10.4699 19.9902 11.0827 20.6036 11.7134 20.6045C11.9792 20.6049 12.2404 20.5358 12.4713 20.4041C13.0192 20.0914 13.2493 19.2551 13.7095 17.5825C13.9119 16.8472 14.013 16.4795 14.2254 16.1835C14.3184 16.054 14.4262 15.9358 14.5468 15.8314C14.8221 15.593 15.1788 15.459 15.8922 15.191L17.7362 14.4981C20.4 13.4973 21.7319 12.9969 21.9667 11.9115C22.2014 10.826 21.1954 9.81905 19.1835 7.80516Z" />
<rect class="pin_node" style="opacity:0" x="0" y="0" width="24" height="24" />
</svg>`;
displayNameItem.appendChild(pinButton);
/* End Pin Button */
displayNamesList.appendChild(displayNameItem);
displayNamesList.addEventListener("drop", function (event) {
event.preventDefault();
if (dragItem) {
if (dragItem.parentElement == event.target.parentElement) {
displayNamesList.insertBefore(dragItem, event.target);
reorderNodeInCategory(categoryItem.dataset.namecategory, getCustomNodeOrder(dragItem.parentElement.id))
}
dragItem = null;
}
});
}
catch (err) {
console.log(err);
}
})
try {
sidebarCustomNodes.appendChild(categoryItem);
} catch (err) {
console.log(err);
}
}
// Preview
const sidebarItems_cat = document.querySelectorAll('#' + elementID + ' .sidebarItem');
const previewDiv = document.getElementById('previewDiv');
let sidebad_view_width = document.getElementById("sidebar_views").offsetWidth;
sidebarItems_cat.forEach(item => {
getPreview(item,previewDiv,sidebad_view_width,(item) => {
return createNodePreview(item.id);
});
});
const categoriesList = document.getElementsByClassName("content_sidebar")[1];
categoriesList.addEventListener('scroll', function () {
previewDiv.style.display = 'none';
});
window.addEventListener('click', function (event) {
if (!event.target.classList.contains('sidebarItem')) {
previewDiv.style.display = 'none';
}
});
const categoryItems = document.querySelectorAll("#custom_categories_main .sidebarCategory");
categoryItems.forEach(function (categoryItem) {
categoryItem.addEventListener("click", function (event) {
if (event.target === event.currentTarget) {
const displayNamesList = event.target.querySelector("ul");
displayNamesList.style.display = displayNamesList.style.display === "none" ? "block" : "none";
setNodeStatus('sb_categoryNodeStatus',displayNamesList.parentElement.dataset.namecategory, displayNamesList.style.display)
}
});
});
resolve(afterRender);
});
myPromise.then((callback) => {
callback();
}).catch((error) => {
console.error("An error occurred:", error);
});
}
function searchT(e) {
if (e.value != "") {
//search in all .sidebarItem
handleSearch(categorySearchToggleCustom, "#custom_categories_main", "searchInputCn")
} else {
renderList("custom_categories_main");
}
}
async function getNodeMap() {
let categoryNodeMap = JSON.parse(await getConfiguration('sb_categoryNodeMap')) || {};
return categoryNodeMap;
}
function getNodesStatus() {
let categoryNodeStatus = JSON.parse(localStorage.getItem('sb_categoryNodeStatus')) || {};
return categoryNodeStatus;
}
async function addCategory() {
const newCategoryName = document.getElementById('sb-categoryName').value;
// Check if the category name contains special characters
if (containsSpecialCharacters(newCategoryName)) {
// Alert the user and return if special characters are found
alert("Category name cannot contain special characters.");
return;
}
let categoryNodeMap = await getNodeMap();
if (!categoryNodeMap[newCategoryName]) {
categoryNodeMap[newCategoryName] = [];
updateConfiguration('sb_categoryNodeMap', JSON.stringify(categoryNodeMap));
createContextualMenu();
reloadCtxMenu()
renderList("custom_categories_main");
closeModal()
} else {
alert("Category already exists!");
return;
}
}
// Function to rename a category
async function renameCategory(oldCategoryName, newCategoryName) {
if (containsSpecialCharacters(newCategoryName)) {
// Alert the user and return if special characters are found
alert("Category name cannot contain special characters.");
return;
}
if (oldCategoryName === newCategoryName) {
closeModal()
return;
}
let categoryNodeMap = await getNodeMap();
if (categoryNodeMap[newCategoryName]) {
alert("Category already exists!");
return;
}
let categoryNodeStatus = getNodesStatus();
if (categoryNodeMap[oldCategoryName]) {
// Categoty Current Order
let keys = Object.keys(categoryNodeMap);
categoryNodeMap[newCategoryName] = categoryNodeMap[oldCategoryName];
delete categoryNodeMap[oldCategoryName];
// Rebuild Order
let updatedMap = {};
keys.forEach(key => {
if (key === oldCategoryName) {
updatedMap[newCategoryName] = categoryNodeMap[newCategoryName];
} else {
updatedMap[key] = categoryNodeMap[key];
}
});
updateConfiguration('sb_categoryNodeMap', JSON.stringify(updatedMap));
//rename status
if (categoryNodeStatus[oldCategoryName]) {
categoryNodeStatus[newCategoryName] = categoryNodeStatus[oldCategoryName];
delete categoryNodeStatus[oldCategoryName];
localStorage.setItem('sb_categoryNodeStatus', JSON.stringify(categoryNodeStatus));
}
//rename color
renameKeyConfig("sb_ColorCustomCategories", oldCategoryName, newCategoryName)
createContextualMenu();
reloadCtxMenu()
renderList("custom_categories_main");
closeModal()
} else {
alert("Category not found!");
}
}
async function readCategories() {
let categoryNodeMap = await getNodeMap();
if (categoryNodeMap) {
return Object.keys(categoryNodeMap);
}
else {
return [];
}
}
// Function to delete a category
async function deleteCategory(e) {
const name = e.target.dataset.namecategory;
confirmation = confirm("Are you sure you want to delete the category " + name + "?");
if (!confirmation) { return; }
let categoryNodeMap = await getNodeMap();
// If the category name is found, remove it from the array
if (categoryNodeMap[name]) {
removeNodeMapCategory(name);
createContextualMenu();
removeKeyFromConfig("sb_ColorCustomCategories", name)
reloadCtxMenu()
setTimeout(() => {
renderList("custom_categories_main");
},300)
} else {
alert("Category not found!");
}
}
/////////////////////////////////////////////////
async function getConfig(configName) {
let categoryNodeMap = JSON.parse(await getConfiguration(configName)) || {};
return categoryNodeMap;
}
async function getValueFromConfig(configName, key) {
let config = await getConfig(configName);
return config[key] || null;
}
async function assignValueToConfig(configName, key, value) {
let config = await getConfig(configName);
config[key] = value;
updateConfiguration(configName, JSON.stringify(config));
}
async function removeKeyFromConfig(configName, key) {
let config = await getConfig(configName);
delete config[key];
updateConfiguration(configName, JSON.stringify(config));
}
async function renameKeyConfig(configName, oldKey, newKey) {
let config = await getConfig(configName);
let value = config[oldKey];
delete config[oldKey];
config[newKey] = value;
updateConfiguration(configName, JSON.stringify(config));
}
async function removeKeyConfig(configName) {
removeConfiguration(configName)
renderList("custom_categories_main");
}
////////////////////////////////////////////////
async function getCategoriesForNode(nodeName) {
let categoryNodeMap = await getNodeMap();
let categories = [];
for (let category in categoryNodeMap) {
if (categoryNodeMap.hasOwnProperty(category)) {
if (categoryNodeMap[category].includes(nodeName)) {
categories.push(category);
}
}
}
return categories;
}
async function getNodesForCategory(categoryName) {
let categoryNodeMap = await getNodeMap();
return categoryNodeMap[categoryName] || [];
}
async function assignNodeToCategory(nodeName, categoryNames) {
let categoryNodeMap = await getNodeMap();
let assignedCategories = await getCategoriesForNode(nodeName);
categoryNames = categoryNames.filter(category => !assignedCategories.includes(category));
categoryNames.forEach(categoryName => {
if (!categoryNodeMap[categoryName]) {
categoryNodeMap[categoryName] = [];
}
categoryNodeMap[categoryName].push(nodeName);
});
updateConfiguration('sb_categoryNodeMap', JSON.stringify(categoryNodeMap));
renderList("custom_categories_main");
}
async function removeNodeFromCategory(nodeName, categoryName) {
let categoryNodeMap = await getNodeMap();
if (categoryNodeMap[categoryName]) {
categoryNodeMap[categoryName] = categoryNodeMap[categoryName].filter(node => node !== nodeName);
updateConfiguration('sb_categoryNodeMap', JSON.stringify(categoryNodeMap));
renderList("custom_categories_main");
}
}
async function removeNodeMapCategory(categoryName) {
let categoryNodeMap = await getNodeMap();
let categoryNodeStatus = getNodesStatus();
delete categoryNodeMap[categoryName];
updateConfiguration('sb_categoryNodeMap', JSON.stringify(categoryNodeMap));
if (categoryNodeStatus[categoryName]) {
delete categoryNodeStatus[categoryName];
localStorage.setItem('sb_categoryNodeStatus', JSON.stringify(categoryNodeStatus));
}
}
function editNodeCategory(oldCategoryName, newCategoryName, nodeName) {
removeNodeFromCategory(nodeName, oldCategoryName);
assignNodeToCategory(nodeName, [newCategoryName]);
}
async function reorderCategories(orderedKeys) {
let categoryNodeMap = await getNodeMap();
let reorderedCategoryNodeMap = {};
orderedKeys.forEach(categoryKey => {
if (categoryNodeMap.hasOwnProperty(categoryKey)) {
reorderedCategoryNodeMap[categoryKey] = categoryNodeMap[categoryKey];
}
});
updateConfiguration('sb_categoryNodeMap', JSON.stringify(reorderedCategoryNodeMap));
}
async function reorderNodeInCategory(categoryName, orderedKeys) {
let categoryNodeMap = await getNodeMap();
if (categoryNodeMap[categoryName]) {
categoryNodeMap[categoryName] = orderedKeys;
updateConfiguration('sb_categoryNodeMap', JSON.stringify(categoryNodeMap));
}
}
// Callbacks
function removeNodeFromCategoryCallback(e, trge) {
removeNodeFromCategory(e.target.dataset.type, e.target.parentElement.parentElement.dataset.namecategory)
}
function assignNodeToCategoryCallback(e, trge) {
assignNodeToCategory(e.target.id, [trge]);
}
function renameCategoryCallback(e, trge) {
const name = e.target.dataset.namecategory;
openModal(createCustomHtml("rename", name))
}
function colorCategoryCallback(e) {
const name = e.target.dataset.namecategory;
openModal(createCustomHtml("color", name, e.target))
//colorCategory(e.target.id, e.target.parentElement.parentElement.dataset.namecategory)
}
async function colorCategory(name, value) {
const value_perc = await getConfiguration("sb_opacity") || "1.0";
const rgbColor = hexToRgb(value);
currentElement.style.setProperty('background-color', `rgba(${rgbColor}, ${value_perc})`, 'important');
assignValueToConfig("sb_ColorCustomCategories", name, value)
}
// Add an event listener to the button to open the modal
function resetColorCategory(name) {
removeKeyFromConfig("sb_ColorCustomCategories", name)
renderList("custom_categories_main");
}
async function createContextualMenu() {
//const menuOptions = document.getElementById('menu-options');
new_menu_options = [];
new_submenu_options = [];
new_menu_options_callback = [];
new_submenu_options_callback = [];
new_menu_options.push("Add to category");
/*new_menu_options.push("Pin It");
new_menu_options_callback.push("dummy");
new_menu_options_callback.push("test_altert");*/
//for each category
category_menu = await readCategories();
callback_menu = [];
for (let i = 0; i < category_menu.length; i++) {
callback_menu.push("custom_categories.assignNodeToCategoryCallback");
}
new_submenu_options.push(category_menu);
new_submenu_options_callback.push(callback_menu);
console.log("custom category panel loaded")
}
function test_altert(e, trge) {
console.log(trge);
console.log(e.target.id);
console.log(e.target.parentElement.parentElement.dataset.namecategory);
assignNodeToCategory(e.target.id, [trge]);
}
function dummy(e, trge) {
}
const searchCategoryIconCustomCategory = document.querySelector(".searchCategoryIconCustomCategory");
searchCategoryIconCustomCategory.addEventListener("click", async function () {
try {
categorySearchToggleCustom = !categorySearchToggleCustom;
searchCategoryIconCustomCategory.style.opacity = categorySearchToggleCustom ? "1" : "0.5";
} catch (error) {
console.error("Error occurred during search:", error);
}
});
return {
openModal,
addSidebarStyles,
createContextualMenu,
renderList,
renameCategory,
colorCategory,
removeKeyConfig,
searchT,
deleteCategory,
addCategory,
editNodeCategory,
removeNodeMapCategory,
reorderNodeInCategory,
removeNodeFromCategoryCallback,
assignNodeToCategoryCallback,
renameCategoryCallback,
renameCategoryCallback,
colorCategoryCallback,
resetColorCategory
};
})();
custom_categories.addSidebarStyles("panels/custom_categories/style.css");
custom_categories.createContextualMenu()
custom_categories.renderList("custom_categories_main");
-126
View File
@@ -1,126 +0,0 @@
#custom_categories_main,#custom_categories_header {
justify-content: center;
align-items: center;
height: 100%;
margin: 0;
}
#custom_categories_main{
padding-top: 44px;
padding-bottom: 150px;
}
#custom_categories_header {
margin: 10px 20px;
font-size: 16px;
}
#custom_categories_header #h_search {
position: absolute;
width: calc(100% - 76px);
margin-top: 0px;
left: 10px;
z-index: 400;
}
#custom_categories_header button {
/* padding: 10px 20px; */
height: 35px;
width: 35px;
font-size: 16px;
background: var(--comfy-input-bg);
border: 2px solid var(--border-color);
color: var(--input-text);
border-radius: 5px;
float: right;
position: relative;
margin: 3px 2px 2px 8px;
cursor: pointer;
z-index: 999;
}
#custom_categories_header button:hover {border: 2px solid var(--descrip-text);
}
.sb-category-dialog {
outline: 0;
border: 0;
border-radius: 6px;
background: var(--bg-color);
color: #fff;
box-shadow: inset 1px 1px 0px rgba(255, 255, 255, 0.05), inset -1px -1px 0px rgba(0, 0, 0, 0.5), 2px 2px 20px rgb(0, 0, 0);
max-width: 800px;
min-width: 30rem;
box-sizing: border-box;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-size: 1rem;
padding: 20px;
position: fixed;
top: 50%;
left: 50%;
z-index: 1001;
}
#sb-category-form {
margin-top: 10px;
text-align: center;
}
.sb-input {
width: 100%;
padding: 8px;
margin-bottom: 10px;
box-sizing: border-box;
border: 2px solid var(--border-color);
border-radius: 4px;
margin-top: 16px;
background: var(--bg-color);
color: var(--input-text);
}
.sb-input:focus {
outline: none;
border: 2px solid var(--descrip-text);
}
.sb-button {
background-color: var(--comfy-menu-bg);
color: var(--input-text);
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
text-transform: uppercase;
font-weight: bold;
}
.sb-button:hover {
filter: brightness(120%);
}
#closeModalButton {
background-color: var(--comfy-menu-bg);
position: absolute;
top: 0;
right: 0;
margin: 0;
}
#closeModalButton:hover {
filter: brightness(120%);
}
#searchInputCn {
box-sizing: border-box;
width: 100%;
border-radius: 5px;
padding: 10px;
border: none;
user-select: none;
background: var(--comfy-input-bg);
color: var(--input-text);
line-height: 1.4;
border: 1px solid var(--border-color);
}
@@ -1,13 +0,0 @@
<div id="custom_templates_header">
<div class="h-header" id="h_search" data-content="panel_head_home">
<input type="text" id="searchTemplateInput" oninput="custom_templates.searchT(this)" placeholder="Search..."
autocomplete="off" class="">
<span class="clearIconCustomTemplate">×</span>
<span class="searchTemplateIconCustomTemplate" title="Category Only">C</span>
</div>
<button id="openModalButton" onclick="custom_templates.openModal()" class="left" data-tooltip="Add New Template">+</button>
<button id="refreshlButton" onclick="custom_templates.renderList('custom_templates_main')" class="left" data-tooltip="Refresh">↻</button>
</div>
<div id="custom_templates_main">
</div>
File diff suppressed because it is too large Load Diff
-143
View File
@@ -1,143 +0,0 @@
#custom_templates_main,#custom_templates_header {
justify-content: center;
align-items: center;
height: 44px;
margin: 0;
}
#custom_templates_main{
height: auto;
padding-top: 0px;
padding-bottom: 150px;
}
#custom_templates_header {
margin: 10px 0px;
font-size: 16px;
}
#custom_templates_header #h_search {
position: absolute;
width: calc(100% - 92px);
margin-top: 0px;
left: 10px;
z-index: 400;
}
#custom_templates_header button {
/* padding: 10px 20px; */
height: 35px;
width: 35px;
font-size: 16px;
background: var(--comfy-input-bg);
border: 2px solid var(--border-color);
color: var(--input-text);
border-radius: 5px;
float: right;
position: relative;
margin: 3px 2px 2px 2px;
cursor: pointer;
z-index: 999;
}
#custom_templates_header button:hover {
border: 2px solid var(--descrip-text);
}
.sb-template-dialog {
outline: 0;
border: 0;
border-radius: 6px;
background: var(--bg-color);
color: #fff;
box-shadow: inset 1px 1px 0px rgba(255, 255, 255, 0.05), inset -1px -1px 0px rgba(0, 0, 0, 0.5), 2px 2px 20px rgb(0, 0, 0);
max-width: 800px;
min-width: 30rem;
box-sizing: border-box;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-size: 1rem;
padding: 20px;
position: fixed;
top: 50%;
left: 50%;
z-index: 1001;
}
#sb-template-form {
margin-top: 10px;
text-align: center;
}
.sb-input {
width: 100%;
padding: 8px;
margin-bottom: 10px;
box-sizing: border-box;
border: 2px solid var(--border-color);
border-radius: 4px;
margin-top: 16px;
background: var(--bg-color);
color: var(--input-text);
}
.sb-input:focus {
outline: none;
border: 2px solid var(--descrip-text);
}
.sb-button {
background-color: var(--comfy-menu-bg);
color: var(--input-text);
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
text-transform: uppercase;
font-weight: bold;
}
.sb-button:hover {
filter: brightness(120%);
}
#closeModalButton {
background-color: var(--comfy-menu-bg);
position: absolute;
top: 0;
right: 0;
margin: 0;
}
#closeModalButton:hover {
filter: brightness(120%);
}
#searchTemplateInput {
box-sizing: border-box;
width: 100%;
border-radius: 5px;
padding: 10px;
border: none;
user-select: none;
background: var(--comfy-input-bg);
color: var(--input-text);
line-height: 1.4;
border: 1px solid var(--border-color);
}
#previewDiv .sb_table {
text-align: center;
}
.sidebar #previewDiv .sidebarItem, #custom_templates_main .sidebarItem {
max-width: 100% !important;
}
#refreshlButton{
margin-left: 48px !important;
}
@@ -1,14 +0,0 @@
<div id="custom_workflows_header">
<div class="h-header" id="h_search" data-content="panel_head_home">
<input type="text" id="searchWorkflowInput" oninput="custom_workflows.searchT(this)" placeholder="Search..."
autocomplete="off" class="">
<span class="clearIconCustomWorkflow">×</span>
<span class="searchWorkflowIconCustomWorkflow" title="Category Only">C</span>
</div>
<button id="openModalButton" onclick="custom_workflows.openModal()" data-tooltip="Add New Workflow">+</button>
<button id="refreshlButton" onclick="custom_workflows.renderList('custom_workflows_main')" data-tooltip="Refresh">↻</button>
</div>
<div id="custom_workflows_main">
<div></div>
</div>
File diff suppressed because it is too large Load Diff
-169
View File
@@ -1,169 +0,0 @@
#custom_workflows_main,#custom_workflows_header {
justify-content: center;
align-items: center;
height: 100%;
margin: 0;
}
#custom_workflows_main{
padding-top: 44px;
padding-bottom: 150px;
}
#custom_workflows_header {
margin: 10px 0px;
font-size: 16px;
}
#custom_workflows_header #h_search {
position: absolute;
width: calc(100% - 92px);
margin-top: 0px;
left: 10px;
z-index: 400;
}
#custom_workflows_header button {
/* padding: 10px 20px; */
height: 35px;
width: 35px;
font-size: 16px;
background: var(--comfy-input-bg);
border: 2px solid var(--border-color);
color: var(--input-text);
border-radius: 5px;
float: right;
position: relative;
margin: 3px 2px 2px 2px;
cursor: pointer;
z-index: 999;
}
#custom_workflows_header button:hover {
border: 2px solid var(--descrip-text);
}
.sidebarFolder {
list-style-type: none;
font-family: 'Open Sans', sans-serif;
text-transform: capitalize;
margin: 2px;
background-color: var(--comfy-input-bg);
opacity: 1;
border-radius: 8px;
padding-top: 11px;
font-size: 15px;
padding-bottom: 8px;
}
.sb-workflow-dialog {
outline: 0;
border: 0;
border-radius: 6px;
background: var(--bg-color);
color: #fff;
box-shadow: inset 1px 1px 0px rgba(255, 255, 255, 0.05), inset -1px -1px 0px rgba(0, 0, 0, 0.5), 2px 2px 20px rgb(0, 0, 0);
max-width: 800px;
min-width: 30rem;
box-sizing: border-box;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-size: 1rem;
padding: 20px;
position: fixed;
top: 50%;
left: 50%;
z-index: 1001;
}
#sb-workflow-form {
margin-top: 10px;
text-align: center;
}
.sb-input {
width: 100%;
padding: 8px;
margin-bottom: 10px;
box-sizing: border-box;
border: 2px solid var(--border-color);
border-radius: 4px;
margin-top: 16px;
background: var(--bg-color);
color: var(--input-text);
}
.sb-input:focus {
outline: none;
border: 2px solid var(--descrip-text);
}
.sb-button {
background-color: var(--comfy-menu-bg);
color: var(--input-text);
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
text-transform: uppercase;
font-weight: bold;
}
.sb-button:hover {
filter: brightness(120%);
}
#closeModalButton {
background-color: var(--comfy-menu-bg);
position: absolute;
top: 0;
right: 0;
margin: 0;
}
#closeModalButton:hover {
filter: brightness(120%);
}
#searchWorkflowInput {
box-sizing: border-box;
width: 100%;
border-radius: 5px;
padding: 10px;
border: none;
user-select: none;
background: var(--comfy-input-bg);
color: var(--input-text);
line-height: 1.4;
border: 1px solid var(--border-color);
}
#previewDiv .sb_table {
text-align: center;
}
.sidebar #previewDiv .sidebarItem, #custom_workflows_main .sidebarItem {
max-width: 100% !important;
}
#refreshlButton{
margin-left: 48px !important;
}
.TIPSidebarItem {
color: var(--border-color);
font-family: 'Open Sans';
font-size: 2rem;
font-weight: bold;
text-align: center;
}
-25
View File
@@ -1,25 +0,0 @@
.terminal {
width: 100%;
height: 400px;
background-color: #000;
padding: 10px;
overflow-y: auto;
white-space: pre-wrap; /* Maintains spaces and line breaks */
}
.input-area {
display: flex;
}
.input-field {
flex: 1;
background-color: #333;
color: #fff;
border: none;
padding: 5px;
}
.submit-btn {
background-color: #007bff;
color: #fff;
padding: 5px 10px;
border: none;
cursor: pointer;
}
-13
View File
@@ -1,13 +0,0 @@
<div id="custom_example_header">
</div>
<div id="custom_example_main">
<div class="terminal" id="terminal-output">
</div>
<div class="input-area">
<input type="text" id="terminal-input" class="input-field" placeholder="Enter command...">
<button class="submit-btn" onclick="custom_example.sendCommand()">Send</button>
</div>
</div>
-87
View File
@@ -1,87 +0,0 @@
var custom_example= (function () {
let history = [];
let historyIndex = -1;
function escapeHtml(html) {
const text = document.createTextNode(html);
const p = document.createElement('p');
p.appendChild(text);
return p.innerHTML;
}
function sendCommand() {
const inputField = document.getElementById('terminal-input');
const command = inputField.value;
if (command.trim() === '') return;
// Add command to terminal
document.getElementById('terminal-output').innerHTML += `<div>> ${escapeHtml(command)}</div>`;
// Send command to backend
fetch('/sidebar/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ command }),
})
.then(response => response.json())
.then(data => {
// Display output
const output = data.output;//.replace(/\n/g, '<br>'); // Convert line breaks to <br>
document.getElementById('terminal-output').innerHTML += `<div>${escapeHtml(output)}</div>`;
// Scroll to the bottom
const terminal = document.getElementById('terminal-output');
terminal.scrollTop = terminal.scrollHeight;
})
.catch(error => {
document.getElementById('terminal-output').innerHTML += `<div>Error: ${escapeHtml(error.message)}</div>`;
// Scroll to the bottom
const terminal = document.getElementById('terminal-output');
terminal.scrollTop = terminal.scrollHeight;
});
// Add command to history and clear input field
if (history.length === 0 || history[history.length - 1] !== command) {
history.push(command);
}
historyIndex = history.length;
inputField.value = '';
}
function handleKeyDown(event) {
const inputField = document.getElementById('terminal-input');
if (event.key === 'Enter') {
event.preventDefault(); // Prevent the default action (form submission or newline)
sendCommand();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (historyIndex > 0) {
historyIndex--;
inputField.value = history[historyIndex];
}
} else if (event.key === 'ArrowDown') {
event.preventDefault();
if (historyIndex < history.length - 1) {
historyIndex++;
inputField.value = history[historyIndex];
} else {
historyIndex = history.length;
inputField.value = '';
}
}
}
// Add event listener for Enter key and Arrow keys
document.getElementById('terminal-input').addEventListener('keydown', handleKeyDown);
return {
sendCommand
};
})();
addSidebarStyles("panels/terminal/style.css?v=3421");
-6
View File
@@ -1,6 +0,0 @@
[
{
}
]
-42
View File
@@ -1,42 +0,0 @@
[
{
"id": "custom_categories",
"description": "Custom Node Categories",
"icon": "⌬",
"panels": [
"custom_categories"
]
},
{
"id": "custom_templates",
"description": "Templates list",
"icon": "Τ",
"panels": [
"custom_templates"
]
},
{
"id": "custom_workflows",
"description": "Workflows list",
"icon": "W",
"panels": [
"custom_workflows"
]
},
{
"id": "section_-281285428",
"description": "Assets Downloader",
"icon": "A",
"panels": [
"assets_downloader"
]
},
{
"id": "section_-1116604772",
"description": "Terminal",
"icon": ">",
"panels": [
"terminal"
]
}
]
-42
View File
@@ -1,42 +0,0 @@
[
{
"id": "custom_categories",
"description": "Custom Node Categories",
"icon": "⌬",
"panels": [
"custom_categories"
]
},
{
"id": "custom_templates",
"description": "Templates list",
"icon": "Τ",
"panels": [
"custom_templates"
]
},
{
"id": "custom_workflows",
"description": "Workflows list",
"icon": "W",
"panels": [
"custom_workflows"
]
},
{
"id": "assets_downloader",
"description": "Assets Downloader",
"icon": "A",
"panels": [
"assets_downloader"
]
},
{
"id": "section_terminal",
"description": "Terminal",
"icon": ">",
"panels": [
"terminal"
]
}
]
+372
View File
@@ -0,0 +1,372 @@
"""Local workflow libraries and explicitly requested Civitai downloads."""
import asyncio
import hashlib
import json
import os
from pathlib import Path
import uuid
from urllib.parse import urlsplit
import aiohttp
from aiohttp import web
BASE = Path(__file__).resolve().parent
MODEL_TYPES = {
"Checkpoint": "checkpoints", "LORA": "loras", "LoCon": "loras",
"DoRA": "loras", "TextualInversion": "embeddings", "VAE": "vae",
"Controlnet": "controlnet", "Upscaler": "upscale_models",
"Hypernetwork": "hypernetworks", "MotionModule": "animatediff_models",
}
MAX_WORKFLOW_BYTES = 16 * 1024 * 1024
def legacy_settings():
path = BASE / "app" / "settings.json"
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def workflow_roots():
configured = os.environ.get("NSIDEBAR_WORKFLOW_ROOTS", "")
configured += "\n" + str(legacy_settings().get("sb_wf_path", ""))
roots = []
for line in configured.splitlines():
if line.strip():
path = Path(line.strip()).expanduser().resolve()
if path.is_dir() and path not in roots:
roots.append(path)
return roots
def workflow_path(root, relative):
candidate = (root / relative).resolve()
if not candidate.is_relative_to(root) or candidate.suffix.lower() != ".json":
raise ValueError("Workflow must be a JSON file inside the selected library")
if candidate.stat().st_size > MAX_WORKFLOW_BYTES:
raise ValueError("Workflow exceeds the 16 MiB limit")
return candidate
def read_workflow(path):
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict) or not isinstance(data.get("nodes"), list):
raise ValueError("This JSON is not a ComfyUI workflow")
return data
def list_workflows(roots):
files = []
for index, root in enumerate(roots):
for path in sorted(root.rglob("*.json")):
try:
relative = path.relative_to(root).as_posix()
read_workflow(workflow_path(root, relative))
except (OSError, ValueError):
continue
files.append({"root": index, "path": relative, "name": path.stem})
return files
def safe_filename(name):
if (not isinstance(name, str) or not name or name in {".", ".."}
or any(char in name for char in '/\\\x00:') or name != Path(name).name):
raise ValueError("Invalid model filename")
if Path(name).suffix.lower() not in {".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf"}:
raise ValueError("Unsupported model file type")
return name
def managed_models(folder_paths):
"""Only files downloaded by this extension have verifiable sidecar metadata."""
roots = set()
for folder in set(MODEL_TYPES.values()):
try:
roots.update(Path(root).resolve() for root in folder_paths.get_folder_paths(folder))
except KeyError:
continue
models = []
for root in sorted(roots):
for sidecar in root.rglob("*.n-sidebar.json"):
try:
if not sidecar.resolve().is_relative_to(root) or sidecar.stat().st_size > 65536:
continue
metadata = json.loads(sidecar.read_text(encoding="utf-8"))
model = sidecar.with_name(sidecar.name.removesuffix(".n-sidebar.json"))
if not model.resolve().is_relative_to(root):
continue
expected = metadata.get("size")
status = "missing" if not model.is_file() else "valid size" if model.stat().st_size == expected else "size mismatch"
models.append({"id": hashlib.sha256(str(sidecar).encode()).hexdigest()[:24],
"name": model.name, "path": str(model), "status": status,
"model": metadata.get("model", {}).get("name", ""),
"sha256": metadata.get("sha256", "")})
except (OSError, ValueError, TypeError, AttributeError):
continue
return models
def model_digest(path):
digest = hashlib.sha256()
with Path(path).open("rb") as model:
for chunk in iter(lambda: model.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
class Downloads:
def __init__(self, folder_paths):
self.folder_paths = folder_paths
self.jobs = {}
self.tasks = {}
self.start_lock = asyncio.Lock()
def owner(self, request):
import server
return server.PromptServer.instance.user_manager.get_request_user_id(request)
def public(self, job):
return {key: value for key, value in job.items() if key not in {"owner", "gate", "token"}}
async def version(self, version_id, token=""):
if not isinstance(version_id, int) or isinstance(version_id, bool) or version_id <= 0:
raise ValueError("Use a positive Civitai model version ID")
headers = {"Authorization": f"Bearer {token}"} if token else {}
async with aiohttp.ClientSession(headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as session:
async with session.get(f"https://civitai.com/api/v1/model-versions/{version_id}") as response:
response.raise_for_status()
data = await response.json()
files = [file for file in data.get("files", []) if file.get("type") == "Model"]
return {
"id": data["id"], "name": data.get("name", ""),
"model": data.get("model", {}), "baseModel": data.get("baseModel", ""),
"files": [{key: file.get(key) for key in ("id", "name", "sizeKB", "hashes", "primary", "downloadUrl")} for file in files],
}
def destination(self, model_type, name, selected=None):
folder = MODEL_TYPES.get(model_type)
if folder is None:
raise ValueError("Unsupported Civitai model type")
paths = self.folder_paths.get_folder_paths(folder)
if not paths:
raise ValueError(f"No ComfyUI model directory configured for {folder}")
local = Path(self.folder_paths.models_dir) / folder
if selected is not None:
if not isinstance(selected, int) or isinstance(selected, bool) or not 0 <= selected < len(paths):
raise ValueError("Unknown model destination")
root = Path(paths[selected])
else:
root = local if str(local) in paths else Path(paths[0])
root.mkdir(parents=True, exist_ok=True)
destination = root / safe_filename(name)
if destination.exists() or destination.is_symlink():
raise ValueError("A file with this name already exists")
return destination
async def start(self, payload, owner):
async with self.start_lock:
return await self._start(payload, owner)
async def _start(self, payload, owner):
if not isinstance(payload, dict):
raise ValueError("Invalid download request")
if any(job["status"] in {"queued", "running", "paused"} for job in self.jobs.values()):
raise ValueError("Finish or cancel the active download first")
token = os.environ.get("CIVITAI_TOKEN", "")
version = await self.version(payload.get("version"), token)
file = next((f for f in version["files"] if f["id"] == payload.get("file")), None)
if file is None:
raise ValueError("Choose a file from this model version")
url = urlsplit(file.get("downloadUrl") or "")
if url.scheme != "https" or url.netloc != "civitai.com" or url.path != f"/api/download/models/{version['id']}":
raise ValueError("Invalid Civitai file download URL")
destination = self.destination(version["model"].get("type"), file["name"], payload.get("destination"))
job_id = uuid.uuid4().hex
job = {
"id": job_id, "owner": owner, "name": file["name"], "status": "queued",
"received": 0, "total": 0, "error": "", "gate": asyncio.Event(),
}
job["gate"].set()
self.jobs[job_id] = job
for old_id in list(self.jobs)[:-20]:
if old_id not in self.tasks:
self.jobs.pop(old_id)
self.tasks[job_id] = asyncio.create_task(self.run(job, destination, version, file, token))
def finished(task):
if task.cancelled():
job["status"] = "cancelled"
self.tasks.pop(job_id, None)
self.tasks[job_id].add_done_callback(finished)
return self.public(job)
async def run(self, job, destination, version, file, token):
temporary = destination.with_name(destination.name + f".{job['id']}.part")
digest = hashlib.sha256()
headers = {"Authorization": f"Bearer {token}"} if token else {}
try:
if job["status"] == "queued":
job["status"] = "running"
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=120)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(file["downloadUrl"], headers=headers) as response:
response.raise_for_status()
job["total"] = int(response.headers.get("Content-Length", 0))
with temporary.open("xb") as output:
async for chunk in response.content.iter_chunked(1024 * 1024):
await job["gate"].wait()
write = asyncio.create_task(asyncio.to_thread(output.write, chunk))
try:
await asyncio.shield(write)
except asyncio.CancelledError:
await write
raise
digest.update(chunk)
job["received"] += len(chunk)
if not job["received"]:
raise ValueError("Empty model download")
if job["total"] and job["received"] != job["total"]:
raise ValueError("Incomplete download")
expected = (file.get("hashes") or {}).get("SHA256")
if expected and digest.hexdigest().lower() != expected.lower():
raise ValueError("SHA256 verification failed")
os.link(temporary, destination)
temporary.unlink()
metadata = {"version": version["id"], "model": version["model"],
"baseModel": version["baseModel"], "size": job["received"],
"sha256": digest.hexdigest()}
destination.with_name(destination.name + ".n-sidebar.json").write_text(json.dumps(metadata, indent=2))
job["status"] = "completed"
except asyncio.CancelledError:
job["status"] = "cancelled"
except (aiohttp.ClientError, OSError, ValueError, asyncio.TimeoutError) as error:
job["status"] = "failed"
job["error"] = str(error)
finally:
temporary.unlink(missing_ok=True)
self.tasks.pop(job["id"], None)
def register_routes():
import folder_paths
import server
routes = server.PromptServer.instance.routes
downloads = Downloads(folder_paths)
@routes.get("/n-sidebar/legacy")
async def legacy(request):
data = legacy_settings()
keys = ("sb_pinnedItems", "sb_categoryNodeMap", "sb_ColorCustomCategories",
"sb_templateNodeMap", "sb_ColorCustomTemplates", "sb_workflowNodeMap", "sb_ColorCustomWorkflows")
return web.json_response({key: data[key] for key in keys if key in data})
@routes.get("/n-sidebar/workflows")
async def workflows(request):
roots = workflow_roots()
files = await asyncio.to_thread(list_workflows, roots)
return web.json_response({"roots": [str(root) for root in roots], "files": files})
@routes.get("/n-sidebar/workflow")
async def workflow(request):
try:
roots = workflow_roots()
index = int(request.query.get("root", "-1"))
if not 0 <= index < len(roots):
raise ValueError("Unknown workflow library")
path = workflow_path(roots[index], request.query.get("path", ""))
data = await asyncio.to_thread(read_workflow, path)
return web.json_response(data)
except (ValueError, OSError) as error:
return web.json_response({"error": str(error)}, status=400)
@routes.get("/n-sidebar/civitai/search")
async def search(request):
params = {"limit": "12", "nsfw": "false"}
for key in ("query", "types", "page"):
if request.query.get(key):
params[key] = request.query[key]
try:
token = os.environ.get("CIVITAI_TOKEN", "")
headers = {"Authorization": f"Bearer {token}"} if token else {}
async with aiohttp.ClientSession(headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as session:
async with session.get("https://civitai.com/api/v1/models", params=params) as response:
response.raise_for_status()
data = await response.json()
return web.json_response(data)
except (aiohttp.ClientError, ValueError, asyncio.TimeoutError) as error:
return web.json_response({"error": str(error)}, status=502)
@routes.get("/n-sidebar/civitai/version/{version}")
async def version(request):
try:
data = await downloads.version(int(request.match_info["version"]), os.environ.get("CIVITAI_TOKEN", ""))
data["files"] = [{key: value for key, value in file.items() if key != "downloadUrl"} for file in data["files"]]
return web.json_response(data)
except (ValueError, aiohttp.ClientError, asyncio.TimeoutError) as error:
return web.json_response({"error": str(error)}, status=400)
@routes.get("/n-sidebar/model-paths")
async def model_paths(request):
folder = MODEL_TYPES.get(request.query.get("type"))
if folder is None:
return web.json_response({"error": "Unsupported model type"}, status=400)
try:
paths = folder_paths.get_folder_paths(folder)
except KeyError:
paths = []
local = str(Path(folder_paths.models_dir) / folder)
return web.json_response({"paths": paths, "default": paths.index(local) if local in paths else 0})
@routes.get("/n-sidebar/downloads")
async def progress(request):
owner = downloads.owner(request)
return web.json_response([downloads.public(job) for job in downloads.jobs.values() if job["owner"] == owner])
@routes.get("/n-sidebar/models")
async def models(request):
entries = await asyncio.to_thread(managed_models, folder_paths)
return web.json_response([{key: value for key, value in entry.items() if key != "path"} for entry in entries])
@routes.post("/n-sidebar/models/{id}/verify")
async def verify(request):
entries = await asyncio.to_thread(managed_models, folder_paths)
model = next((entry for entry in entries if entry["id"] == request.match_info["id"]), None)
if model is None:
raise web.HTTPNotFound()
if model["status"] == "missing":
return web.json_response({"status": "missing"})
try:
digest = await asyncio.to_thread(model_digest, model["path"])
return web.json_response({"status": "verified" if digest == model["sha256"] else "hash mismatch"})
except OSError as error:
return web.json_response({"error": str(error)}, status=400)
@routes.post("/n-sidebar/downloads")
async def download(request):
try:
return web.json_response(await downloads.start(await request.json(), downloads.owner(request)), status=202)
except (ValueError, OSError, aiohttp.ClientError, asyncio.TimeoutError) as error:
return web.json_response({"error": str(error)}, status=400)
@routes.post("/n-sidebar/downloads/{id}/{action}")
async def control(request):
job = downloads.jobs.get(request.match_info["id"])
if job is None or job["owner"] != downloads.owner(request):
raise web.HTTPNotFound()
action = request.match_info["action"]
if action not in {"pause", "resume", "cancel"} or job["status"] not in {"queued", "running", "paused"}:
return web.json_response({"error": "Invalid download action"}, status=400)
if action == "cancel":
downloads.tasks[job["id"]].cancel()
elif action == "pause":
job["gate"].clear()
job["status"] = "paused"
else:
job["gate"].set()
job["status"] = "running"
return web.json_response(downloads.public(job))
+6
View File
@@ -0,0 +1,6 @@
{
"name": "comfyui-n-sidebar",
"private": true,
"type": "module",
"scripts": { "test": "node --test tests/*.test.mjs" }
}
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-n-sidebar"
description = "A simple sidebar for ComfyUI."
version = "1.6.0"
description = "Optional template collections, external workflow libraries, workflow inspection and Civitai downloads for the official ComfyUI sidebar."
version = "2.0.0"
license = { file = "LICENSE" }
[project.urls]
+1 -1
View File
@@ -1 +1 @@
aiofiles
# Uses aiohttp supplied by ComfyUI. No additional packages required.
+213
View File
@@ -0,0 +1,213 @@
"""Exercise the extension in an existing ComfyUI frontend with isolated browser writes.
Requires Playwright and its Chromium browser. All mutations and Civitai calls are
intercepted; this does not install the extension or download models on the server.
"""
import argparse
import asyncio
import json
from pathlib import Path
from urllib.parse import unquote, urlsplit
from playwright.async_api import async_playwright
REPO = Path(__file__).resolve().parents[1]
WORKFLOW = {"id": "sidebar-test", "version": 0.4, "last_node_id": 1, "last_link_id": 0,
"nodes": [{"id": 1, "type": "CLIPTextEncode", "pos": [100, 100], "size": [400, 200],
"flags": {}, "order": 0, "mode": 0, "inputs": [], "outputs": [],
"properties": {}, "widgets_values": ["N-Sidebar browser test"]}],
"links": [], "groups": [], "config": {}, "extra": {}}
TEMPLATE = {"name": "Browser test template", "data": json.dumps({
"nodes": WORKFLOW["nodes"], "links": [], "groups": [], "reroutes": [], "subgraphs": []})}
async def run(args):
async with async_playwright() as playwright:
launch = {"headless": True, "args": ["--no-sandbox"]}
if args.chromium:
launch["executable_path"] = args.chromium
browser = await playwright.chromium.launch(**launch)
for mobile in (False, True):
context = await browser.new_context(viewport={"width": 412 if mobile else 1440, "height": 915 if mobile else 1000},
is_mobile=mobile, has_touch=mobile, device_scale_factor=1)
page = await context.new_page()
errors = []
console_errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.on("console", lambda message: console_errors.append(message.text) if message.type == "error" else None)
shadow_settings = {"Comfy.UseNewMenu": "Top", "Comfy.NodeLibrary.Bookmarks.V2": ["KSampler"],
"Comfy.VueNodes.Enabled": False, "Comfy.OnboardingCoachmarks.Seen": True,
"Comfy.TutorialCompleted": True}
user_data = {"comfy.templates.json": [TEMPLATE]}
download_jobs = []
writes = []
async def intercept(route):
request = route.request
url = urlsplit(request.url)
path = unquote(url.path)
if path.startswith("/api/"):
path = path[4:]
async def response(data, status=200):
await route.fulfill(status=status, content_type="application/json", body=json.dumps(data))
if url.netloc != urlsplit(args.url).netloc:
await route.abort()
return
if path == "/extensions":
await response(["/extensions/n-sidebar/sidebar.js"])
elif path.startswith("/extensions/n-sidebar/"):
file = REPO / "web" / path.rsplit("/", 1)[-1]
await route.fulfill(path=str(file), content_type="text/css" if file.suffix == ".css" else "text/javascript")
elif path == "/settings" or path.startswith("/settings/"):
if request.method == "POST":
value = request.post_data_json
if path == "/settings": shadow_settings.update(value)
else: shadow_settings[path.split("/", 2)[-1]] = value
writes.append(path)
await response({})
else:
await response(shadow_settings if path == "/settings" else shadow_settings.get(path.split("/", 2)[-1]))
elif path.startswith("/userdata/"):
name = path[len("/userdata/"):]
if request.method == "POST":
user_data[name] = request.post_data_json
writes.append(path)
await response({})
elif name in user_data:
await response(user_data[name])
else:
await response({"error": "not found"}, 404)
elif path == "/userdata":
await response([])
elif path == "/n-sidebar/workflows":
await response({"roots": ["/test/library"], "files": [{"root": 0, "path": "Demo.json", "name": "Demo"}]})
elif path == "/n-sidebar/workflow":
await response(WORKFLOW)
elif path == "/n-sidebar/legacy":
await response({"sb_pinnedItems": '["LoadImage"]', "sb_categoryNodeMap": '{"My nodes":["KSampler"]}',
"sb_templateNodeMap": '{"Imported":["Browser test template"]}'})
elif path == "/n-sidebar/civitai/search":
await response({"items": [{"name": "Test Civitai model", "type": "LORA", "creator": {"username": "Tester"},
"modelVersions": [{"id": 123, "name": "v1", "baseModel": "SDXL"}]}], "metadata": {}})
elif path.startswith("/n-sidebar/civitai/version/"):
await response({"id": 123, "name": "v1", "model": {"name": "Test Civitai model", "type": "LORA"},
"files": [{"id": 456, "name": "sample.safetensors", "sizeKB": 1024}]})
elif path == "/n-sidebar/model-paths":
await response({"paths": ["/test/models/loras"], "default": 0})
elif path == "/n-sidebar/downloads":
if request.method == "POST":
download_jobs.append({"id": "test", "name": "sample.safetensors", "status": "running", "received": 512, "total": 1024})
await response(download_jobs[-1], 202)
else:
await response(download_jobs)
elif path.startswith("/n-sidebar/downloads/"):
action = path.rsplit("/", 1)[-1]
download_jobs[0]["status"] = {"pause": "paused", "resume": "running", "cancel": "cancelled"}[action]
await response(download_jobs[0])
elif path == "/n-sidebar/models":
await response([{"id": "sample", "name": "sample.safetensors", "status": "valid size"}])
elif path == "/n-sidebar/models/sample/verify":
await response({"status": "verified"})
elif request.method not in {"GET", "HEAD", "OPTIONS"}:
writes.append(path)
await response({})
else:
await route.continue_()
await page.route("**/*", intercept)
await page.goto(args.url, wait_until="domcontentloaded")
await page.wait_for_function("() => !!window.comfyAPI?.app?.app?.extensionManager", timeout=90000)
try:
await page.wait_for_function("() => !!window.comfyAPI.app.app.extensionManager.getSidebarTabs().find(tab => tab.id === 'n-sidebar-templates')", timeout=30000)
except Exception:
print("Boot errors:", errors, console_errors, flush=True)
print("Boot state:", await page.evaluate("({tabs: window.comfyAPI.app.app.extensionManager.getSidebarTabs().map(tab => tab.id), text: document.body.innerText.slice(-2000)})"), flush=True)
raise
print("Frontend loaded:", "touch" if mobile else "desktop", flush=True)
await page.evaluate("async () => { window.sidebarTestApp = (await import('/scripts/app.js')).app; }")
await page.wait_for_function("() => !!sidebarTestApp.canvas?.graph", timeout=60000)
await page.locator("#graph-canvas").wait_for(state="visible", timeout=60000)
await page.keyboard.press("Escape")
async def show(key):
await page.evaluate("key => sidebarTestApp.extensionManager.command.execute('Workspace.ToggleSidebarTab.n-sidebar-' + key)", key)
await page.locator(f".ns-panel[data-module='{key.title()}']").wait_for(state="visible")
async def activate(locator):
if mobile:
await locator.tap()
else:
await locator.click()
await show("templates")
await page.get_by_text("Browser test template", exact=True).wait_for()
await page.get_by_placeholder("New collection").fill("Touch collection")
await activate(page.get_by_role("button", name="Create", exact=True))
await page.locator(".ns-card select").select_option("Touch collection")
await page.locator(".ns-card").wait_for()
assert "Touch collection" in user_data["n-sidebar-extras.json"]["templateGroups"]
await activate(page.get_by_role("button", name="Insert", exact=True))
assert await page.evaluate("sidebarTestApp.graph._nodes.some(node => node.widgets?.some(widget => widget.value === 'N-Sidebar browser test'))")
await activate(page.get_by_role("button", name="Preview nodes", exact=True))
await page.get_by_text("CLIPTextEncode × 1", exact=True).wait_for()
await show("inspector")
await activate(page.get_by_role("button", name="Locate", exact=True).first)
await page.get_by_placeholder("Inspect workflow JSON").set_input_files({"name": "missing.json", "mimeType": "application/json", "buffer": b'{"nodes":[{"type":"MissingTestNode"}]}'})
await page.get_by_text("MissingTestNode × 1 — missing", exact=True).wait_for()
await show("workflows")
await activate(page.get_by_role("button", name="Open in tab", exact=True))
await page.wait_for_function("sidebarTestApp.extensionManager.workflow.activeWorkflow?.path?.includes('Demo')")
await show("migration")
await activate(page.get_by_role("button", name="Import old server settings", exact=True))
await page.get_by_text("Imported. Open the official node library", exact=False).wait_for()
assert "LoadImage" in shadow_settings["Comfy.NodeLibrary.Bookmarks.V2"]
assert "KSampler" in shadow_settings["Comfy.NodeLibrary.Bookmarks.V2"]
assert "N-Sidebar/My nodes/KSampler" in shadow_settings["Comfy.NodeLibrary.Bookmarks.V2"]
await show("civitai")
await page.get_by_placeholder("Search Civitai models").fill("Demo")
await activate(page.get_by_role("button", name="Search", exact=True))
await activate(page.get_by_role("button", name="Choose file", exact=True))
await activate(page.get_by_role("button", name="Download", exact=True))
await activate(page.get_by_role("button", name="Pause", exact=True))
await activate(page.get_by_role("button", name="Resume", exact=True))
await activate(page.get_by_role("button", name="Cancel", exact=True))
await page.get_by_text("cancelled ·", exact=False).wait_for()
await activate(page.get_by_role("button", name="Scan downloaded models", exact=True))
await activate(page.get_by_role("button", name="Verify SHA256", exact=True))
await page.get_by_text("verified", exact=True).wait_for()
await page.evaluate("sidebarTestApp.extensionManager.setting.set('NSidebar.Civitai.Enabled', false)")
assert await page.locator(".ns-panel[data-module='Civitai']").count() == 0
assert not await page.evaluate("sidebarTestApp.extensionManager.getSidebarTabs().some(tab => tab.id === 'n-sidebar-civitai')")
await page.evaluate("sidebarTestApp.extensionManager.setting.set('NSidebar.Civitai.Enabled', true)")
for module in ("Templates", "Workflows", "Inspector", "Civitai", "Migration", "Diagnostics"):
await page.evaluate("key => sidebarTestApp.extensionManager.setting.set('NSidebar.' + key + '.Enabled', false)", module)
assert not await page.evaluate("key => sidebarTestApp.extensionManager.getSidebarTabs().some(tab => tab.id === 'n-sidebar-' + key.toLowerCase())", module)
await page.evaluate("key => sidebarTestApp.extensionManager.setting.set('NSidebar.' + key + '.Enabled', true)", module)
assert await page.evaluate("key => sidebarTestApp.extensionManager.getSidebarTabs().filter(tab => tab.id === 'n-sidebar-' + key.toLowerCase()).length === 1", module)
await show("templates")
await page.get_by_text("Browser test template", exact=True).first.wait_for()
overflow = await page.locator(".ns-panel").evaluate("node => node.scrollWidth > node.clientWidth + 1")
assert not overflow, "Panel overflows its native sidebar container"
screenshot = Path(args.artifacts) / ("sidebar-android.png" if mobile else "sidebar-desktop.png")
screenshot.parent.mkdir(parents=True, exist_ok=True)
await page.screenshot(path=str(screenshot))
assert not errors, "Browser errors: " + "; ".join(errors)
print(f"{'Android touch' if mobile else 'Desktop'}: passed; {len(writes)} writes intercepted; screenshot {screenshot}")
await context.close()
await browser.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:8188")
parser.add_argument("--chromium", default=None)
parser.add_argument("--artifacts", default="/tmp/n-sidebar-artifacts")
asyncio.run(run(parser.parse_args()))
+81
View File
@@ -0,0 +1,81 @@
import test from "node:test";
import assert from "node:assert/strict";
import { MODULES, syncModules, migrateLegacy, inspectWorkflow, templateClipboard, mergeGroups } from "../web/core.js";
test("module settings register once and immediately remove an active panel", () => {
const settings = Object.fromEntries(MODULES.map(module => [`NSidebar.${module.key}.Enabled`, true]));
const tabs = new Map();
let destroyed = 0;
const manager = {
registerSidebarTab(tab) { assert.ok(!tabs.has(tab.id)); tabs.set(tab.id, tab); },
unregisterSidebarTab(id) { tabs.get(id).destroy(); tabs.delete(id); },
};
const mounted = new Set();
const sync = () => syncModules(manager, id => settings[id], () => () => destroyed++, mounted);
sync();
sync();
assert.equal(tabs.size, MODULES.length);
tabs.get("n-sidebar-civitai").render({});
settings["NSidebar.Civitai.Enabled"] = false;
sync();
assert.equal(destroyed, 1);
assert.ok(!tabs.has("n-sidebar-civitai"));
settings["NSidebar.Civitai.Enabled"] = true;
sync();
assert.equal(tabs.size, MODULES.length);
});
test("legacy import merges native bookmarks and preserves collection colors and order", () => {
const source = {
sb_pinnedItems: '["KSampler","KSampler"]',
sb_categoryNodeMap: '{"Video/Helpers":["LoadImage","MissingNode"]}',
sb_ColorCustomCategories: '{"Video/Helpers":"#abcdef"}',
sb_templateNodeMap: '{"Video":["Decode","Load"]}',
};
const result = migrateLegacy(source, ["Existing"], { Existing: { color: "#111111" } });
assert.deepEqual(result.bookmarks, ["Existing", "KSampler", "N-Sidebar/Video ∕ Helpers/", "N-Sidebar/Video ∕ Helpers/LoadImage", "N-Sidebar/Video ∕ Helpers/MissingNode"]);
assert.equal(result.colors["N-Sidebar/Video ∕ Helpers/"].color, "#abcdef");
assert.deepEqual(result.templateGroups.Video, ["Decode", "Load"]);
const again = migrateLegacy(source, result.bookmarks, result.colors);
assert.deepEqual(again.bookmarks, result.bookmarks);
});
test("collection names cannot modify the object's prototype", () => {
const data = JSON.parse('{"__proto__":["Sample"],"constructor":["Other"]}');
const result = mergeGroups({}, data);
assert.deepEqual(result.__proto__, ["Sample"]);
assert.equal(Object.getPrototypeOf(result), Object.prototype);
assert.deepEqual(mergeGroups(result, { constructor: ["More"] }).constructor, ["Other", "More"]);
});
test("legacy workflow IDs decode UTF-8 and Windows paths without corrupting plain names", () => {
const unix = Buffer.from("/work/日本/image.json").toString("base64");
const windows = Buffer.from("C:\\Workflows\\image.json").toString("base64");
const result = migrateLegacy({ sb_workflowNodeMap: JSON.stringify({ Examples: [unix, windows, "Plain name"] }) });
assert.deepEqual(result.workflowGroups.Examples, ["/work/日本/image.json", "C:/Workflows/image.json", "Plain name"]);
});
test("inspector counts nodes inside subgraphs and distinguishes missing implementations", () => {
const graph = { nodes: [{ type: "KSampler" }, { type: "Missing" }, { type: "subgraph-id" }],
definitions: { subgraphs: [{ id: "subgraph-id", nodes: [{ type: "KSampler" }, { type: "InnerMissing" }] }] } };
const entries = inspectWorkflow(graph, new Set(["KSampler"]));
assert.equal(entries.find(entry => entry.type === "KSampler").count, 2);
assert.equal(entries.find(entry => entry.type === "subgraph-id").missing, false);
assert.deepEqual(entries.filter(entry => entry.missing).map(entry => entry.type), ["InnerMissing", "Missing"]);
});
test("old node templates reconnect by array index, skipping external links", () => {
const source = { nodes: [{ id: 12, type: "A", pos: [0, 0] }, { id: 78, type: "B", pos: [1, 1] }],
links: [[0, 2, 1, 0, "IMAGE"], [null, 0, 1, 2, "IMAGE"]] };
const converted = templateClipboard(JSON.stringify(source));
assert.deepEqual(converted.nodes.map(node => node.id), ["1", "2"]);
assert.deepEqual(converted.links, [{ id: 1, origin_id: "1", origin_slot: 2, target_id: "2", target_slot: 0, type: "IMAGE" }]);
assert.deepEqual(templateClipboard(converted), converted);
assert.equal(source.nodes[0].id, 12);
});
test("malformed imports are rejected before settings are changed", () => {
assert.throws(() => migrateLegacy({ sb_pinnedItems: "invalid json" }));
assert.throws(() => mergeGroups({}, { Collection: "not an array" }));
assert.throws(() => templateClipboard("{}"));
});
+188
View File
@@ -0,0 +1,188 @@
import asyncio
import hashlib
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
spec = importlib.util.spec_from_file_location("n_sidebar_backend", Path(__file__).parents[1] / "backend.py")
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class WorkflowFiles(unittest.TestCase):
def test_scan_excludes_secrets_and_links_outside_library(self):
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
root = base / "library"
root.mkdir()
(root / "valid.json").write_text('{"nodes":[]}')
(root / "credentials.json").write_text('{"secret":"do not serve"}')
(base / "outside.json").write_text('{"nodes":[]}')
(root / "escape.json").symlink_to(base / "outside.json")
self.assertEqual([item["name"] for item in backend.list_workflows([root])], ["valid"])
with self.assertRaises(ValueError):
backend.workflow_path(root, "../outside.json")
with self.assertRaises(ValueError):
backend.workflow_path(root, "escape.json")
def test_model_filename_validation(self):
for name in ["../a.safetensors", "a\\b.pt", "a:stream.pt", "model.py", ".", ""]:
with self.subTest(name=name), self.assertRaises(ValueError):
backend.safe_filename(name)
self.assertEqual(backend.safe_filename("model v2.safetensors"), "model v2.safetensors")
class BackendRoutes(AioHTTPTestCase):
async def get_application(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.base = Path(self.directory.name)
self.library = self.base / "library"
self.library.mkdir()
(self.library / "demo.json").write_text('{"nodes":[{"type":"Test"}]}')
self.models = self.base / "models" / "loras"
self.models.mkdir(parents=True)
self.routes = web.RouteTableDef()
self.folder_paths = SimpleNamespace(models_dir=str(self.base / "models"),
get_folder_paths=lambda key: [str(self.models)] if key == "loras" else [])
self.prompt_server = SimpleNamespace(instance=SimpleNamespace(routes=self.routes,
user_manager=SimpleNamespace(get_request_user_id=lambda request: request.headers.get("Comfy-User", "default"))))
self.patch_modules = patch.dict(sys.modules, {"server": SimpleNamespace(PromptServer=self.prompt_server), "folder_paths": self.folder_paths})
self.patch_modules.start()
self.addCleanup(self.patch_modules.stop)
self.patch_base = patch.object(backend, "BASE", self.base)
self.patch_base.start()
self.addCleanup(self.patch_base.stop)
self.patch_env = patch.dict(backend.os.environ, {"NSIDEBAR_WORKFLOW_ROOTS": str(self.library)})
self.patch_env.start()
self.addCleanup(self.patch_env.stop)
backend.register_routes()
app = web.Application()
app.add_routes(self.routes)
return app
async def test_workflow_routes_validate_root_and_file(self):
response = await self.client.get("/n-sidebar/workflows")
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())["files"][0]["name"], "demo")
response = await self.client.get("/n-sidebar/workflow?root=0&path=demo.json")
self.assertEqual((await response.json())["nodes"][0]["type"], "Test")
for url in ["/n-sidebar/workflow?root=-1&path=demo.json", "/n-sidebar/workflow?root=0&path=../private.json"]:
self.assertEqual((await self.client.get(url)).status, 400)
async def test_legacy_route_never_exposes_api_token(self):
(self.base / "app").mkdir()
(self.base / "app" / "settings.json").write_text(json.dumps({"sb_w_API_key": "SECRET", "sb_pinnedItems": '["Test"]'}))
response = await self.client.get("/n-sidebar/legacy")
self.assertEqual(await response.json(), {"sb_pinnedItems": '["Test"]'})
async def test_integrity_detects_same_size_corruption_and_missing_file(self):
model = self.models / "sample.safetensors"
model.write_bytes(b"correct")
sidecar = self.models / "sample.safetensors.n-sidebar.json"
sidecar.write_text(json.dumps({"model": {"name": "Demo"}, "size": 7, "sha256": hashlib.sha256(b"correct").hexdigest()}))
response = await self.client.get("/n-sidebar/models")
entry = (await response.json())[0]
self.assertNotIn("path", entry)
response = await self.client.post(f"/n-sidebar/models/{entry['id']}/verify")
self.assertEqual((await response.json())["status"], "verified")
model.write_bytes(b"changed")
response = await self.client.post(f"/n-sidebar/models/{entry['id']}/verify")
self.assertEqual((await response.json())["status"], "hash mismatch")
model.unlink()
response = await self.client.post(f"/n-sidebar/models/{entry['id']}/verify")
self.assertEqual((await response.json())["status"], "missing")
class DownloadLifecycle(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.manager = backend.Downloads(SimpleNamespace(models_dir=str(self.root), get_folder_paths=lambda _: [str(self.root)]))
self.body = b"model-contents"
self.version = {"id": 123, "name": "v1", "model": {"name": "Demo", "type": "LORA"}, "baseModel": "SDXL",
"files": [{"id": 456, "name": "test.safetensors", "hashes": {"SHA256": hashlib.sha256(self.body).hexdigest()},
"downloadUrl": "https://civitai.com/api/download/models/123?type=Model&format=SafeTensor"}]}
async def version(version_id, token):
await asyncio.sleep(0)
return self.version
self.manager.version = version
def session(self, slow=False):
body = self.body
class Response:
headers = {"Content-Length": str(len(body))}
def raise_for_status(self): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
@property
def content(self): return self
async def iter_chunked(self, size):
if slow:
await asyncio.sleep(10)
yield body
class Session:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def get(self, url, **kwargs):
if "format=SafeTensor" not in url:
raise AssertionError("Must request the API's file-specific URL")
return Response()
return Session
async def test_download_hash_and_metadata_and_no_overwrite(self):
with patch.object(backend.aiohttp, "ClientSession", self.session()):
job = await self.manager.start({"version": 123, "file": 456}, "alice")
await self.manager.tasks[job["id"]]
self.assertEqual(self.manager.jobs[job["id"]]["status"], "completed")
self.assertEqual((self.root / "test.safetensors").read_bytes(), self.body)
self.assertTrue((self.root / "test.safetensors.n-sidebar.json").exists())
with self.assertRaisesRegex(ValueError, "already exists"):
await self.manager.start({"version": 123, "file": 456}, "alice")
async def test_bad_hash_removes_partial_file(self):
self.version["files"][0]["hashes"]["SHA256"] = "bad"
with patch.object(backend.aiohttp, "ClientSession", self.session()):
job = await self.manager.start({"version": 123, "file": 456}, "alice")
await self.manager.tasks[job["id"]]
self.assertEqual(self.manager.jobs[job["id"]]["status"], "failed")
self.assertEqual(list(self.root.iterdir()), [])
async def test_concurrent_starts_only_create_one_download(self):
with patch.object(backend.aiohttp, "ClientSession", self.session(slow=True)):
jobs = await asyncio.gather(self.manager.start({"version": 123, "file": 456}, "alice"),
self.manager.start({"version": 123, "file": 456}, "bob"), return_exceptions=True)
self.assertEqual(sum(isinstance(job, ValueError) for job in jobs), 1)
task = next(iter(self.manager.tasks.values()))
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await asyncio.sleep(0)
self.assertEqual(next(iter(self.manager.jobs.values()))["status"], "cancelled")
self.assertEqual(self.manager.tasks, {})
async def test_pause_and_cancel_clean_up(self):
with patch.object(backend.aiohttp, "ClientSession", self.session()):
job = await self.manager.start({"version": 123, "file": 456}, "alice")
state = self.manager.jobs[job["id"]]
state["gate"].clear()
task = self.manager.tasks[job["id"]]
await asyncio.sleep(0.02)
self.assertEqual(state["received"], 0)
task.cancel()
await task
self.assertEqual(state["status"], "cancelled")
self.assertEqual(list(self.root.iterdir()), [])
if __name__ == "__main__":
unittest.main()
+117
View File
@@ -0,0 +1,117 @@
export const MODULES = [
{ key: "Templates", title: "N · Template collections", icon: "pi pi-th-large", enabled: true },
{ key: "Workflows", title: "N · External workflows", icon: "pi pi-folder-open", enabled: true },
{ key: "Inspector", title: "N · Workflow inspector", icon: "pi pi-search-plus", enabled: true },
{ key: "Civitai", title: "N · Civitai downloader", icon: "pi pi-download", enabled: true },
{ key: "Migration", title: "N · Import & export", icon: "pi pi-file-import", enabled: true },
{ key: "Diagnostics", title: "N · Diagnostics", icon: "pi pi-info-circle", enabled: false },
];
export const settingId = key => `NSidebar.${key}.Enabled`;
export function syncModules(manager, getSetting, createPanel, mounted) {
for (const module of MODULES) {
const id = `n-sidebar-${module.key.toLowerCase()}`;
if (getSetting(settingId(module.key)) && !mounted.has(id)) {
let dispose;
manager.registerSidebarTab({
id, title: module.title, tooltip: module.title, icon: module.icon, type: "custom",
render(container) { dispose?.(); dispose = createPanel(module.key, container); },
destroy() { dispose?.(); dispose = undefined; },
});
mounted.add(id);
} else if (!getSetting(settingId(module.key)) && mounted.has(id)) {
manager.unregisterSidebarTab(id);
mounted.delete(id);
}
}
}
export function parseLegacy(value, fallback) {
if (value == null) return fallback;
return typeof value === "string" ? JSON.parse(value) : value;
}
export function groupMap(value) {
const source = parseLegacy(value, {});
if (!source || typeof source !== "object" || Array.isArray(source)) throw new Error("Invalid collection data");
const result = {};
for (const [name, members] of Object.entries(source)) {
if (!Array.isArray(members) || !members.every(item => typeof item === "string")) throw new Error("Invalid collection members");
Object.defineProperty(result, name, { value: [...new Set(members)], enumerable: true, writable: true, configurable: true });
}
return result;
}
export function migrateLegacy(data, currentBookmarks = [], currentColors = {}) {
const pinned = parseLegacy(data.sb_pinnedItems, []);
if (!Array.isArray(pinned) || !pinned.every(item => typeof item === "string")) throw new Error("Invalid pinned nodes");
const bookmarks = new Set(currentBookmarks);
pinned.forEach(type => bookmarks.add(type));
const colors = { ...currentColors };
const oldColors = parseLegacy(data.sb_ColorCustomCategories, {});
for (const [name, types] of Object.entries(groupMap(data.sb_categoryNodeMap))) {
const folder = `N-Sidebar/${name.replaceAll("/", " ∕ ")}/`;
bookmarks.add(folder);
types.forEach(type => bookmarks.add(folder + type));
if (oldColors?.[name]) colors[folder] = { ...colors[folder], color: oldColors[name] };
}
const workflowGroups = groupMap(data.sb_workflowNodeMap);
for (const [name, members] of Object.entries(workflowGroups)) {
workflowGroups[name] = [...new Set(members.map(legacyWorkflowPath))];
}
return {
bookmarks: [...bookmarks], colors,
templateGroups: groupMap(data.sb_templateNodeMap),
workflowGroups,
templateColors: parseLegacy(data.sb_ColorCustomTemplates, {}),
workflowColors: parseLegacy(data.sb_ColorCustomWorkflows, {}),
};
}
export function legacyWorkflowPath(value) {
try {
const bytes = Uint8Array.from(atob(value), character => character.charCodeAt(0));
const path = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
if (/^(\/|[a-z]:[\\/]|\\\\)/i.test(path)) return path.replaceAll("\\", "/");
} catch { /* Early versions stored names rather than encoded absolute paths. */ }
return value;
}
export function inspectWorkflow(workflow, registeredTypes) {
const definitions = workflow.definitions?.subgraphs ?? [];
const subgraphTypes = new Set(definitions.map(graph => graph.id));
const counts = new Map();
const visit = nodes => {
for (const node of nodes ?? []) {
if (typeof node.type !== "string") continue;
counts.set(node.type, (counts.get(node.type) ?? 0) + 1);
}
};
visit(workflow.nodes);
definitions.forEach(graph => visit(graph.nodes));
return [...counts].map(([type, count]) => ({
type, count, missing: !registeredTypes.has(type) && !subgraphTypes.has(type),
})).sort((a, b) => Number(b.missing) - Number(a.missing) || a.type.localeCompare(b.type));
}
export function templateClipboard(value) {
const data = typeof value === "string" ? JSON.parse(value) : structuredClone(value);
if (!Array.isArray(data.nodes)) throw new Error("Invalid node template");
if (data.reroutes) return data;
// The old LiteGraph format stores links by node-array index rather than node ID.
const nodes = data.nodes.map((node, index) => ({ ...node, id: String(index + 1) }));
const links = (data.links ?? []).filter(link => link[0] != null && nodes[link[0]] && nodes[link[2]])
.map((link, index) => ({ id: index + 1, origin_id: String(link[0] + 1), origin_slot: link[1],
target_id: String(link[2] + 1), target_slot: link[3], type: link[4] ?? "*" }));
return { nodes, links, groups: [], reroutes: [], subgraphs: [] };
}
export function mergeGroups(existing, incoming) {
const result = groupMap(existing);
for (const [name, members] of Object.entries(groupMap(incoming))) {
Object.defineProperty(result, name, { value: [...new Set([...(Object.hasOwn(result, name) ? result[name] : []), ...members])],
enumerable: true, writable: true, configurable: true });
}
return result;
}
+42
View File
@@ -0,0 +1,42 @@
.ns-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--fg-color, #ddd);
background: var(--comfy-menu-bg, #202020);
min-width: 0;
overflow-wrap: anywhere;
}
.ns-panel h3, .ns-panel h4, .ns-panel p { margin: 0; }
.ns-panel input, .ns-panel select, .ns-panel button {
color: inherit;
background: var(--comfy-input-bg, #303030);
border: 1px solid var(--border-color, #555);
border-radius: 6px;
padding: 8px;
min-height: 42px;
min-width: 0;
max-width: 100%;
font: inherit;
}
.ns-panel button { cursor: pointer; touch-action: manipulation; }
.ns-panel button:disabled { opacity: .5; cursor: default; }
.ns-panel :is(input, select, button):focus-visible { outline: 2px solid #6faaff; outline-offset: 2px; }
.ns-panel input[type="file"] { width: 100%; }
.ns-panel input[type="color"] { width: 48px; padding: 3px; }
.ns-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.ns-row > input:not([type="color"]), .ns-row > span { flex: 1 1 140px; }
.ns-row > select { flex: 1 1 140px; }
.ns-list, .ns-card, .ns-panel form { display: flex; flex-direction: column; gap: 8px; }
.ns-card { padding: 10px; border: 1px solid var(--border-color, #555); border-radius: 7px; }
.ns-card small, .ns-muted, .ns-status { opacity: .75; }
.ns-collection { border-inline-start: 3px solid var(--border-color, #555); padding-inline-start: 8px; }
.ns-collection > :not(summary) { margin-block: 8px; }
.ns-panel summary { min-height: 36px; padding: 8px 0; cursor: pointer; }
.ns-node-list { padding-inline-start: 18px; }
.ns-node-list li { margin-block: 8px; }
.ns-node-list button { margin-inline-start: 8px; }
.ns-missing { color: #ff9292; }
.ns-panel progress { width: 100%; }
.ns-panel pre { white-space: pre-wrap; font-size: 12px; }
+550
View File
@@ -0,0 +1,550 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { MODULES, settingId, syncModules, migrateLegacy, mergeGroups, groupMap, inspectWorkflow, templateClipboard } from "./core.js";
const DATA_FILE = "n-sidebar-extras.json";
const BOOKMARKS = "Comfy.NodeLibrary.Bookmarks.V2";
const COLORS = "Comfy.NodeLibrary.BookmarksCustomization";
const mounted = new Set();
const panels = new Set();
let ready = false;
function element(tag, text, className) {
const node = document.createElement(tag);
if (text != null) node.textContent = text;
if (className) node.className = className;
return node;
}
function notify(error) {
app.extensionManager.toast.add({ severity: "error", summary: "N-Sidebar", detail: error.message, life: 6000 });
}
function button(label, action) {
const node = element("button", label);
node.type = "button";
node.onclick = async () => {
node.disabled = true;
try { await action(); } catch (error) { notify(error); }
finally { node.disabled = false; }
};
return node;
}
function input(placeholder, type = "text") {
const node = element("input");
node.type = type;
node.placeholder = placeholder;
node.setAttribute("aria-label", placeholder);
return node;
}
function select(options, label) {
const node = element("select");
node.setAttribute("aria-label", label);
options.forEach(([value, text]) => {
const option = element("option", text);
option.value = value;
node.append(option);
});
return node;
}
function row(...nodes) {
const node = element("div", null, "ns-row");
node.append(...nodes);
return node;
}
async function json(route, options = {}) {
const response = await api.fetchApi(route, options);
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? `Request failed (${response.status})`);
return data;
}
const post = (route, data = {}) => json(route, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data),
});
async function readData() {
const response = await api.getUserData(DATA_FILE);
if (response.status === 404) return {};
if (!response.ok) throw new Error("Cannot read N-Sidebar user data");
return response.json();
}
async function saveData(data) {
await api.storeUserData(DATA_FILE, data);
}
function registeredTypes() {
return new Set(Object.keys(globalThis.LiteGraph.registered_node_types));
}
function preview(workflow) {
const node = element("details", null, "ns-preview");
node.append(element("summary", "Used nodes"));
const list = element("ul");
for (const item of inspectWorkflow(workflow, registeredTypes())) {
const text = `${item.type} × ${item.count}${item.missing ? " — missing" : ""}`;
list.append(element("li", text, item.missing && app.extensionManager.setting.get("NSidebar.HighlightMissing") ? "ns-missing" : ""));
}
node.append(list);
return node;
}
function downloadJson(name, data) {
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
const link = element("a");
link.href = url;
link.download = name;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function lifecycle(container, key) {
const root = element("section", null, "ns-panel");
root.dataset.module = key;
container.replaceChildren(root);
const controller = new AbortController();
let active = true;
const cleanup = [];
return { root, signal: controller.signal, alive: () => active,
onCleanup(fn) { cleanup.push(fn); },
dispose() { active = false; controller.abort(); cleanup.forEach(fn => fn()); root.remove(); },
};
}
async function collections(ctx, key) {
const templates = key === "Templates";
const groupKey = templates ? "templateGroups" : "workflowGroups";
const colorKey = templates ? "templateColors" : "workflowColors";
const root = ctx.root;
root.append(element("h3", templates ? "Template collections" : "External workflows"));
const search = input("Search name or collection");
const createName = input("New collection");
const list = element("div", null, "ns-list");
const status = element("p", "Loading…", "ns-status");
let data, items = [], roots = [], queryVersion = 0;
const keyFor = item => templates ? item.name : `${roots[item.root].replaceAll("\\", "/").replace(/\/$/, "")}/${item.path}`;
async function persist() {
await saveData(data);
draw();
}
const create = button("Create", async () => {
const name = createName.value.trim();
if (!name || Object.hasOwn(data[groupKey], name)) return;
Object.defineProperty(data[groupKey], name, { value: [], enumerable: true, configurable: true, writable: true });
createName.value = "";
await persist();
});
root.append(search, row(createName, create), status, list);
async function workflowFor(item) {
if (templates) return templateClipboard(item.data);
return json(`/n-sidebar/workflow?${new URLSearchParams({ root: item.root, path: item.path })}`, { signal: ctx.signal });
}
async function open(item) {
const graph = await workflowFor(item);
if (!ctx.alive()) return;
if (templates) {
const clipboardKey = "litegrapheditor_clipboard";
const previous = localStorage.getItem(clipboardKey);
try {
localStorage.setItem(clipboardKey, JSON.stringify(graph));
const canvas = app.canvas;
const position = [(-canvas.ds.offset[0] + canvas.canvas.width / canvas.ds.scale / 2),
(-canvas.ds.offset[1] + canvas.canvas.height / canvas.ds.scale / 2)];
canvas.pasteFromClipboard({ position });
} finally {
if (previous == null) localStorage.removeItem(clipboardKey);
else localStorage.setItem(clipboardKey, previous);
}
} else {
// A named load opens a native workflow tab and preserves the current workflow.
await app.loadGraphData(graph, true, true, item.name + ".json");
}
}
function draw() {
if (!ctx.alive()) return;
list.replaceChildren();
const query = search.value.toLocaleLowerCase();
const groups = [["", []], ...Object.entries(data[groupKey])];
for (const [name, members] of groups) {
const section = element("details", null, "ns-collection");
section.open = true;
section.append(element("summary", name || "Uncategorized"));
const color = data[colorKey]?.[name];
if (typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color)) section.style.borderInlineStartColor = color;
if (name) {
const colorInput = input("Collection color", "color");
colorInput.value = typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color) ? color : "#808080";
colorInput.onchange = async () => {
data[colorKey] = { ...data[colorKey], [name]: colorInput.value };
try { await persist(); } catch (error) { notify(error); }
};
const rename = input("Rename collection");
rename.value = name;
const options = element("details", null, "ns-collection-options");
options.append(element("summary", "Collection options"), row(rename, button("Rename", async () => {
const next = rename.value.trim();
if (!next || next === name) return;
if (Object.hasOwn(data[groupKey], next)) throw new Error("Collection already exists");
data[groupKey] = groupMap({ ...data[groupKey], [next]: members });
delete data[groupKey][name];
data[colorKey] = { ...data[colorKey], [next]: data[colorKey]?.[name] };
delete data[colorKey][name];
await persist();
})), row(colorInput, button("Remove collection", async () => {
delete data[groupKey][name];
if (data[colorKey]) delete data[colorKey][name];
await persist();
})));
section.append(options);
}
const membersFor = item => members.includes(keyFor(item)) || (!templates && members.includes(item.name));
const matching = items.filter(item => name ? membersFor(item)
: !Object.values(data[groupKey]).some(values => values.includes(keyFor(item)) || (!templates && values.includes(item.name))));
if (!name && !matching.length && groups.length > 1) continue;
if (name) matching.sort((a, b) => members.findIndex(member => member === keyFor(a) || member === a.name)
- members.findIndex(member => member === keyFor(b) || member === b.name));
let shown = 0;
for (const item of matching) {
if (query && !`${item.name} ${name} ${item.path ?? ""}`.toLocaleLowerCase().includes(query)) continue;
shown++;
const card = element("article", null, "ns-card");
card.append(element("strong", item.name));
if (!templates) card.append(element("small", item.path));
const membership = select([["", "Add to collection…"], ...Object.keys(data[groupKey]).map(group => [group, group])], "Add to collection");
membership.onchange = async () => {
if (!membership.value) return;
data[groupKey][membership.value] = [...new Set([...data[groupKey][membership.value], keyFor(item)])];
try { await persist(); } catch (error) { notify(error); }
};
card.append(row(button(templates ? "Insert" : "Open in tab", () => open(item)), membership));
if (name) card.append(row(button("↑", async () => {
const index = members.findIndex(member => member === keyFor(item) || member === item.name);
if (index > 0) [members[index - 1], members[index]] = [members[index], members[index - 1]];
await persist();
}), button("Remove from collection", async () => {
data[groupKey][name] = members.filter(member => member !== keyFor(item) && member !== item.name);
await persist();
})));
if (app.extensionManager.setting.get("NSidebar.Previews")) {
const holder = element("div");
card.append(button("Preview nodes", async () => {
const graph = await workflowFor(item);
if (ctx.alive() && card.isConnected) {
const details = preview(graph);
details.open = true;
holder.replaceChildren(details);
}
}), holder);
}
section.append(card);
}
if (!shown) section.append(element("p", "No matching items", "ns-muted"));
list.append(section);
}
}
async function refresh() {
const version = ++queryVersion;
status.textContent = "Loading…";
try {
const nextData = await readData();
let nextItems, nextRoots = [];
if (templates) {
const response = await api.getUserData("comfy.templates.json", { signal: ctx.signal });
if (response.status === 404) nextItems = [];
else if (!response.ok) throw new Error("Cannot read native node templates");
else nextItems = await response.json();
if (!Array.isArray(nextItems)) throw new Error("Invalid native template file");
} else {
const library = await json("/n-sidebar/workflows", { signal: ctx.signal });
nextItems = library.files;
nextRoots = library.roots;
}
if (!ctx.alive() || version !== queryVersion) return;
data = nextData;
data[groupKey] = groupMap(data[groupKey]);
roots = nextRoots;
// Sort collection items by their saved order before falling back to library order.
items = nextItems;
status.textContent = templates ? `${items.length} native node templates. Save templates from the canvas context menu.`
: roots.length ? `${items.length} workflows in ${roots.length} external libraries.`
: "Set NSIDEBAR_WORKFLOW_ROOTS on the server (one directory per line), or keep the old workflow paths in app/settings.json.";
draw();
} catch (error) {
if (error.name !== "AbortError" && ctx.alive()) status.textContent = error.message;
}
}
root.insertBefore(button("Refresh", refresh), status);
search.oninput = draw;
create.disabled = true;
await refresh();
create.disabled = !data;
}
function inspector(ctx) {
const root = ctx.root;
root.append(element("h3", "Workflow inspector"));
const search = input("Filter node types");
const output = element("div");
let source;
function draw() {
const graph = source ?? app.graph.serialize();
const entries = inspectWorkflow(graph, registeredTypes());
const missing = entries.filter(item => item.missing).length;
output.replaceChildren(element("p", `${entries.length} node types · ${missing} missing`));
const list = element("ul", null, "ns-node-list");
for (const item of entries.filter(item => item.type.toLowerCase().includes(search.value.toLowerCase()))) {
const li = element("li", `${item.type} × ${item.count}${item.missing ? " — missing" : ""}`,
item.missing && app.extensionManager.setting.get("NSidebar.HighlightMissing") ? "ns-missing" : "");
const nodes = app.graph._nodes.filter(node => node.type === item.type);
if (!source && nodes.length) li.append(button("Locate", () => {
app.canvas.selectNodes(nodes);
app.canvas.centerOnNode(nodes[0]);
}));
list.append(li);
}
output.append(list);
}
const file = input("Inspect workflow JSON", "file");
file.accept = ".json,application/json";
file.onchange = async () => {
try {
const candidate = JSON.parse(await file.files[0].text());
if (!Array.isArray(candidate.nodes)) throw new Error("Choose a ComfyUI workflow JSON");
source = candidate;
draw();
} catch (error) { notify(error); }
};
root.append(row(button("Inspect current workflow", () => { source = undefined; draw(); }),
button("Export node report", () => downloadJson("workflow-node-report.json", inspectWorkflow(source ?? app.graph.serialize(), registeredTypes())))),
file, search, output);
search.oninput = draw;
api.addEventListener("graphChanged", draw, { signal: ctx.signal });
draw();
}
function civitai(ctx) {
const root = ctx.root;
root.append(element("h3", "Civitai downloader"));
const query = input("Search Civitai models");
const type = select([["", "All types"], ...["Checkpoint", "LORA", "VAE", "Controlnet", "Upscaler", "TextualInversion"].map(value => [value, value])], "Model type");
const versionInput = input("Model version ID", "number");
const results = element("div", null, "ns-list");
const files = element("div");
const jobs = element("div");
const integrity = element("div");
let page = 1;
let queryVersion = 0;
let fileVersion = 0;
async function showVersion(id) {
const requestVersion = ++fileVersion;
const version = await json(`/n-sidebar/civitai/version/${id}`, { signal: ctx.signal });
const destinations = await json(`/n-sidebar/model-paths?${new URLSearchParams({ type: version.model.type })}`, { signal: ctx.signal });
if (!ctx.alive() || requestVersion !== fileVersion) return;
files.replaceChildren(element("h4", `${version.model.name} · ${version.name}`));
if (!destinations.paths.length) {
files.append(element("p", "No ComfyUI model folder configured for this type."));
return;
}
const destination = select(destinations.paths.map((path, index) => [index, path]), "Model destination");
destination.value = String(destinations.default);
files.append(destination);
for (const file of version.files) {
const size = Math.round((file.sizeKB ?? 0) / 1024);
files.append(row(element("span", `${file.name} (${size} MiB)`), button("Download", async () => {
await post("/n-sidebar/downloads", { version: version.id, file: file.id, destination: Number(destination.value) });
await progress();
})));
}
}
async function search() {
const version = ++queryVersion;
const data = await json(`/n-sidebar/civitai/search?${new URLSearchParams({ query: query.value, types: type.value, page })}`, { signal: ctx.signal });
if (!ctx.alive() || version !== queryVersion) return;
results.replaceChildren();
for (const model of data.items ?? []) {
const card = element("article", null, "ns-card");
card.append(element("strong", model.name), element("small", `${model.type} · ${model.creator?.username ?? ""}`));
const versions = select(model.modelVersions.map(item => [item.id, `${item.name} · ${item.baseModel}`]), "Model version");
card.append(versions, button("Choose file", () => showVersion(versions.value)));
results.append(card);
}
if (!data.items?.length) results.append(element("p", "No matching models"));
next.disabled = !data.metadata?.nextPage;
previous.disabled = page <= 1;
}
async function progress() {
const data = await json("/n-sidebar/downloads", { signal: ctx.signal });
if (!ctx.alive()) return;
jobs.replaceChildren(element("h4", "Downloads"));
for (const job of data) {
const card = element("article", null, "ns-card");
card.append(element("strong", job.name), element("span", `${job.status} · ${Math.round(job.received / 1048576)} MiB${job.total ? ` / ${Math.round(job.total / 1048576)} MiB` : ""}`));
if (job.error) card.append(element("p", job.error, "ns-missing"));
if (["queued", "running", "paused"].includes(job.status)) {
const bar = element("progress");
if (job.total) { bar.max = job.total; bar.value = job.received; }
card.append(bar, row(button(job.status === "paused" ? "Resume" : "Pause", async () => {
await post(`/n-sidebar/downloads/${job.id}/${job.status === "paused" ? "resume" : "pause"}`);
await progress();
}), button("Cancel", async () => { await post(`/n-sidebar/downloads/${job.id}/cancel`); await progress(); })));
}
jobs.append(card);
}
}
const previous = button("Previous", async () => { page = Math.max(1, page - 1); await search(); });
previous.disabled = true;
const next = button("Next", async () => { page++; await search(); });
next.disabled = true;
const form = element("form");
const submit = button("Search", async () => { page = 1; await search(); });
form.append(query, row(type, submit));
form.onsubmit = event => { event.preventDefault(); submit.click(); };
root.append(form, row(versionInput, button("Look up version", () => showVersion(versionInput.value))),
files, results, row(previous, next), jobs, integrity);
if (app.extensionManager.setting.get("NSidebar.Integrity")) {
const library = element("div", null, "ns-list");
integrity.append(element("h4", "Downloaded model integrity"),
button("Scan downloaded models", async () => {
const models = await json("/n-sidebar/models", { signal: ctx.signal });
if (!ctx.alive()) return;
library.replaceChildren();
for (const model of models) {
const status = element("span", model.status);
library.append(row(element("span", model.name), status, button("Verify SHA256", async () => {
const result = await post(`/n-sidebar/models/${model.id}/verify`);
if (ctx.alive()) status.textContent = result.status;
})));
}
if (!models.length) library.append(element("p", "No models downloaded by N-Sidebar v2 yet."));
}), library);
}
progress().catch(error => { if (error.name !== "AbortError" && ctx.alive()) notify(error); });
// Poll only while this native sidebar tab is mounted.
const timer = setInterval(() => progress().catch(error => {
if (error.name !== "AbortError" && ctx.alive()) jobs.textContent = error.message;
}), 2000);
ctx.onCleanup(() => clearInterval(timer));
}
function migration(ctx) {
const root = ctx.root;
root.append(element("h3", "Import & export"), element("p", "Import old pinned nodes and colored categories into the official node library. Existing bookmarks are kept. Template and external workflow collections are preserved here."));
const status = element("p", null, "ns-status");
async function applyLegacy(legacy) {
const migrated = migrateLegacy(legacy, app.extensionManager.setting.get(BOOKMARKS) ?? [], app.extensionManager.setting.get(COLORS) ?? {});
const data = await readData();
// Save a reversible backup before changing native bookmarks or collections.
await api.storeUserData("n-sidebar-before-import.json", {
bookmarks: app.extensionManager.setting.get(BOOKMARKS) ?? [],
colors: app.extensionManager.setting.get(COLORS) ?? {}, data,
});
for (const key of ["templateGroups", "workflowGroups"]) data[key] = mergeGroups(data[key], migrated[key]);
for (const key of ["templateColors", "workflowColors"]) data[key] = { ...data[key], ...migrated[key] };
await saveData(data);
await app.extensionManager.setting.set(BOOKMARKS, migrated.bookmarks);
await app.extensionManager.setting.set(COLORS, migrated.colors);
status.textContent = "Imported. Open the official node library to see your bookmarks and categories.";
}
const legacyFile = input("Import old settings.json", "file");
legacyFile.accept = ".json,application/json";
legacyFile.onchange = async () => {
try { await applyLegacy(JSON.parse(await legacyFile.files[0].text())); }
catch (error) { notify(error); }
};
const extrasFile = input("Import N-Sidebar extras JSON", "file");
extrasFile.accept = ".json,application/json";
extrasFile.onchange = async () => {
try {
const incoming = JSON.parse(await extrasFile.files[0].text());
if (incoming.version !== 2 || !incoming.data) throw new Error("Choose an N-Sidebar v2 export");
const data = await readData();
for (const key of ["templateGroups", "workflowGroups"]) data[key] = mergeGroups(data[key], incoming.data[key]);
for (const key of ["templateColors", "workflowColors"]) data[key] = { ...data[key], ...incoming.data[key] };
await saveData(data);
for (const [id, value] of Object.entries(incoming.settings ?? {})) {
if (settings.some(setting => setting.id === id) && typeof value === "boolean") await app.extensionManager.setting.set(id, value);
}
status.textContent = "Extra collections and module settings imported.";
} catch (error) { notify(error); }
};
root.append(button("Import old server settings", async () => {
const legacy = await json("/n-sidebar/legacy", { signal: ctx.signal });
if (!Object.keys(legacy).length) throw new Error("No legacy settings found. Choose your old settings.json file instead.");
await applyLegacy(legacy);
}), legacyFile, element("hr"), button("Export extras & settings", async () => downloadJson("n-sidebar-extras.json", {
version: 2, data: await readData(),
settings: Object.fromEntries(settings.map(setting => [setting.id, app.extensionManager.setting.get(setting.id)])),
})), extrasFile, status);
}
function diagnostics(ctx) {
const root = ctx.root;
root.append(element("h3", "Server diagnostics"));
const output = element("pre");
async function refresh() {
const stats = await json("/system_stats", { signal: ctx.signal });
if (ctx.alive()) output.textContent = JSON.stringify(stats, null, 2);
}
root.append(button("Refresh", refresh), output);
refresh().catch(error => { if (error.name !== "AbortError" && ctx.alive()) notify(error); });
}
function createPanel(key, container) {
const ctx = lifecycle(container, key);
panels.add(ctx);
const render = { Templates: () => collections(ctx, key), Workflows: () => collections(ctx, key),
Inspector: () => inspector(ctx), Civitai: () => civitai(ctx), Migration: () => migration(ctx), Diagnostics: () => diagnostics(ctx) };
Promise.resolve().then(render[key]).catch(error => { if (error.name !== "AbortError" && ctx.alive()) notify(error); });
return () => { panels.delete(ctx); ctx.dispose(); };
}
function reconcile() {
if (ready) syncModules(app.extensionManager, id => app.extensionManager.setting.get(id), createPanel, mounted);
}
function refreshPanels() {
// Render only the visible panels again after a preview/display setting changes.
for (const ctx of [...panels]) {
const parent = ctx.root.parentElement;
const key = ctx.root.dataset.module;
panels.delete(ctx);
ctx.dispose();
if (parent) {
const tab = app.extensionManager.getSidebarTabs().find(tab => tab.id === `n-sidebar-${key.toLowerCase()}`);
tab?.render(parent);
}
}
}
const settings = [
...MODULES.map(module => ({ id: settingId(module.key), name: module.title.replace("N · ", "") + " — enabled",
category: ["N-Sidebar", "Modules", module.key], type: "boolean", defaultValue: module.enabled, onChange: reconcile })),
{ id: "NSidebar.Previews", name: "Show node previews in template and workflow collections", category: ["N-Sidebar", "Display", "Previews"],
type: "boolean", defaultValue: true, onChange: refreshPanels },
{ id: "NSidebar.HighlightMissing", name: "Highlight missing node types", category: ["N-Sidebar", "Display", "Missing nodes"],
type: "boolean", defaultValue: true, onChange: refreshPanels },
{ id: "NSidebar.Integrity", name: "Show downloaded model integrity checks", category: ["N-Sidebar", "Modules", "Integrity"],
type: "boolean", defaultValue: true, onChange: refreshPanels },
];
app.registerExtension({
name: "Nuked.NSidebar.Extras", settings,
setup() {
const stylesheet = element("link");
stylesheet.rel = "stylesheet";
stylesheet.href = new URL("./sidebar.css", import.meta.url).href;
document.head.append(stylesheet);
ready = true;
reconcile();
},
});