├── .gitattributes ├── .gitignore ├── LICENSE ├── additional_style_files.md ├── changes.md ├── javascript └── main.js ├── readme.md ├── scripts ├── additionals.py ├── background.py ├── filemanager.py ├── install.py ├── main.py └── shared.py └── todo.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | scripts/Styles-Editor.code-workspace 154 | *.csv 155 | test.py 156 | lasthash.json 157 | backups/ 158 | notes.json 159 | .vscode/settings.json 160 | .DS_Store 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /additional_style_files.md: -------------------------------------------------------------------------------- 1 | # Working with additional style files 2 | If you have a lot of styles you might find it useful to break them up into categories. This functionality is available through the `Edit additional style files` checkbox (under `Advanced`). These additional style files are stored (as `.csv`) in `extensions/Styles-Editor/additonal_style_files`. 3 | 4 | ## Basic idea 5 | Any style that is in an additional style file gets renamed to `filename::stylename`. So if you put a style `Picasso` into an additional style file `Artists`, it will now have the name `Artists::Picasso`. 6 | 7 | ## Creating and removing additional style files 8 | Tick the `Edit additional style files` box, then use `--Create New--` in the dropdown menu to create the categories you want. If there are no additional style files when you tick the box, you will automatically be prompted to create one. 9 | 10 | Alternatively, add the prefix `title::` to a style in the master view: the additional style file `title` will be created for you when you next tick the `Edit additional style files` box. 11 | 12 | Empty additional style files are removed when you uncheck the `Edit additional style files` box. 13 | 14 | ## Saving 15 | The additional style files are autosaved as you edit. They are merged into the master style file when you uncheck the `Edit additional style files` box, or when you move to a different tab. 16 | 17 | ## Moving styles to or between an additional style file 18 | Select style or styles (with right click) then press `M` and enter the new prefix. 19 | 20 | -------------------------------------------------------------------------------- /changes.md: -------------------------------------------------------------------------------- 1 | # Recent changes 2 | 3 | ## 12 July 2023 4 | - Make mac command key work for cut, copy, paste 5 | - Fix delete of cell when no row selected 6 | - `D` to duplicate selected row(s) 7 | 8 | ## 9 July 2023 9 | - Allow backups to be downloaded 10 | 11 | ## 8 July 2023 12 | - Fixed some bugs 13 | - List existing backups to restore from 14 | 15 | ## 5 July 2023 16 | - Changes under the covers to increase performance 17 | - Automatically merge additional styles when moving to another tab 18 | 19 | ## 4 July 2023 20 | - Move styles to new additional style file 21 | 22 | ## 3 July 2023 23 | - Moved delete style functionality into an API call 24 | - Ctrl-right-click to select multiple rows 25 | 26 | ## 1 July 2023 27 | - Moved `create new additional style file` to the dropdown menu 28 | - Removed `merge into master` - now it happens automatically when `Edit additional` is unchecked 29 | - Fixed a crashing bug when a style had no name 30 | - Added a subtle color shading to indicate filter and encryption are active even when closed 31 | - Moved checkboxes to an `Advanced Options` accordian 32 | 33 | ## 30 June 2023 34 | - Delete from master list removes from additional style file as well 35 | - Major refactoring of code - sorry if I broke things, but it's going to be a lot easier going forward. 36 | 37 | ## 29 June 2023 38 | - Layout tweaks and minor fixes 39 | 40 | ## 28 June 2023 41 | - Allow linebreaks in styles (represented as `
` in editor) 42 | - Restore from backups 43 | 44 | ## 22 June 2023 45 | - Option to encrypt backups 46 | 47 | ## 21 June 2023 48 | - Automatically create new Additional Style Files if needed 49 | - Automatically delete empty Additional Style Files on merge 50 | - Added notes column back in 51 | - Fixed some minor bugs 52 | 53 | ## 20 June 2023 54 | - Regular backups created in `extensions/Styles-Editor/backups` (saved every ten minutes if changes have been made, last 12 retained) 55 | - Updated most of the documentation 56 | - Removed `Renumber Sort Column` button (just switch tabs and switch back!) 57 | - Removed `Extract from Master` button (automatically done when you go into additional style files view) 58 | 59 | ## 19 June 2023 60 | - Right-click can be used to select a row in the table (a style) 61 | - Delete the selected style by pressing `backspace`/`delete` -------------------------------------------------------------------------------- /javascript/main.js: -------------------------------------------------------------------------------- 1 | function api_post(path, payload, callback) { 2 | var xhr = new XMLHttpRequest(); 3 | xhr.open("POST", path, true); 4 | xhr.onload = function() { callback(JSON.parse(xhr.responseText)); }; 5 | xhr.setRequestHeader("Content-type", "application/json"); 6 | xhr.send(JSON.stringify(payload)); 7 | } 8 | 9 | function when_loaded() { 10 | api_post("/style-editor/check-api/", {}, function(x) { console.log( "Style Editor Check API", x['value'] )}); 11 | globalThis.selectedRows = []; 12 | grid = document.getElementById('style_editor_grid'); 13 | grid.addEventListener('keydown', function(event){ 14 | // if a key is pressed in a TD which has an INPUT child, or an INPUT, this is typing in a cell, allow it 15 | if (event.target.tagName === 'TD' && event.target.querySelector("input")) { return; } 16 | if (event.target.tagName === 'INPUT') { return; } 17 | 18 | if (event.ctrlKey === true || event.metaKey === true) { 19 | event.stopImmediatePropagation(); 20 | span = event.target.querySelector("span"); 21 | if (event.key === 'c') { 22 | navigator.clipboard.writeText(span.textContent); 23 | } 24 | if (event.key === 'x') { 25 | navigator.clipboard.writeText(span.textContent); 26 | update(event.target, ""); 27 | } 28 | if (event.key === 'v') { 29 | navigator.clipboard.readText().then((clipText) => (update(event.target,clipText))); 30 | } 31 | } 32 | 33 | // if M is pressed, move the selected styles 34 | if (event.key === "m" && globalThis.selectedRows.length > 0) { 35 | new_prefix = prompt("Move to style file:", ""); 36 | if (new_prefix != null) { 37 | globalThis.selectedRows.forEach( function(row) { 38 | api_post("/style-editor/move-style", 39 | {"style":{"value":row_style_name(row)}, "new_prefix":{"value":new_prefix}}, 40 | function(x){} ); 41 | }); 42 | document.getElementById("style_editor_handle_api").click(); 43 | unselect_rows(); 44 | } 45 | } 46 | 47 | // if D is pressed, duplicate the selected styles 48 | if (event.key === "d" && globalThis.selectedRows.length > 0) { 49 | globalThis.selectedRows.forEach( function(row) { 50 | api_post("/style-editor/duplicate-style", 51 | {"value":row_style_name(row)}, 52 | function(x){} ); 53 | }); 54 | document.getElementById("style_editor_handle_api").click(); 55 | unselect_rows(); 56 | } 57 | 58 | // if backspace or delete are pressed, delete selected rows 59 | if (event.key === "Backspace" || event.key === "Delete") { 60 | if (globalThis.selectedRows.length > 0) { 61 | globalThis.selectedRows.forEach( function(row) { 62 | api_post("/style-editor/delete-style", 63 | {"value":row_style_name(row)}, 64 | function(x){} ); 65 | }); 66 | document.getElementById("style_editor_handle_api").click(); 67 | } else { 68 | update(event.target,""); 69 | } 70 | globalThis.selectedRows = []; 71 | } 72 | 73 | // if we get to here, stop the keypress from propogating 74 | event.stopImmediatePropagation(); 75 | }, { capture: true }); 76 | 77 | grid.addEventListener('contextmenu', function(event){ 78 | if(event.shiftKey) { return; } 79 | row = event.target.closest("tr"); 80 | if (row) { select_row(row); event.stopImmediatePropagation(); event.preventDefault(); } 81 | }, { capture: true }); 82 | 83 | grid.addEventListener('click', function(event){ 84 | if (event.ctrlKey || event.metaKey) { 85 | row = event.target.closest("tr"); 86 | if (row) { select_row(row); event.stopImmediatePropagation(); event.preventDefault(); } 87 | } else { 88 | unselect_rows(); 89 | } 90 | }, { capture: true }); 91 | } 92 | 93 | function row_style_name(row) { 94 | return row.querySelectorAll("td")[1].querySelector("span").textContent; 95 | } 96 | 97 | function select_row(row) { 98 | globalThis.selectedRows.push(row); 99 | row.style.backgroundColor = "#840"; 100 | } 101 | 102 | function unselect_rows() { 103 | globalThis.selectedRows.forEach( function(row){ row.style.backgroundColor = ''; }) 104 | globalThis.selectedRows = [] 105 | } 106 | 107 | function press_refresh_button(tab) { 108 | b = document.getElementById("refresh_txt2img_styles"); 109 | if (b) {b.click()} 110 | b = document.getElementById("refresh_img2img_styles"); 111 | if (b) {b.click()} 112 | } 113 | 114 | function update(target, text) { 115 | // Update the cell in such a way as to get the backend to notice... 116 | // - generate a double click on the original target 117 | // - wait 10ms to make sure it has happened, then: 118 | // - paste the text into the input that has been created 119 | // - send a 'return' keydown event through the input 120 | const dblclk = new MouseEvent("dblclick", {"bubbles":true, "cancelable":true}); 121 | target.dispatchEvent(dblclk); 122 | setTimeout( function() { 123 | const the_input = target.querySelector('input'); 124 | the_input.value = text; 125 | const rtrn = new KeyboardEvent( "keydown", { 126 | 'key': 'Enter', 'target': the_input, 127 | 'view': window, 'bubbles': true, 'cancelable': true 128 | }); 129 | the_input.dispatchEvent(rtrn); 130 | }, 10); 131 | } 132 | 133 | function encryption_change(value) { 134 | accordian_style = document.getElementById('style_editor_encryption_accordian').style; 135 | if (value) { 136 | accordian_style.color = "#f88"; 137 | } else { 138 | accordian_style.color = "white"; 139 | } 140 | return value 141 | } 142 | 143 | function filter_style_list(filter_text, type) { 144 | if (type=="regex") { 145 | filter = document.getElementById('style_editor_filter').firstElementChild.lastElementChild; 146 | try { 147 | re = new RegExp(filter_text); 148 | filter.style.color="white"; 149 | } 150 | catch (error) { 151 | re = new RegExp(); 152 | filter.style.color="red"; 153 | } 154 | } 155 | accordian_style = document.getElementById('style_editor_filter_accordian').style; 156 | if (filter_text==="") { 157 | accordian_style.color = "white"; 158 | } else { 159 | accordian_style.color = "#f88"; 160 | } 161 | rows = document.getElementById('style_editor_grid').querySelectorAll("tr"); 162 | header = true; 163 | for (row of rows) { 164 | vis = false; 165 | for (cell of row.querySelectorAll("span")) { 166 | if ( (type=="Exact match" && cell.textContent.includes(filter_text)) || 167 | (type=="Case insensitive" && cell.textContent.toLowerCase().includes(filter_text.toLowerCase())) || 168 | (type=="regex" && cell.textContent.match(re)) ) 169 | { vis = true; }; 170 | } 171 | if (vis || header) { row.style.display = '' } else { row.style.display='none' } 172 | header = false; 173 | } 174 | return [filter_text, type] 175 | } 176 | 177 | function style_file_selection_change(x,y) { 178 | if (x==='--Create New--') { 179 | return [new_style_file_dialog(''),''] 180 | } 181 | return [x,''] 182 | } 183 | 184 | function new_style_file_dialog(x) { 185 | let filename = prompt("New style filename", ""); 186 | if (filename == null) { filename = "" } 187 | return filename; 188 | } 189 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Style Editor 2 | 3 | *Note that due to me moving across to ComfyUI this repository is no longer being maintained* 4 | 5 | An extension for Automatic1111 to add a Style Editor, which allows you to view and edit saved styles in a spreadsheet-like format. 6 | 7 | See also: 8 | - [Recent Changes](./changes.md "Recent Changes") 9 | - [To Do](/todo.md "To Do") 10 | - [Additional Style Files](/additional_style_files.md "Working with additional style files") 11 | 12 | ## Installation 13 | 14 | In Automatic1111 you can add this extension through the extensions index. 15 | 16 | Alternatively, paste the url `https://github.com/chrisgoringe/Styles-Editor` into the manual install URL box. 17 | 18 | Or clone the repository into your extensions folder. 19 | 20 | ## Gradio 21 | This extension requires `gradio 3.30` or above. Automatic1111 moved to this on May 19th 2023, so if you have updated since then you'll be fine. 22 | 23 | If you get an error `AttributeError: 'Dataframe' object has no attribute 'input'`, then you are probably still running `gradio 3.29`. Check before raising an issue! 24 | 25 | ## Basic Usage 26 | 27 | ### Edit styles 28 | Double-click in any of the boxes to get an edit cursor within the box. 29 | 30 | ### Search and replace 31 | Enter a search term and a replace term and press the button... 32 | 33 | ### Cut, copy, paste 34 | Click on a cell to select it, then use Ctrl-X, C and V. 35 | 36 | ### Delete styles 37 | Right-click on a style to select that row. Then hit `backspace` or `delete`. You can select multiple rows by ctrl-clicking. 38 | 39 | ### Add styles 40 | Use the `New row` button, and then edit the boxes as you need. Or select a row and press `D` to duplicate it. 41 | Note that if you have a filter applied the new row probably won't appear because it is empty, so best not to do that. 42 | 43 | ### Duplicate style names 44 | The editor will not allow styles to have the same name, so one or more 'x's will be appended to any duplicates. 45 | 46 | ### Save styles 47 | Styles are saved automatically. 48 | 49 | ### Filter view 50 | Type into the filter text box to only show rows matching the text string. Matches from any of the columns. Filter can be set to Exact match, case insensitive, or regex. 51 | If filtering by regex, if an invalid regex is entered it will be highlighted in red. 52 | 53 | ### Sorting 54 | The `sort` column is automatically generated whenever you save or load. If you select `autosort` (under `Advanced`) the table will automatically sort whenever you change any `sort` value (as long as every `sort` value is numeric). 55 | 56 | ### Notes 57 | You can put whatever you want in the notes column. 58 | 59 | ### Encryption 60 | Check the `Use encryption` box and all (subsequent) backups will be encrypted using the key you specify. 61 | Encryption is done using [pyAesCrypt](https://pypi.org/project/pyAesCrypt/). 62 | 63 | ### Backups 64 | The master style file is backed up every ten minutes (if changes have been made), with the most recent 24 backups retained. Backups are stored in `extensions/Styles-Editor/backups`. 65 | 66 | To restore a backup, drag and drop the backup style file into the `restore from backup` box, or select one of the backups from the dropdown (the names are date_time in format `YYMMDD_HHMM`). If it is encrypted (`.aes`) then the encryption key in the `Encryption` section is used to decrypt. 67 | 68 | To download a backup, select it from the dropdown then click the `download` link that appears in the upload/download box. 69 | 70 | ### Stargazers 71 | Thanks to those who've starred this - knowing people value the extension makes it worth working on. 72 | - 20 on 21 June 2023 73 | - 31 on 6 July 2023 74 | -------------------------------------------------------------------------------- /scripts/additionals.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | class Additionals: 4 | @classmethod 5 | def init(cls, default_style_file_path, additional_style_files_directory) -> None: 6 | cls.default_style_file_path = default_style_file_path 7 | cls.additional_style_files_directory = additional_style_files_directory 8 | 9 | @staticmethod 10 | def has_prefix(fullname:str): 11 | """ 12 | Return true if the fullname is prefixed. 13 | """ 14 | return ('::' in fullname) 15 | 16 | @staticmethod 17 | def split_stylename(fullname:str): 18 | """ 19 | Split a stylename in the form [prefix::]name into prefix, name or None, name 20 | """ 21 | if '::' in fullname: 22 | return fullname[:fullname.find('::')], fullname[fullname.find('::')+2:] 23 | else: 24 | return None, fullname 25 | 26 | @staticmethod 27 | def merge_name(prefix:str, name:str): 28 | """ 29 | Merge prefix and name into prefix::name (or name, if prefix is none or '') 30 | """ 31 | if prefix: 32 | return prefix+"::"+name 33 | else: 34 | return name 35 | 36 | @staticmethod 37 | def prefixed_style(maybe_prefixed_style: str, current_prefix:str, force=False): 38 | """ 39 | If force is False: 40 | If it has a prefix, return it. 41 | If not, use the one specified. If that is None or '', no prefix 42 | If force is True: 43 | use the prefix specified 44 | """ 45 | prefix, style = Additionals.split_stylename(maybe_prefixed_style) 46 | prefix = current_prefix if force else (prefix or current_prefix) 47 | return Additionals.merge_name(prefix, style) 48 | 49 | @classmethod 50 | def full_path(cls, filename:str) -> str: 51 | """ 52 | Return the full path for an additional style file. 53 | Input can be the full path, the filename with extension, or the filename without extension. 54 | If input is None, '', or the default style file path, return the default style file path 55 | """ 56 | if filename is None or filename=='' or filename==cls.default_style_file_path: 57 | return cls.default_style_file_path 58 | filename = filename+".csv" if not filename.endswith(".csv") else filename 59 | return os.path.relpath(os.path.join(cls.additional_style_files_directory,os.path.split(filename)[1])) 60 | 61 | @classmethod 62 | def display_name(cls, filename:str) -> str: 63 | """ 64 | Return the full path for an additional style file. 65 | Input can be the full path, the filename with extension, or the filename without extension 66 | """ 67 | fullpath = cls.full_path(filename) 68 | return os.path.splitext(os.path.split(fullpath)[1])[0] if fullpath!=cls.default_style_file_path else '' 69 | 70 | @classmethod 71 | def additional_style_files(cls, include_new, display_names): 72 | format = cls.display_name if display_names else cls.full_path 73 | additional_style_files = [format(f) for f in os.listdir(cls.additional_style_files_directory) if f.endswith(".csv")] 74 | return additional_style_files+["--Create New--"] if include_new else additional_style_files 75 | 76 | @classmethod 77 | def prefixes(cls): 78 | return cls.additional_style_files(include_new=False, display_names=True) -------------------------------------------------------------------------------- /scripts/background.py: -------------------------------------------------------------------------------- 1 | import time, threading 2 | 3 | class Background: 4 | """ 5 | A simple background task manager that considers doing a background task every n seconds. 6 | The task is only done if the manager has been marked as pending. 7 | """ 8 | def __init__(self, method, sleeptime) -> None: 9 | """ 10 | Create a manager that will consider calling `method` every `sleeptime` seconds 11 | """ 12 | self.method = method 13 | self.sleeptime = sleeptime 14 | self._pending = False 15 | self._started = False 16 | self.lock = threading.Lock() 17 | 18 | def start(self): 19 | """ 20 | Start the manager's thread 21 | """ 22 | with self.lock: 23 | if not self._started: 24 | threading.Thread(group=None, target=self._action, daemon=True).start() 25 | self._started = True 26 | 27 | def set_pending(self, pending=True): 28 | """ 29 | Set the task as pending. Next time the manager checks it will call `method` and then unset pending. 30 | """ 31 | with self.lock: 32 | self._pending = pending 33 | 34 | def _action(self): 35 | while True: 36 | with self.lock: 37 | if self._pending: 38 | self.method() 39 | self._pending = False 40 | time.sleep(self.sleeptime) 41 | -------------------------------------------------------------------------------- /scripts/filemanager.py: -------------------------------------------------------------------------------- 1 | # A bunch of utility methods to load and save style files 2 | import pandas as pd 3 | import numpy as np 4 | import os, json 5 | from typing import Dict 6 | from modules.shared import cmd_opts, opts, prompt_styles 7 | import modules.scripts as scripts 8 | import shutil 9 | import datetime 10 | from pathlib import Path 11 | try: 12 | import pyAesCrypt 13 | except: 14 | print("No pyAesCrypt - won't be able to do encryption") 15 | from scripts.additionals import Additionals 16 | from scripts.shared import columns, user_columns, display_columns, d_types, name_column 17 | 18 | class StyleFile: 19 | def __init__(self, prefix:str): 20 | self.prefix = prefix 21 | self.filename = Additionals.full_path(prefix) 22 | self.data:pd.DataFrame = self._load() 23 | 24 | def _load(self): 25 | try: 26 | data = pd.read_csv(self.filename, header=None, names=columns, 27 | encoding="utf-8-sig", dtype=d_types, 28 | skiprows=[0], usecols=[0,1,2]) 29 | except: 30 | data = pd.DataFrame(columns=columns) 31 | 32 | indices = range(data.shape[0]) 33 | data.insert(loc=0, column="sort", value=[i+1 for i in indices]) 34 | data.fillna('', inplace=True) 35 | data.insert(loc=4, column="notes", 36 | value=[FileManager.lookup_notes(data['name'][i], self.prefix) for i in indices]) 37 | if len(data)>0: 38 | for column in user_columns: 39 | data[column] = data[column].str.replace('\n', '
',regex=False) 40 | return data 41 | 42 | @staticmethod 43 | def sort_dataset(data:pd.DataFrame) -> pd.DataFrame: 44 | def _to_numeric(series:pd.Series): 45 | nums = pd.to_numeric(series) 46 | if any(nums.isna()): 47 | raise Exception("don't update display") 48 | return nums 49 | 50 | try: 51 | return data.sort_values(by='sort', axis='index', inplace=False, na_position='first', key=_to_numeric) 52 | except: 53 | return data 54 | 55 | def save(self): 56 | self.fix_duplicates() 57 | clone = self.data.copy() 58 | if len(clone)>0: 59 | for column in user_columns: 60 | clone[column] = clone[column].str.replace('
', '\n',regex=False) 61 | clone.to_csv(self.filename, encoding="utf-8-sig", columns=columns, index=False) 62 | 63 | def fix_duplicates(self): 64 | names = self.data['name'] 65 | used = set() 66 | for index, value in names.items(): 67 | if value in used: 68 | while value in used: 69 | value = value + "x" 70 | names.at[index] = value 71 | used.add(value) 72 | 73 | class FileManager: 74 | basedir = scripts.basedir() 75 | additional_style_files_directory = os.path.join(basedir,"additonal_style_files") 76 | backup_directory = os.path.join(basedir,"backups") 77 | if not os.path.exists(backup_directory): 78 | os.mkdir(backup_directory) 79 | if not os.path.exists(additional_style_files_directory): 80 | os.mkdir(additional_style_files_directory) 81 | 82 | try: 83 | default_style_file_path = cmd_opts.styles_file 84 | except: 85 | default_style_file_path = getattr(opts, 'styles_dir', None) 86 | current_styles_file_path = default_style_file_path 87 | 88 | Additionals.init(default_style_file_path=default_style_file_path, additional_style_files_directory=additional_style_files_directory) 89 | 90 | try: 91 | with open(os.path.join(basedir, "notes.json")) as f: 92 | notes_dictionary = json.load(f) 93 | except: 94 | notes_dictionary = {} 95 | 96 | encrypt = False 97 | encrypt_key = "" 98 | loaded_styles:Dict[str,StyleFile] = {} 99 | 100 | @classmethod 101 | def clear_style_cache(cls): 102 | """ 103 | Drop all loaded styles 104 | """ 105 | cls.loaded_styles = {} 106 | 107 | @classmethod 108 | def get_current_styles(cls): 109 | return cls.get_styles(cls._current_prefix()) 110 | 111 | @classmethod 112 | def using_additional(cls): 113 | return cls._current_prefix()!='' 114 | 115 | @classmethod 116 | def get_styles(cls, prefix='') -> pd.DataFrame: 117 | """ 118 | If prefix is '', this is the default style file. 119 | Load or retrieve from cache 120 | """ 121 | if not prefix in cls.loaded_styles: 122 | cls.loaded_styles[prefix] = StyleFile(prefix) 123 | return cls.loaded_styles[prefix].data.copy() 124 | 125 | @classmethod 126 | def save_current_styles(cls, data): 127 | cls.save_styles(data, cls._current_prefix()) 128 | 129 | @classmethod 130 | def save_styles(cls, data:pd.DataFrame, prefix=''): 131 | if not prefix in cls.loaded_styles: 132 | cls.loaded_styles[prefix] = StyleFile(prefix) 133 | cls.loaded_styles[prefix].data = data 134 | cls.loaded_styles[prefix].save() 135 | 136 | cls.update_notes_dictionary(data, prefix) 137 | cls.save_notes_dictionary() 138 | prompt_styles.reload() 139 | 140 | @staticmethod 141 | def create_file_if_missing(filename): 142 | filename = Additionals.full_path(filename) 143 | if not os.path.exists(filename): 144 | print("", file=open(filename,"w")) 145 | 146 | @staticmethod 147 | def add_or_replace(array:np.ndarray, row): 148 | for i in range(len(array)): 149 | if array[i][1] == row.iloc[1]: 150 | array[i] = row 151 | return array 152 | return np.vstack([array,row]) 153 | 154 | @classmethod 155 | def update_additional_style_files(cls): 156 | additional_files_as_numpy = { prefix : FileManager.get_styles(prefix=prefix).to_numpy() for prefix in Additionals.additional_style_files(include_new=False, display_names=True) } 157 | for _, row in cls.get_styles().iterrows(): 158 | prefix, row.iloc[1] = Additionals.split_stylename(row.iloc[1]) 159 | if prefix: 160 | if prefix in additional_files_as_numpy: 161 | additional_files_as_numpy[prefix] = cls.add_or_replace(additional_files_as_numpy[prefix], row) 162 | else: 163 | additional_files_as_numpy[prefix] = np.vstack([row]) 164 | for prefix in additional_files_as_numpy: 165 | cls.save_styles(pd.DataFrame(additional_files_as_numpy[prefix], columns=display_columns), prefix=prefix) 166 | 167 | @classmethod 168 | def merge_additional_style_files(cls): 169 | styles = cls.get_styles('') 170 | styles = styles.drop(index=[i for (i, row) in styles.iterrows() if Additionals.has_prefix(row.iloc[1])]) 171 | for prefix in Additionals.prefixes(): 172 | styles_with_prefix = cls.get_styles(prefix=prefix).copy() 173 | if len(styles_with_prefix)==0: 174 | os.remove(Additionals.full_path(prefix)) 175 | else: 176 | styles_with_prefix[name_column] = [Additionals.merge_name(prefix,x) for x in styles_with_prefix[name_column]] 177 | styles = pd.concat([styles, styles_with_prefix], ignore_index=True) 178 | styles['sort'] = [i+1 for i in range(len(styles['sort']))] 179 | cls.save_styles(styles) 180 | 181 | @classmethod 182 | def _current_prefix(cls): 183 | return Additionals.display_name(cls.current_styles_file_path) 184 | 185 | @classmethod 186 | def move_to_additional(cls, maybe_prefixed_style, new_prefix): 187 | old_prefixed_style = Additionals.prefixed_style(maybe_prefixed_style, cls._current_prefix()) 188 | new_prefixed_style = Additionals.prefixed_style(maybe_prefixed_style, new_prefix, force=True) 189 | data = cls.get_styles() 190 | data[name_column] = data[name_column].str.replace(old_prefixed_style, new_prefixed_style) 191 | cls.save_styles(data) 192 | cls.remove_from_additional(old_prefixed_style) 193 | cls.update_additional_style_files() 194 | 195 | @classmethod 196 | def remove_style(cls, maybe_prefixed_style): 197 | prefixed_style = Additionals.prefixed_style(maybe_prefixed_style, cls._current_prefix()) 198 | data = cls.get_styles() 199 | rows_to_drop = [i for (i, row) in data.iterrows() if row.iloc[1]==prefixed_style] 200 | cls.save_styles(data.drop(index=rows_to_drop)) 201 | cls.remove_from_additional(prefixed_style) 202 | cls.update_additional_style_files() 203 | 204 | @classmethod 205 | def duplicate_style(cls, maybe_prefixed_style): 206 | prefixed_style = Additionals.prefixed_style(maybe_prefixed_style, cls._current_prefix()) 207 | data = cls.get_styles() 208 | new_rows = pd.DataFrame([row for (i, row) in data.iterrows() if row.iloc[1]==prefixed_style]) 209 | data = pd.concat([data, new_rows], ignore_index=True) 210 | data = StyleFile.sort_dataset(data) 211 | cls.save_styles(data) 212 | cls.update_additional_style_files() 213 | 214 | @classmethod 215 | def remove_from_additional(cls, maybe_prefixed_style): 216 | prefix, style = Additionals.split_stylename(maybe_prefixed_style) 217 | if prefix: 218 | data = cls.get_styles(prefix) 219 | data = data.drop(index=[i for (i, row) in data.iterrows() if row.iloc[1]==style]) 220 | cls.save_styles(data, prefix=prefix) 221 | 222 | @classmethod 223 | def do_backup(cls): 224 | fileroot = os.path.join(cls.backup_directory, datetime.datetime.now().strftime("%y%m%d_%H%M")) 225 | if not os.path.exists(cls.default_style_file_path): 226 | return 227 | shutil.copyfile(cls.default_style_file_path, fileroot+".csv") 228 | paths = sorted(Path(cls.backup_directory).iterdir(), key=os.path.getmtime, reverse=True) 229 | for path in paths[24:]: 230 | os.remove(str(path)) 231 | if cls.encrypt and len(cls.encrypt_key)>0: 232 | try: 233 | pyAesCrypt.encryptFile(fileroot+".csv", fileroot+".csv.aes", cls.encrypt_key) 234 | os.remove(fileroot+".csv") 235 | except: 236 | print("Failed to encrypt") 237 | 238 | @classmethod 239 | def list_backups(cls): 240 | return [file for file in os.listdir(cls.backup_directory) if (file.endswith('csv') or file.endswith('aes'))] 241 | 242 | @classmethod 243 | def backup_file_path(cls, file): 244 | return os.path.join(cls.backup_directory, file) 245 | 246 | @classmethod 247 | def restore_from_backup(cls, file): 248 | path = cls.backup_file_path(file) 249 | if not os.path.exists(path): 250 | return "Invalid selection" 251 | if os.path.splitext(file)[1]==".aes": 252 | try: 253 | temp = os.path.join(cls.backup_directory, "temp.aes") 254 | temd = os.path.join(cls.backup_directory, "temp.csv") 255 | shutil.copyfile(file,temp) 256 | pyAesCrypt.decryptFile(temp, temd, cls.encrypt_key) 257 | os.rename(temd, cls.default_style_file_path) 258 | except: 259 | error = "Failed to decrypt .aes file" 260 | finally: 261 | if os.path.exists(temp): 262 | os.remove(temp) 263 | if os.path.exists(temd): 264 | os.remove(temd) 265 | else: 266 | shutil.copyfile(path, cls.default_style_file_path) 267 | return None 268 | 269 | 270 | @classmethod 271 | def restore_from_upload(cls, tempfile): 272 | error = None 273 | if os.path.exists(cls.default_style_file_path): 274 | if os.path.exists(cls.default_style_file_path+".temp"): 275 | os.remove(cls.default_style_file_path+".temp") 276 | os.rename(cls.default_style_file_path, cls.default_style_file_path+".temp") 277 | if os.path.splitext(tempfile)[1]==".aes": 278 | try: 279 | pyAesCrypt.decryptFile(tempfile, cls.default_style_file_path, cls.encrypt_key) 280 | except: 281 | error = "Failed to decrypt .aes file" 282 | elif os.path.splitext(tempfile)[1]==".csv": 283 | os.rename(tempfile, cls.default_style_file_path) 284 | else: 285 | error = "Can only restore from .csv or .aes file" 286 | if os.path.exists(cls.default_style_file_path+".temp"): 287 | if os.path.exists(cls.default_style_file_path): 288 | os.remove(cls.default_style_file_path+".temp") 289 | else: 290 | os.rename(cls.default_style_file_path+".temp", cls.default_style_file_path) 291 | return error 292 | 293 | @classmethod 294 | def save_notes_dictionary(cls): 295 | print(json.dumps(cls.notes_dictionary),file=open(os.path.join(cls.basedir, "notes.json"), 'w')) 296 | 297 | @classmethod 298 | def update_notes_dictionary(cls, data:pd.DataFrame, prefix:str): 299 | for _, row in data.iterrows(): 300 | stylename = prefix+"::"+row.iloc[1] if prefix!='' else row.iloc[1] 301 | cls.notes_dictionary[stylename] = row.iloc[4] 302 | 303 | @classmethod 304 | def lookup_notes(cls, stylename, prefix): 305 | stylename = prefix+"::"+stylename if prefix!='' else stylename 306 | return cls.notes_dictionary[stylename] if stylename in cls.notes_dictionary else '' -------------------------------------------------------------------------------- /scripts/install.py: -------------------------------------------------------------------------------- 1 | import launch 2 | 3 | if not launch.is_installed("pyAesCrypt"): 4 | launch.run_pip("install pyAesCrypt", "pyAesCrypt for Styles Editor") -------------------------------------------------------------------------------- /scripts/main.py: -------------------------------------------------------------------------------- 1 | import gradio as gr 2 | from fastapi import FastAPI, Request, status 3 | from fastapi.exceptions import RequestValidationError 4 | from fastapi.responses import JSONResponse 5 | from pydantic import BaseModel 6 | from typing import Tuple 7 | import modules.scripts as scripts 8 | from modules import script_callbacks 9 | 10 | import pandas as pd 11 | import threading 12 | 13 | from scripts.filemanager import FileManager, StyleFile 14 | from scripts.additionals import Additionals 15 | from scripts.background import Background 16 | from scripts.shared import display_columns 17 | 18 | class Script(scripts.Script): 19 | def __init__(self) -> None: 20 | super().__init__() 21 | 22 | def title(self): 23 | return "Style Editor" 24 | 25 | def show(self, is_img2img): 26 | return scripts.AlwaysVisible 27 | 28 | def ui(self, is_img2img): 29 | return () 30 | 31 | class ParameterString(BaseModel): 32 | value: str 33 | 34 | class ParameterBool(BaseModel): 35 | value: bool 36 | 37 | class StyleEditor: 38 | update_help = """# Recent changes: 39 | ## Changed in this update: 40 | - Make mac command key work for cut, copy, paste 41 | - Fix delete of cell when no row selected 42 | - Cmd / Ctrl / Right -click all select multiple rows 43 | - `D` to duplicate selected row 44 | 45 | ## Changed in recent updates: 46 | - Allow backups to be downloaded 47 | - Show backups in the `restore from backup` section 48 | - Automatically merge styles when changing away from this tab 49 | - Select row(s) then press `M` to move them 50 | - Ctrl-right-click to select multiple rows 51 | 52 | """ 53 | brief_guide = """Click to select cell. Dbl-click to edit cell. Cmd-, Ctrl-, ⌘- or right-click to select rows. 54 | 55 | `Backspace/Delete` to clear selected cell/delete row(s). Ctrl- or ⌘- `X` `C` `V` cut copy or paste selected cell (not row). 56 | `M` to move selected row(s). `D` to duplicate selected row(s)""" 57 | backup = Background(FileManager.do_backup, 600) 58 | api_calls_outstanding = [] 59 | api_lock = threading.Lock() 60 | this_tab_selected = False 61 | 62 | @classmethod 63 | def handle_this_tab_selected(cls): 64 | FileManager.clear_style_cache() 65 | FileManager.update_additional_style_files() 66 | cls.this_tab_selected = True 67 | return FileManager.get_current_styles() 68 | 69 | @classmethod 70 | def handle_another_tab_selected(cls): 71 | if cls.this_tab_selected: 72 | FileManager.merge_additional_style_files() 73 | FileManager.clear_style_cache() 74 | cls.this_tab_selected = False 75 | 76 | @classmethod 77 | def handle_autosort_checkbox_change(cls, data:pd.DataFrame, autosort) -> pd.DataFrame: 78 | if autosort: 79 | data = StyleFile.sort_dataset(data) 80 | FileManager.save_current_styles(data) 81 | return data 82 | 83 | @classmethod 84 | def handle_dataeditor_input(cls, data:pd.DataFrame, autosort) -> pd.DataFrame: 85 | cls.backup.set_pending() 86 | data = StyleFile.sort_dataset(data) if autosort else data 87 | FileManager.save_current_styles(data) 88 | return data 89 | 90 | @classmethod 91 | def handle_search_and_replace_click(cls, search:str, replace:str, current_data:pd.DataFrame): 92 | if len(search)==0: 93 | return current_data 94 | data_np = current_data.to_numpy() 95 | for i, row in enumerate(data_np): 96 | for j, item in enumerate(row): 97 | if isinstance(item,str) and search in item: 98 | data_np[i][j] = item.replace(search, replace) 99 | return pd.DataFrame(data=data_np, columns=display_columns) 100 | 101 | @classmethod 102 | def handle_use_additional_styles_box_change(cls, activate, filename): 103 | FileManager.current_styles_file_path = Additionals.full_path(filename) if activate else FileManager.default_style_file_path 104 | if activate: 105 | FileManager.update_additional_style_files() 106 | labels = Additionals.additional_style_files(display_names=True, include_new=True) 107 | selected = Additionals.display_name(FileManager.current_styles_file_path) 108 | selected = selected if selected in labels else labels[0] if len(labels)>0 else '' 109 | return gr.Row.update(visible=activate), FileManager.get_current_styles(), gr.Dropdown.update(choices=labels, value=selected) 110 | else: 111 | FileManager.merge_additional_style_files() 112 | return gr.Row.update(visible=activate), FileManager.get_current_styles(), gr.Dropdown.update() 113 | 114 | @classmethod 115 | def handle_style_file_selection_change(cls, prefix, _): 116 | if prefix: 117 | FileManager.create_file_if_missing(prefix) 118 | FileManager.current_styles_file_path = Additionals.full_path(prefix) 119 | else: 120 | prefix = Additionals.display_name(FileManager.current_styles_file_path) 121 | return FileManager.get_current_styles(), gr.Dropdown.update(choices=Additionals.additional_style_files(display_names=True, include_new=True), value=prefix) 122 | 123 | @classmethod 124 | def handle_use_encryption_checkbox_changed(cls, encrypt): 125 | FileManager.encrypt = encrypt 126 | return "" 127 | 128 | @classmethod 129 | def handle_encryption_key_change(cls, key): 130 | FileManager.encrypt_key = key 131 | 132 | @classmethod 133 | def handle_restore_backup_file_upload(cls, tempfile): 134 | return cls._after_backup_restore( FileManager.restore_from_upload(tempfile) ) 135 | 136 | @classmethod 137 | def handle_backup_restore_button_click(cls, selection): 138 | return cls._after_backup_restore( FileManager.restore_from_backup(selection) ) 139 | 140 | @classmethod 141 | def _after_backup_restore(cls, error): 142 | if error is None: 143 | FileManager.clear_style_cache() 144 | FileManager.update_additional_style_files() 145 | return gr.Text.update(visible=True, value="Styles restored"), False, FileManager.get_styles() 146 | else: 147 | return gr.Text.update(visible=True, value=error), False, FileManager.get_styles() 148 | 149 | @classmethod 150 | def handle_restore_backup_file_clear(cls): 151 | return gr.Text.update(visible=False) 152 | 153 | @classmethod 154 | def handle_backup_selection_change(cls, selection): 155 | if selection=="Refresh list" or selection=="---": 156 | return gr.Dropdown.update(choices=FileManager.list_backups()+["---","Refresh list"], value="---"), gr.File.update() 157 | else: 158 | return gr.Dropdown.update(choices=FileManager.list_backups()+["---","Refresh list"], value=selection), gr.File.update(value=FileManager.backup_file_path(selection)) 159 | 160 | @classmethod 161 | def handle_outstanding_api_calls(cls): 162 | with cls.api_lock: 163 | for command, value in cls.api_calls_outstanding: 164 | match command: 165 | case "delete": 166 | FileManager.remove_style(maybe_prefixed_style=value) 167 | case "move": 168 | FileManager.move_to_additional(maybe_prefixed_style=value[0], new_prefix=value[1]) 169 | case "duplicate": 170 | FileManager.duplicate_style(maybe_prefixed_style=value) 171 | cls.api_calls_outstanding = [] 172 | return FileManager.get_current_styles() 173 | 174 | @classmethod 175 | def on_ui_tabs(cls): 176 | with gr.Blocks(analytics_enabled=False) as style_editor: 177 | dummy_component = gr.Label(visible=False) 178 | with gr.Row(): 179 | cls.do_api = gr.Button(visible=False, elem_id="style_editor_handle_api") 180 | with gr.Column(scale=1, min_width=400): 181 | with gr.Accordion(label="Documentation and Recent Changes", open=False): 182 | gr.HTML(value="Link to Documentation") 183 | gr.Markdown(value=cls.update_help) 184 | gr.HTML(value="Change log") 185 | with gr.Column(scale=1, min_width=400): 186 | with gr.Accordion(label="Encryption", open=False, elem_id="style_editor_encryption_accordian"): 187 | cls.use_encryption_checkbox = gr.Checkbox(value=False, label="Use Encryption") 188 | cls.encryption_key_textbox = gr.Textbox(max_lines=1, placeholder="encryption key", label="Encryption Key") 189 | gr.Markdown(value="If checked, and a key is provided, backups are encrypted. The active style file and additional style files are not.") 190 | gr.Markdown(value="Files are encrypted using pyAesCrypt (https://pypi.org/project/pyAesCrypt/)") 191 | with gr.Column(scale=1, min_width=400): 192 | with gr.Accordion(label="Restore/Download backups", open=False): 193 | gr.Markdown(value="If restoring from an encrypted backup, enter the encrption key under `Encryption` first.") 194 | gr.Markdown(value="To restore: select a backup from the dropdown and press `Restore`, or upload a `.csv` or `.aes` file below.") 195 | gr.Markdown(value="To download: select a backup from the dropdown then download it from the box below.") 196 | with gr.Row(): 197 | cls.backup_selection = gr.Dropdown(choices=FileManager.list_backups()+["---","Refresh list"],value="---", label="Backups") 198 | cls.backup_restore_button = gr.Button(value="Restore") 199 | cls.restore_backup_file_upload = gr.File(file_types=[".csv", ".aes"], label="Upload / Download") 200 | cls.restore_result = gr.Text(visible=False, label="Result:") 201 | with gr.Column(scale=1, min_width=400): 202 | with gr.Accordion(label="Filter view", open=False, elem_id="style_editor_filter_accordian"): 203 | cls.filter_textbox = gr.Textbox(max_lines=1, interactive=True, placeholder="filter", elem_id="style_editor_filter", show_label=False) 204 | cls.filter_select = gr.Dropdown(choices=["Exact match", "Case insensitive", "regex"], value="Exact match", show_label=False) 205 | with gr.Column(scale=1, min_width=400): 206 | with gr.Accordion(label="Search and replace", open=False): 207 | cls.search_box = gr.Textbox(max_lines=1, interactive=True, placeholder="search for", show_label=False) 208 | cls.replace_box= gr.Textbox(max_lines=1, interactive=True, placeholder="replace with", show_label=False) 209 | cls.search_and_replace_button = gr.Button(value="Search and Replace") 210 | with gr.Column(scale=1, min_width=400): 211 | with gr.Accordion(label="Advanced options", open=False): 212 | cls.use_additional_styles_checkbox = gr.Checkbox(value=FileManager.using_additional(), label="Edit additional style files") 213 | cls.autosort_checkbox = gr.Checkbox(value=False, label="Autosort") 214 | with gr.Group(visible=False) as cls.additional_file_display: 215 | cls.style_file_selection = gr.Dropdown(choices=Additionals.additional_style_files(display_names=True, include_new=True), 216 | value=Additionals.display_name(''), 217 | label="Additional Style File", scale=1, min_width=200) 218 | with gr.Row(): 219 | gr.Markdown(cls.brief_guide) 220 | with gr.Row(): 221 | cls.dataeditor = gr.Dataframe(value=FileManager.get_current_styles(), col_count=(len(display_columns),'fixed'), 222 | wrap=True, max_rows=1000, show_label=False, interactive=True, elem_id="style_editor_grid") 223 | 224 | cls.search_and_replace_button.click(fn=cls.handle_search_and_replace_click, inputs=[cls.search_box, cls.replace_box, cls.dataeditor], outputs=cls.dataeditor) 225 | 226 | cls.filter_textbox.change(fn=None, inputs=[cls.filter_textbox, cls.filter_select], _js="filter_style_list") 227 | cls.filter_select.change(fn=None, inputs=[cls.filter_textbox, cls.filter_select], _js="filter_style_list") 228 | 229 | cls.use_encryption_checkbox.change(fn=cls.handle_use_encryption_checkbox_changed, inputs=[cls.use_encryption_checkbox], outputs=[dummy_component], _js="encryption_change") 230 | cls.encryption_key_textbox.change(fn=cls.handle_encryption_key_change, inputs=[cls.encryption_key_textbox], outputs=[]) 231 | cls.restore_backup_file_upload.upload(fn=cls.handle_restore_backup_file_upload, inputs=[cls.restore_backup_file_upload], outputs=[cls.restore_result, cls.use_additional_styles_checkbox, cls.dataeditor]) 232 | cls.restore_backup_file_upload.clear(fn=cls.handle_restore_backup_file_clear, inputs=[], outputs=[cls.restore_result]) 233 | cls.backup_selection.change(fn=cls.handle_backup_selection_change, inputs=[cls.backup_selection], outputs=[cls.backup_selection, cls.restore_backup_file_upload]) 234 | cls.backup_restore_button.click(fn=cls.handle_backup_restore_button_click, inputs=[cls.backup_selection], outputs=[cls.restore_result, cls.use_additional_styles_checkbox, cls.dataeditor]) 235 | cls.dataeditor.change(fn=None, inputs=[cls.filter_textbox, cls.filter_select], _js="filter_style_list") 236 | 237 | cls.dataeditor.input(fn=cls.handle_dataeditor_input, inputs=[cls.dataeditor, cls.autosort_checkbox], outputs=cls.dataeditor) 238 | cls.autosort_checkbox.change(fn=cls.handle_autosort_checkbox_change, inputs=[cls.dataeditor, cls.autosort_checkbox], outputs=cls.dataeditor) 239 | 240 | style_editor.load(fn=None, _js="when_loaded") 241 | style_editor.load(fn=cls.backup.start, inputs=[], outputs=[]) 242 | 243 | cls.use_additional_styles_checkbox.change(fn=cls.handle_use_additional_styles_box_change, inputs=[cls.use_additional_styles_checkbox, cls.style_file_selection], 244 | outputs=[cls.additional_file_display, cls.dataeditor, cls.style_file_selection]) 245 | cls.style_file_selection.change(fn=cls.handle_style_file_selection_change, inputs=[cls.style_file_selection, dummy_component], 246 | outputs=[cls.dataeditor,cls.style_file_selection], _js="style_file_selection_change") 247 | 248 | cls.do_api.click(fn=cls.handle_outstanding_api_calls,outputs=cls.dataeditor) 249 | 250 | return [(style_editor, "Style Editor", "style_editor")] 251 | 252 | @classmethod 253 | def on_app_started(cls, block:gr.Blocks, api:FastAPI): 254 | 255 | @api.post("/style-editor/delete-style/") 256 | def delete_style(stylename:ParameterString): 257 | with cls.api_lock: 258 | cls.api_calls_outstanding.append(("delete",stylename.value)) 259 | 260 | @api.post("/style-editor/duplicate-style/") 261 | def duplicate_style(stylename:ParameterString): 262 | with cls.api_lock: 263 | cls.api_calls_outstanding.append(("duplicate",stylename.value)) 264 | 265 | @api.post("/style-editor/move-style/") 266 | def move_style(style:ParameterString, new_prefix:ParameterString): 267 | with cls.api_lock: 268 | cls.api_calls_outstanding.append(("move",(style.value, new_prefix.value))) 269 | 270 | @api.post("/style-editor/check-api/") 271 | def check() -> ParameterBool: 272 | return ParameterBool(value=True) 273 | 274 | @api.exception_handler(RequestValidationError) 275 | async def validation_exception_handler(request: Request, exc: RequestValidationError): 276 | exc_str = f'{exc}'.replace('\n', ' ').replace(' ', ' ') 277 | content = {'status_code': 422, 'message': exc_str, 'data': None} 278 | return JSONResponse(content=content, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) 279 | 280 | with block: 281 | for tabs in block.children: 282 | if isinstance(tabs, gr.layouts.Tabs): 283 | for tab in tabs.children: 284 | if isinstance(tab, gr.layouts.Tab): 285 | if tab.id=="style_editor": 286 | tab.select(fn=cls.handle_this_tab_selected, outputs=cls.dataeditor) 287 | else: 288 | tab.select(fn=cls.handle_another_tab_selected) 289 | if tab.id=="txt2img" or tab.id=="img2img": 290 | tab.select(fn=None, inputs=tab, _js="press_refresh_button") 291 | 292 | script_callbacks.on_ui_tabs(StyleEditor.on_ui_tabs) 293 | script_callbacks.on_app_started(StyleEditor.on_app_started) -------------------------------------------------------------------------------- /scripts/shared.py: -------------------------------------------------------------------------------- 1 | name_column = 'name' 2 | columns = [name_column,'prompt','negative_prompt'] 3 | user_columns = ['prompt','negative_prompt','notes'] 4 | display_columns = ['sort', name_column,'prompt','negative_prompt','notes'] 5 | d_types = {name_column:str,'prompt':str,'negative_prompt':str} -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # Things I hope to add 2 | Feel free to open an issue if there's something else you'd like! 3 | 4 | ## Crawl (it's broken if this isn't done) 5 | 6 | ## Walk (really ought to do these) 7 | 8 | ## Run (these might be nice) 9 | - add style from txt2img or img2img prompts [https://github.com/chrisgoringe/Styles-Editor/issues/77] 10 | - copy selected styles between tabs not just prompt (not really this extension, but a pain) 11 | 12 | ## Fly (probably never going to happen, but just imagine...) 13 | - remote sharing of styles in a public database 14 | --------------------------------------------------------------------------------