Compare commits

..
163 Commits
Author SHA1 Message Date
newtextdoc1111 9cfd2aceb0 fix: Bump version to 1.11.0 2026-02-21 20:17:12 +09:00
newtextdoc1111 a06a794813 fix: Use Literal interpolation in localization files.
See Also: https://vue-i18n.intlify.dev/guide/essentials/syntax#literal-interpolation
2026-02-21 20:09:40 +09:00
newtextdoc1111 3b726b860c Merge pull request #68 from newtextdoc1111/dev
Merge Dev
2026-02-21 19:09:35 +09:00
newtextdoc1111 4066f58d25 feat: tooltip and doc for PrefixArtist 2026-02-21 19:03:02 +09:00
newtextdoc1111 ce07964a68 feat: Implement debounced search update for autocomplete 2026-02-19 19:38:48 +09:00
newtextdoc1111 d39a915888 Merge pull request #67 from sebagallo/patch-1
FIX: Support new 'element' prop in comfyui frontend
2026-02-18 22:22:45 +09:00
Sebastiano Gallo 5aa72379e8 outer return statement 2026-02-18 13:56:10 +01:00
newtextdoc1111 8257ad5109 Merge pull request #65 from bedovyy/feature/prefixArtist
feat: add prefixArtist for artist tags
2026-02-18 20:21:44 +09:00
newtextdoc1111 b8c250e836 fix: Update stylelint job config 2026-02-18 19:59:55 +09:00
Sebastiano Gallo 4be66d7132 FIX: Support new 'element' prop in comfyui frontend
The new comfyui-frontend versions don't use the 'inputEl' prop anymore, they use 'element' prop.
This change supports the new prop and is backwards compatible
2026-02-13 16:32:42 +01:00
bedovyy 7f089527c5 feat: add prefixArtist for artist tags 2026-02-06 11:24:12 +09:00
newtextdoc1111 7d8190ea07 fix: Bump version to 1.10.0 2025-12-26 22:43:57 +09:00
newtextdoc1111 9e2f174bcf Merge pull request #61 from newtextdoc1111/dev
Merge Dev
2025-12-26 22:34:24 +09:00
newtextdoc1111 199ee05f12 test: Enhance auto-formatter tests 2025-12-26 22:29:41 +09:00
newtextdoc1111 06f0157635 docs: Add "Trim Surrounding Spaces" setting doc 2025-12-26 22:26:40 +09:00
newtextdoc1111 f96fac44a2 fix: Update package-lock.json with new package versions 2025-12-26 22:15:11 +09:00
newtextdoc1111 729574cf3b Merge pull request #59 from BlakeNeko/feature/trim-surrounding-spaces
Feature/#58 trim surrounding spaces
2025-12-26 22:03:09 +09:00
BlakeNeko e4decf948b fix: Update formatting conditions in shouldAutoFormat function 2025-12-26 09:26:44 +08:00
BlakeNeko 24dd6901ed style: Fix missing indent in zh-CN, zh-TW and ja translation files 2025-12-26 09:09:50 +08:00
BlakeNeko aa192f51f0 fix: Enhance handling of empty input in formatPromptText function with trimSurroundingSpaces setting 2025-12-25 21:29:49 +08:00
BlakeNeko 300ee91cc1 fix: Improve handling of surrounding spaces in shouldAutoFormat and formatPromptText functions 2025-12-25 21:15:02 +08:00
BlakeNeko 3f3561a02b test: Add tests for trimSurroundingSpaces functionality in formatPromptText 2025-12-18 13:14:27 +08:00
BlakeNeko c3822bc4f8 feat: Add en/zh/zh-TW/jp translation for "trimSurroundingSpaces" option 2025-12-18 11:52:16 +08:00
BlakeNeko d480c586d0 feat: Implement trim surrounding spaces 2025-12-18 11:39:18 +08:00
BlakeNeko e0ce4beb21 feat: Add settings for trim surrounding spaces 2025-12-18 11:30:46 +08:00
newtextdoc1111 f8cbd27c5c docs: Replace screenshot 2025-12-01 03:02:55 +09:00
newtextdoc1111 17bad4da60 fix: Bump version to 1.9.0 2025-12-01 02:30:04 +09:00
newtextdoc1111 9d12afb48c Merge pull request #55 from newtextdoc1111/dev
Merge Dev
2025-12-01 02:21:27 +09:00
newtextdoc1111 091ec15b2a feat: Improve related tags selection with F1 key wiki access support 2025-12-01 01:41:53 +09:00
newtextdoc1111 c87d92d7f8 docs: Add "Use Trailing Comma" setting doc 2025-12-01 01:34:35 +09:00
newtextdoc1111 5d5da100e1 docs: Add Wiki page opening feature doc 2025-11-30 03:56:08 +09:00
newtextdoc1111 85b86c0618 feat: Add wiki icon functionality and related methods for tag information (#45) 2025-11-30 03:37:07 +09:00
newtextdoc1111 1f93ca0259 fix: Update color variables in CSS 2025-11-29 22:01:55 +09:00
newtextdoc1111 e0486313a6 test: Update test cases for useTrailingComma setting 2025-11-27 00:22:41 +09:00
newtextdoc1111 11b4eb48f0 Merge pull request #54 from BlakeNeko/feature/add_trailing_comma
Feature/add trailing comma
2025-11-27 00:04:59 +09:00
BlakeNeko 156d569614 refactor: Update condition to use 'useTrailingComma' setting instead of 'addTrailingComma' 2025-11-26 10:05:19 +08:00
BlakeNeko 4fdef7f436 refactor: Rename 'Add Trailing Comma' to 'Use Trailing Comma' and update in localization files 2025-11-26 10:04:26 +08:00
BlakeNeko 8189b2b75d refactor: Replace 'Add Trailing Comma' setting with 'Use Trailing Comma' 2025-11-26 10:01:41 +08:00
BlakeNeko 5a76096c7e feat: Add en/zh/zh-TW translation for "addTrailingComma" option 2025-11-23 11:32:28 +08:00
BlakeNeko b6d4fb5590 feat: Add setting for trailing comma in auto-formatting 2025-11-23 11:28:45 +08:00
newtextdoc1111 3f84125262 fix: Bump version  to 1.8.0 2025-11-22 17:56:55 +09:00
newtextdoc1111 906ba108cc fix: Add "AutoFormatter" category to localization files 2025-11-22 17:51:47 +09:00
newtextdoc1111 6d54bbc7ca Merge pull request #51 from newtextdoc1111/dev
Merge dev
2025-11-22 17:32:21 +09:00
newtextdoc1111 bda6f4fa9d docs Add node link 2025-11-22 17:30:36 +09:00
newtextdoc1111 2237b0cb52 docs: Add "Auto Format Trigger" doc 2025-11-22 17:20:40 +09:00
newtextdoc1111 6e23ad89b6 feat: Added translation for "Auto Format Trigger" option 2025-11-22 17:19:19 +09:00
newtextdoc1111 389a79cac9 Merge pull request #50 from newtextdoc1111/feature/auto_format_blocklist
Feature/auto format blocklist
2025-11-22 16:52:07 +09:00
newtextdoc1111 1c4e081f9a refactor: Comment out debug logs 2025-11-22 16:49:42 +09:00
newtextdoc1111 9c57062011 docs: Update shouldAutoFormat doc comment 2025-11-22 16:49:24 +09:00
newtextdoc1111 38975c11ab refactor: Extract node info retrieval into a separate function for cleaner event handlers 2025-11-22 00:05:14 +09:00
newtextdoc1111 8459e3dac9 test: Add unit tests for auto-formatting functions 2025-11-21 19:25:44 +09:00
newtextdoc1111 e7eab66d8f feat: Enhance shouldAutoFormat logic to include single-letter placeholders in numeric checks 2025-11-21 19:25:35 +09:00
newtextdoc1111 26772ac8dd feat: Add manual formatting trigger and settings for auto-formatting 2025-11-21 19:05:22 +09:00
newtextdoc1111 f68c0c7dc2 feat: Implement auto-formatting block logic 2025-11-20 22:00:15 +09:00
newtextdoc1111 79fb35ff8c docs: Add Auto Formatter feature documentation 2025-11-20 17:05:03 +09:00
newtextdoc1111 4f776d676a feat: Added translation for "Enable auto-format" option 2025-11-20 16:32:58 +09:00
newtextdoc1111 9f8078c408 refactor: Reorganized the AutoFormatterEventHandler class to make it consistent with other code 2025-11-20 16:12:39 +09:00
newtextdoc1111 ab2e83ec38 Merge pull request #49 from BlakeNeko/feature/auto_format_on_blur
Feature/#48 auto format on blur
2025-11-18 20:01:16 +09:00
BlakeNeko 2715f1f165 Merge branch 'newtextdoc1111:main' into feature/auto_format_on_blur 2025-11-18 11:53:17 +08:00
BlakeNeko 56f506110d refactor: use AutoFormatterEventHandler to maintain architectural consistency, format code indent 2025-11-18 11:15:02 +08:00
BlakeNeko 6785a2b6a5 Merge branch 'feature/auto_format_on_blur' of https://github.com/BlakeNeko/ComfyUI-Autocomplete-Plus into feature/auto_format_on_blur 2025-11-15 23:32:43 +08:00
BlakeNeko e8b0e44cf4 fix: Remove an invalid setting option 2025-11-15 23:31:59 +08:00
BlakeNeko 4b64fae70d fix: Simplify auto format setting name and description 2025-11-15 23:17:28 +08:00
BlakeNeko e622148b27 fix: Update settings to support internationalization properly 2025-11-15 22:25:48 +08:00
BlakeNeko 7a279d71c3 refactor: Moving the enableAutoFormat setting 2025-11-15 22:15:59 +08:00
BlakeNeko ee8b265173 feat: Preserving the previous cursor position 2025-11-15 22:13:59 +08:00
BlakeNeko 80d254c6b4 refactor: Move formatPromptText and formatTextareaOnBlur functions from utils.js to auto-formatter.js 2025-11-15 22:11:46 +08:00
newtextdoc1111 8f6d451fef fix: Bump version to 1.7.2 2025-11-13 22:14:50 +09:00
newtextdoc1111 f2920add1d fix: Add tagCategory data attribute for CSS styling 2025-11-13 18:44:43 +09:00
newtextdoc1111 dbb5f66a1f fix: Bump version to 1.7.1 2025-11-13 17:06:04 +09:00
newtextdoc1111 cacee5ddad fix: Use index-based approach for tag data retrieval 2025-11-13 17:01:03 +09:00
BlakeNeko 7f23b02812 feat: add a helper function and trigger it on textarea blur 2025-11-13 11:24:57 +08:00
BlakeNeko edbea2bb43 feat: add auto format settings 2025-11-13 11:21:14 +08:00
BlakeNeko 70f916b825 feat: implement prompt auto-formatter function
- Splits text by lines and cleans up tags separated by commas
- Removes redundant spaces and empty tags
- Ensures each valid line ends with a comma and space
- Preserves empty lines for readability
2025-11-13 11:11:02 +08:00
newtextdoc1111 42f5e73515 fix: Bump version  to 1.7.0 2025-11-12 22:09:17 +09:00
newtextdoc1111 52e5d2fd5f Merge pull request #47 from newtextdoc1111/dev
Merge Dev
2025-11-12 22:03:26 +09:00
newtextdoc1111 397c90275f docs: Add auto insert comma setting to README 2025-11-12 21:37:14 +09:00
newtextdoc1111 2c8877060c feat: Added translations for new settings 2025-11-12 21:05:46 +09:00
newtextdoc1111 1203e2f0ef feat: Add Chinese and Traditional Chinese localization files (machine translated) 2025-11-12 20:35:15 +09:00
newtextdoc1111 f2ff83088d feat: Add Japanese localization files and update setting descriptions in README 2025-11-12 20:35:14 +09:00
newtextdoc1111 d6f53ebb0f fix: Update setting IDs to follow i18n-friendly naming conventions 2025-11-12 20:35:13 +09:00
newtextdoc1111 2552e8d746 feat: Implement addWeightToLora function (#46) 2025-11-12 20:19:27 +09:00
newtextdoc1111 96ba7d8055 feat: Add auto insert comma setting for tag insertion (#44) 2025-11-12 20:00:35 +09:00
newtextdoc1111 65960a2394 fix: Bump version to 1.6.1 2025-10-29 15:54:45 +09:00
newtextdoc1111 92f01b84a0 docs: Add bulk tag insertion section 2025-10-29 15:53:34 +09:00
newtextdoc1111 3f8eb3bc0c Merge pull request #43 from newtextdoc1111/dev
Merge Dev
2025-10-29 14:55:37 +09:00
newtextdoc1111 fe42e198d2 fix: Fix tag normalization to handle multi-tags (#36) 2025-10-29 14:52:31 +09:00
newtextdoc1111 98d9fdfc50 fix: Bump version to 1.6.0 2025-10-26 03:30:50 +09:00
newtextdoc1111 504b681ab8 Merge pull request #42 from newtextdoc1111/dev
Merge Dev
2025-10-26 03:27:03 +09:00
newtextdoc1111 aca0ed6326 feat: Add setting to replace underscores with spaces in tag insertion (#39) 2025-10-24 06:16:11 +09:00
newtextdoc1111 ab941ab329 chore: update actions/setup-node to version 6 2025-10-24 06:03:52 +09:00
newtextdoc1111 c4e408eede fix: update Node.js version to 22 in CI workflow 2025-10-22 08:55:04 +09:00
newtextdoc1111 f8c86203e4 chore: update babel-jest and jest to version 30.2.0 2025-10-22 08:30:18 +09:00
newtextdoc1111 5a4ded44b8 feat: Add testing support for autocomplete functions 2025-10-22 08:29:48 +09:00
newtextdoc1111 295d0c0cdc fix: Add null checks to autocomplete functions 2025-10-22 04:15:13 +09:00
newtextdoc1111 37ce793208 fix: Include hyphens in tag encoder split regex 2025-10-22 04:06:56 +09:00
newtextdoc1111 2386fc7aa0 fix: Optimize CSV loading process 2025-10-22 04:01:59 +09:00
newtextdoc1111 937c5bb283 fix: Fix TagData creation for invalid tag in RelatedTagsUI 2025-10-19 23:39:45 +09:00
newtextdoc1111 512dd88d7a docs: Add display settings documentation 2025-10-18 05:19:31 +09:00
newtextdoc1111 096314933d fix: Bump version to 1.5.0 2025-10-18 02:58:02 +09:00
newtextdoc1111 05c890ee37 Merge pull request #41 from newtextdoc1111/dev
Merge Dev
2025-10-18 02:52:46 +09:00
newtextdoc1111 d4725dfebc feat: Add option to hide aliases in autocomplete and related tags display (#33) 2025-10-18 02:38:00 +09:00
newtextdoc1111 c4d5eee89d feat: Bump version to 1.4.1 2025-10-14 23:54:20 +09:00
newtextdoc1111 fa4d87ee77 Merge pull request #40 from newtextdoc1111/dev
Merge Dev
2025-10-14 23:49:43 +09:00
newtextdoc1111 3cb5a41cc8 fix: Temporarily comment out CSV update check button until remote file detection is properly implemented 2025-10-14 23:46:41 +09:00
newtextdoc1111 16a325b798 fix: Temporarily comment out remote file modification checks in the downloader 2025-08-30 23:40:27 +09:00
newtextdoc1111 e3171940fb fix: Removed the process that periodically checks for CSV file updates. 2025-08-30 23:39:52 +09:00
newtextdoc1111 8db37967f8 fix: Bump version to 1.4.0 2025-07-28 16:01:58 +09:00
newtextdoc1111 e43e51b48a Merge pull request #31 from newtextdoc1111/feature/#26_embedding_and_lora_autocompletion
Feature/#26 embedding and lora autocompletion
2025-07-28 16:00:37 +09:00
newtextdoc1111 7b89683bfc fix: Change to toLowerCase to simplify string normalization 2025-07-28 15:45:23 +09:00
newtextdoc1111 72b9bd94ee fix: Improved the encoder to provide more accurate suggestions 2025-07-28 14:05:49 +09:00
newtextdoc1111 8a4a94bc7e docs: Added Lora and Emb suggestions to README 2025-07-28 05:36:53 +09:00
newtextdoc1111 67b7b334b8 feat: Add embeddings and loras autocompletion 2025-07-28 05:21:57 +09:00
newtextdoc1111 9cdc18e566 Merge pull request #30 from newtextdoc1111/feature/improved_fast_search_behavior
Feature/improved fast search behavior
2025-07-24 03:59:49 +09:00
newtextdoc1111 3bbdb0312d bump: Update version to 1.3.1 2025-07-24 03:56:37 +09:00
newtextdoc1111 5b6fb8f490 refactor: Separates autocomplete logic into two methods: "sequentialSearch" and "searchWithFlexSearch" 2025-07-23 12:39:52 +09:00
newtextdoc1111 83dc2585ed fix: Correct formatting 2025-07-23 11:14:23 +09:00
newtextdoc1111 7e10c106d4 feat: Add env-cmd for managing environment variables in test scripts 2025-07-23 11:13:43 +09:00
newtextdoc1111 bf7823e985 fix: Increase the search result limit for FlexSearch to provide more accurate suggestions. 2025-07-23 11:12:23 +09:00
newtextdoc1111 6c29336435 feat: Optimized FlexSearch document and encoder settings to improve autocompletion 2025-07-23 07:47:52 +09:00
newtextdoc1111 083c1f5acc feat: add FlexSearch integration tests 2025-07-23 07:47:52 +09:00
newtextdoc1111 bdd86ab04d Merge pull request #29 from newtextdoc1111/dev
Merge Dev
2025-07-19 09:30:49 +09:00
newtextdoc1111 bafce3ca47 fix: Remove unused Charset import from FlexSearch module 2025-07-19 09:29:14 +09:00
newtextdoc1111 1729d5c4f7 docs: Fix link format 2025-07-19 09:11:12 +09:00
newtextdoc1111 d0dfcc031a fix: Bump version to 1.3.0 2025-07-19 09:03:43 +09:00
newtextdoc1111 ab2a4bedb3 docs: Add 'Use Fast Search' documentation 2025-07-19 08:59:45 +09:00
newtextdoc1111 6e7ebdae0a feat: Add tooltip for 'Use Fast Search' setting 2025-07-19 08:41:48 +09:00
newtextdoc1111 bd12dd933f docs: Add comment to fast search code 2025-07-19 08:39:07 +09:00
newtextdoc1111 784f7019ce Merge pull request #28 from IceSandwich/fast_search
Improve the performance
2025-07-17 22:30:53 +09:00
IceSandwich 6b34f3876f change flexSearchLimit to flexSearchLimitMultiplier 2025-07-17 20:51:59 +08:00
IceSandwich 59938c08d0 calculate the search limit from CSV files 2025-07-17 11:12:24 +08:00
IceSandwich 9c6215139b display actual candidate count when using fast search 2025-07-17 10:12:15 +08:00
IceSandwich cfda834141 Merge branch 'dev' into fast_search 2025-07-17 09:59:44 +08:00
IceSandwich b4175e700b fix the issue when input in tag and alias at the same time 2025-07-16 15:46:22 +08:00
IceSandwich b6c03233ba fix the behavior of matching words 2025-07-16 14:20:51 +08:00
IceSandwich 385f9d1c4f remove period handling completely 2025-07-16 10:58:03 +08:00
newtextdoc1111 f019960d34 fix: Remove unneccesary period handling in findAllTagPositions 2025-07-16 08:14:33 +09:00
newtextdoc1111 1d85f3e284 feat: Add 'Use Fast Search' setting to autocomplete options 2025-07-16 08:01:45 +09:00
newtextdoc1111 e072832216 fix: Improve sorting logic in searchCompletionCandidates for better tag matching 2025-07-16 08:01:11 +09:00
newtextdoc1111 a374ca6c11 feat: Refactor autocomplete data handling to build FlexSearch index after tags are loaded 2025-07-16 08:00:24 +09:00
IceSandwich 8750b5dcac fix indents 2025-07-14 14:48:20 +08:00
IceSandwich 456bc59240 improve performance by changing the search engine to flexsearch 2025-07-14 14:36:01 +08:00
newtextdoc1111 c3e661439f Merge pull request #27 from newtextdoc1111/bugfix/#25_fix_natural_language_performance
Bugfix/#25 fix natural language performance
2025-07-13 22:57:01 +09:00
newtextdoc1111 ea2ff76fab fix: Bump version to 1.2.1 2025-07-13 22:48:48 +09:00
newtextdoc1111 8b38ade1b9 fix: Add optional chaining to prevent errors in isLongText function 2025-07-13 22:48:36 +09:00
newtextdoc1111 944ecff9ce fix: Implement maximum tag length check for performance issue 2025-07-13 22:21:21 +09:00
newtextdoc1111 a9273fb74d docs: Update README to clarify steps for disabling CSV update check on startup 2025-07-06 20:01:09 +09:00
newtextdoc1111 cf30c94faa Merge pull request #24 from newtextdoc1111/feature/#23_add_check_csv_updates_on_startup_settings 2025-07-06 19:17:09 +09:00
newtextdoc1111 828c08df0c fix: Bump version to 1.2.0 in pyproject.toml 2025-07-06 19:11:12 +09:00
newtextdoc1111 3dbfd29274 docs: Added disable CSV update check on startup section 2025-07-06 19:10:06 +09:00
newtextdoc1111 86527e35b4 feat: Add check_updates_on_startup option to control CSV update checks 2025-07-06 19:08:24 +09:00
newtextdoc1111 1bc609893b refactor: Use constant for HuggingFace URL 2025-07-06 19:07:55 +09:00
newtextdoc1111 a536d58cf2 docs: Remove recent updates section from README files 2025-07-06 18:23:04 +09:00
newtextdoc1111 4c921da6cb fix: Update CSV file status logging format for better readability 2025-07-05 14:29:30 +09:00
newtextdoc1111 98bf626ec1 refactor: Format code with ruff and update configuration 2025-07-05 13:40:17 +09:00
newtextdoc1111 57f80f0991 Merge pull request #22 from newtextdoc1111/feature/#21-add_tag_alias_to_header
Feature/#21 add tag alias to related tags display header
2025-06-14 17:48:14 +09:00
newtextdoc1111 31f8fe4517 fix: Update version to 1.1.0 in pyproject.toml 2025-06-14 17:44:32 +09:00
newtextdoc1111 292c99ae17 fix: Remove unused CSS classes for related tags layout 2025-06-10 04:10:08 +09:00
newtextdoc1111 86b63047c2 feat: Add tag aliases to related tags display 2025-06-10 04:05:22 +09:00
newtextdoc1111 5e4b4fae04 Merge pull request #20 from newtextdoc1111/dev
Merge Dev
2025-05-29 18:07:45 +09:00
newtextdoc1111 7a73a5b1b3 fix: Bump version to 1.0.2 in pyproject.toml 2025-05-29 17:59:07 +09:00
newtextdoc1111 47635d5aea docs: Update user CSV instructions with naming conventions and console log details 2025-05-29 17:52:09 +09:00
newtextdoc1111 4415e22744 fix: Log CSV file status in ComfyUI console and browser network tab 2025-05-29 17:51:50 +09:00
33 changed files with 5439 additions and 1316 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"test": {
"NODE_OPTIONS": "--experimental-vm-modules"
}
}
+16 -6
View File
@@ -12,9 +12,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: '18' node-version: '22'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
@@ -28,7 +28,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - name: Checkout code
- uses: actions-hub/stylelint@master uses: actions/checkout@v4
env:
PATTERN : 'web/css/*.css' - name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run stylelint
run: npx stylelint "web/css/*.css"
+124 -13
View File
@@ -2,29 +2,24 @@
## English • [日本語](docs/README_jp.md) ## English • [日本語](docs/README_jp.md)
![ss01](https://github.com/user-attachments/assets/bb139951-ad78-4d87-b290-97aafa1221d7) ![ss01](https://github.com/user-attachments/assets/45dd0598-4c04-49ab-85f7-33fc9026921c)
## Overview ## Overview
**ComfyUI-Autocomplete-Plus** is a custom node that provides multiple input assistance features for any text area in [ComfyUI](https://github.com/comfyanonymous/ComfyUI). Currently, it supports Danbooru and e621 tags (e621 does not support some functions). **ComfyUI-Autocomplete-Plus** is a custom node that provides multiple input assistance features for any text area in [ComfyUI](https://github.com/comfyanonymous/ComfyUI). Currently, it supports Danbooru and e621 tags (e621 does not support some functions).
## Recent Updates :fire:
- Added automatic and manual update check functionality for CSV files
- Fixed bug where autocomplete suggestions wouldn't display at certain timings with Microsoft IME
- Support for loading and displaying e621 tag CSV
## Features ## Features
- **:zap:No setup required**: Automatically downloads CSV data optimized for Danbooru tags. - **:zap:No setup required**: Automatically downloads CSV data optimized for Danbooru tags.
- **:mag:Autocomplete**: Displays tag suggestions in real-time based on your input as you type. - **:mag:Autocomplete**: Displays tag suggestions in real-time based on your input as you type.
- **:file_cabinet:Related Tags Display**: Shows a list of tags highly related to the selected tag. - **:file_cabinet:Related Tags Display**: Shows a list of tags highly related to the selected tag.
- **:triangular_ruler:Auto Formatter**: Automatically formats prompt text when the textarea loses focus, cleaning up extra spaces and commas.
- **:earth_asia:Multilingual Support**: Supports input completion in Japanese, Chinese, and Korean. - **:earth_asia:Multilingual Support**: Supports input completion in Japanese, Chinese, and Korean.
- **:computer_mouse:Intuitive Operation**: - **:computer_mouse:Intuitive Operation**:
- Supports both mouse and keyboard operations. - Supports both mouse and keyboard operations.
- Natural tag insertion that considers cursor position and existing text. - Natural tag insertion that considers cursor position and existing text.
- **:art:Design**: Supports both light and dark themes of ComfyUI. - **:art:Design**: Supports both light and dark themes of ComfyUI.
- **:pencil:User CSV**: Allows users to add their own CSV files for autocomplete suggestions. - **:pencil:User CSV**: Allows users to add their own CSV files for autocomplete suggestions.
- **Zero Dependencies**: All input assistance processing works in the browser without external libraries.
## Installation ## Installation
@@ -47,6 +42,8 @@ When you type in a text input area, tags that partially match the text are displ
- Tags are color-coded by category. The color-coding rules are the same as Danbooru. - Tags are color-coded by category. The color-coding rules are the same as Danbooru.
- Tags that have already been entered are displayed grayed out. - Tags that have already been entered are displayed grayed out.
- You can display Danbooru and e621 tags at the same time. You can also change the priority from the settings. - You can display Danbooru and e621 tags at the same time. You can also change the priority from the settings.
- Supports autocomplete for Lora and Embedding inputs. You can enable/disable this feature in the settings.
- Clicking the 📖 icon opens the tag's Wiki page. If a tag is selected via keyboard, you can open it with the `F1` key.
## Related Tags ## Related Tags
@@ -57,9 +54,24 @@ When you select any tag in a text input area, a list of highly related tags is d
- The display position is primarily at the bottom of the text area and automatically adjusts vertically based on available space. - The display position is primarily at the bottom of the text area and automatically adjusts vertically based on available space.
- You can switch between vertical and horizontal display positions using the "↕️|↔️" button in the header. - You can switch between vertical and horizontal display positions using the "↕️|↔️" button in the header.
- You can toggle the pinned state of the displayed related tags using the "📌|🎯" button in the header. To close the UI when pinned, press the Esc key. - You can toggle the pinned state of the displayed related tags using the "📌|🎯" button in the header. To close the UI when pinned, press the Esc key.
- Clicking the tag in the header opens the tag's Wiki page.
- Tags that have already been entered are displayed grayed out. If you try to insert a grayed-out tag, the already entered tag will instead be selected. - Tags that have already been entered are displayed grayed out. If you try to insert a grayed-out tag, the already entered tag will instead be selected.
- You can display related tags for the cursor position by pressing `Ctrl+Shift+Space`. - You can display related tags for the cursor position by pressing `Ctrl+Shift+Space`.
## Auto Formatter
When a text input area loses focus (e.g., by clicking outside or pressing Tab), the prompt text is automatically formatted. This feature improves readability when editing large amounts of text.
Detailed behavior is as follows:
- Automatically adds a comma and space after each tag for proper separation
- Removes extra commas and spaces between tags
- You can manually trigger formatting using the keyboard shortcut `Alt+Shift+F` (keybinding can be customized in ComfyUI settings)
- You can enable/disable this feature in the settings
> [!NOTE]
> Auto-formatting is disabled for certain nodes to prevent errors.
> Example: [Power Puter (rgthree)](https://github.com/rgthree/rgthree-comfy/wiki/Node:-Power-Puter) `code` field, [LoraLoaderBlockWeight (Inspire)](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) `block_vector` field
## CSV Data ## CSV Data
Two basic CSV data files are required for operation. These are managed on [HuggingFace](https://huggingface.co/datasets/newtextdoc1111/danbooru-tag-csv) and are automatically downloaded when ComfyUI is first launched after installation, so no setup is required. Two basic CSV data files are required for operation. These are managed on [HuggingFace](https://huggingface.co/datasets/newtextdoc1111/danbooru-tag-csv) and are automatically downloaded when ComfyUI is first launched after installation, so no setup is required.
@@ -92,7 +104,12 @@ Also, displaying related tags is not supported.
### User CSV ### User CSV
Users can also use their own CSV files. For example, frequently used meta tags can be added to the autocomplete suggestions by placing a file named `danbooru_tags_meta.csv` in the `data` folder. Users can also use their own CSV files. CSV files should be placed in the `data` folder according to the following naming convention:
- **CSV for Autocomplete**: `<danbooru | e621>_tags*.csv`
- **CSV for Related Tags**: `<danbooru | e621>_tags_cooccurrence*.csv`
For example, you can add frequently used meta tags to the autocomplete suggestions by placing a file named `danbooru_tags_meta.csv` in the `data` folder.
A header row is not required. A browser reload is necessary to apply the changes. A header row is not required. A browser reload is necessary to apply the changes.
**Example of meta tags:** **Example of meta tags:**
@@ -106,14 +123,40 @@ low_quality,5,9999999,
worst_quality,5,9999999, worst_quality,5,9999999,
``` ```
When the browser is reloaded, you can check the list of loaded CSV files in the ComfyUI console log. If a file is not included in the log output, please verify that the file name follows the naming convention.
**Example of ComfyUI console log output:**
```
[Autocomplete-Plus] CSV file status:
* Danbooru -> base: True, extra: danbooru_tags_meta.csv // If displayed here, meta tags can be autocompleted
* E621 -> base: False, extra:
```
>[!NOTE] >[!NOTE]
> If there are multiple user CSV files, they are loaded in alphabetical order. If the same tag exists in multiple files, the one loaded first is retained. The basic CSV is loaded last. > If there are multiple user CSV files, they are loaded in alphabetical order. If the same tag exists in multiple files, the one loaded first is retained. The basic CSV is loaded last.
### Bulk Tag Insertion (Pseudo "Chants")
By enclosing multiple tags with `""` (double quotation marks), you can insert frequently used tags in bulk.
This is similar to the **Chants** feature in [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete?tab=readme-ov-file#chants).
For example, by preparing the following CSV, you can quickly insert corresponding tags by typing `<c:Basic-HighQuality>` or `<c:Basic-Negative>`.
**`danbooru_tags_chants.csv`:**
```
"masterpiece, best quality, high quality, highres, ultra-detailed",5,9999999,<c:Basic-HighQuality>
"(worst quality, low quality:1.4), normal quality",5,9999999,<c:Basic-Negative>
```
>[!TIP]
> * Text enclosed in `""` does not escape `()` (parentheses). Tags that originally contain parentheses should be written in the CSV with escaped parentheses. Example: `copyright_(series)` -> `copyright_\(series\)`
> * The alias column also supports `""`, allowing you to assign multiple aliases
## Settings ## Settings
### Tag Source ### Tag Source
> [!TIP] > [!NOTE]
> The source of tag data such as Danbooru or e621 is called the "tag source". > The source of tag data such as Danbooru or e621 is called the "tag source".
- **Autocomplete Tag Source**: The tag source to display in the autocomplete suggestions. Select "all" to display all loaded tag sources. - **Autocomplete Tag Source**: The tag source to display in the autocomplete suggestions. Select "all" to display all loaded tag sources.
@@ -124,17 +167,83 @@ worst_quality,5,9999999,
- **Enable Autocomplete**: Enable/disable the autocomplete feature. - **Enable Autocomplete**: Enable/disable the autocomplete feature.
- **Max suggestions**: Maximum number of autocomplete suggestions to display. - **Max suggestions**: Maximum number of autocomplete suggestions to display.
- **Auto-Insert Comma**: Automatically insert a comma after tags when inserting from autocomplete.
- **Replace '_' with 'Space'**: Replaces underscores with spaces when inserting tags. This setting also affects related tag display.
- **String to add before artist tags**: Text to prepend when inserting an artist tag. For Anima models, specify `@`.
- **Enable Loras and Embeddings**: Display Lora and Embedding in the suggestions.
- **Use Fast Search**: Switch autocomplete suggestions search to fast processing (see [About Fast Search for Autocomplete](#about-fast-search-for-autocomplete) for details).
### Related Tags ### Related Tags
- **Enable Related Tags**: Enable/disable the related tags feature. - **Enable Related Tags**: Enable/disable the related tags feature.
- **Max related tags**: Maximum number of related tags to display. - **Max related tags**: Maximum number of related tags to display.
- **Default Display Position**: Default display position when ComfyUI starts. - **Default Display Position**: Default display position when ComfyUI starts.
- **Related Tags Trigger Mode**: Which action will trigger displaying related tags (click only, Ctrl+click) - **Related Tags Trigger Mode**: Which action will trigger displaying related tags for the entered tag (click only, Ctrl+click)
### Miscellaneous ### Display
- **Check CSV updates**: Click the "Check Now" button to check if new CSV files are available in HuggingFace and download them if necessary. - **Hide Alias**: Hide/show the Alias ​​column in autocomplete and related tags (default is show)
### Auto Formatter
- **Enable Auto Format**: Enable/disable the automatically format prompt text when the textarea loses focus.
- **Auto Format Trigger**: Choose when formatting is applied.
- **Auto**: Format automatically when leaving text field
- **Manual**: Format only via keyboard shortcut (default: `Alt+Shift+F`)
- **Use Trailing Comma**: When enabled, ensures all lines end with a trailing comma when formatting. If disabled, removes trailing commas.
- **Trim Surrounding Spaces**: When enabled, trim any blank lines or spaces from the beginning and end of the prompt.
## Advanced Settings
### Disabling CSV Update Check on Startup
By default, ComfyUI performs CSV file update checks and downloads at regular intervals during startup.
When starting in an environment without internet access, startup may be delayed until a timeout occurs.
You can skip the check process during ComfyUI startup by following these steps:
1. Start ComfyUI once with this custom node installed to generate the `csv_meta.json` file.
The `csv_meta.json` file is created directly under this custom node's folder.
2. Open `csv_meta.json` in a text editor and change the value of `check_updates_on_startup` from `true` to `false` and save.
If `check_updates_on_startup` does not exist, add it under `version`.
**`csv_meta.json` after modification:**
```json
{
"version": 1,
"check_updates_on_startup": false,
...
}
```
**Additional notes:**
- The check process will not be performed until the value of `check_updates_on_startup` is changed back to `true` or the `version` is switched.
- Even when `check_updates_on_startup` is `false`, manual checking is still possible by pressing the `Check CSV updates` button in the Autocomplete Plus settings.
## More details on how it works
### About Fast Search for Autocomplete
In `v1.3.0`, Added a fast search feature functionality to autocomplete. By enabling this function in the settings screen, tag search processing during text input operates faster, improving responsiveness.
While fast operation can be expected in any scenario, I particularly recommend **enabling** it in the following use cases:
- When the loaded CSV files contain a large number of tags or aliases. This is especially useful when the total number of tags exceeds **100,000**.
- When using natural language instead of comma-separated tags in prompt input.
**Browser startup behavior**
Fast search requires tag index building and is not available immediately after browser startup. The conventional search process is used until building is complete.
As of `v1.3.0`, The notification when building is completed is displayed only in the browser's developer tools. This is planned to be improved in future versions.
For example, when index building for approximately 220,000 tags is completed, the following log is recorded:
```
[Autocomplete-Plus] Building 221787 index for danbooru took 9398.70ms.
```
> [!NOTE]
> - Index building occurs in the background even when fast search is disabled in settings
> - Fast search uses the full-text search library [nextapps-de/flexsearch](https://github.com/nextapps-de/flexsearch)
## Known Issues ## Known Issues
@@ -150,7 +259,9 @@ worst_quality,5,9999999,
## Credits ## Credits
- [ComfyUI-Custom-Node](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) - [pythongosssss/ComfyUI-Custom-Node](https://github.com/pythongosssss/ComfyUI-Custom-Scripts)
- Referenced for implementing the autocomplete function. - Referenced for implementing the autocomplete function.
- [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete) - [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete)
- Referenced for autocomplete function and CSV specifications. - Referenced for autocomplete function and CSV specifications.
- [nextapps-de/flexsearch](https://github.com/nextapps-de/flexsearch)
- Used to implement fast tag search processing for autocomplete.
+124 -14
View File
@@ -1,28 +1,23 @@
# ComfyUI-Autocomplete-Plus # ComfyUI-Autocomplete-Plus
![ss01](https://github.com/user-attachments/assets/bb139951-ad78-4d87-b290-97aafa1221d7) ![ss01](https://github.com/user-attachments/assets/45dd0598-4c04-49ab-85f7-33fc9026921c)
## 概要 ## 概要
**ComfyUI-Autocomplete-Plus** は、[ComfyUI](https://github.com/comfyanonymous/ComfyUI) の任意のテキストエリアに複数の入力支援機能を提供するカスタムノードです。現在はDanbooruとe621のタグに対応しています(e621は一部の機能が未対応です)。 **ComfyUI-Autocomplete-Plus** は、[ComfyUI](https://github.com/comfyanonymous/ComfyUI) の任意のテキストエリアに複数の入力支援機能を提供するカスタムノードです。現在はDanbooruとe621のタグに対応しています(e621は一部の機能が未対応です)。
## 最近の更新 :fire:
- CSV ファイルの自動・手動更新チェック機能の追加
- Microsoft IMEでオートコンプリート候補が表示されないタイミングがある不具合の修正
- e621タグ CSV の読み込みと表示のサポート
## 特徴 ## 特徴
- **:zap:セットアップ不要**: Danbooruタグに最適化された CSV データを自動でダウンロード - **:zap:セットアップ不要**: Danbooruタグに最適化された CSV データを自動でダウンロード
- **:mag:オートコンプリート**: テキスト入力中に、入力内容に基づいてタグ候補をリアルタイムで表示 - **:mag:オートコンプリート**: テキスト入力中に、入力内容に基づいてタグ候補をリアルタイムで表示
- **:file_cabinet:関連タグ表示機能**: 選択したタグと関連性の高いタグを一覧表示 - **:file_cabinet:関連タグ表示機能**: 選択したタグと関連性の高いタグを一覧表示
- **:triangular_ruler:自動フォーマット**: テキストエリアがフォーカスを失った際に、プロンプトテキストを自動的にフォーマットし、余分なスペースやカンマを整理
- **:earth_asia:多言語対応**: 日本語、中国語、韓国語での入力補完をサポート - **:earth_asia:多言語対応**: 日本語、中国語、韓国語での入力補完をサポート
- **:computer_mouse:直感的な操作**: - **:computer_mouse:直感的な操作**:
- マウスとキーボードどちらの操作にも対応 - マウスとキーボードどちらの操作にも対応
- カーソル位置や既存のテキストを考慮した自然なタグ挿入 - カーソル位置や既存のテキストを考慮した自然なタグ挿入
- **:art:デザイン**: ComfyUIのライトテーマとダークテーマの両方に対応 - **:art:デザイン**: ComfyUIのライトテーマとダークテーマの両方に対応
- **:pencil:ユーザーCSV**: ユーザーが用意した CSV をオートコンプリート候補に追加可能 - **:pencil:ユーザーCSV**: ユーザーが用意した CSV をオートコンプリート候補に追加可能
- **依存ライブラリゼロ**: 外部ライブラリを使用せず、すべての入力支援処理がブラウザで動作
## インストール ## インストール
@@ -45,6 +40,8 @@
- タグのカテゴリ毎に色分けされます。色分けのルールは Danbooru と同じです - タグのカテゴリ毎に色分けされます。色分けのルールは Danbooru と同じです
- 入力済みのタグはグレーアウトで表示されます - 入力済みのタグはグレーアウトで表示されます
- Danbooruとe621のタグを同時に表示出来ます。設定から優先順位を変更できます - Danbooruとe621のタグを同時に表示出来ます。設定から優先順位を変更できます
- LoraとEmbeddingの入力補完に対応しています。設定から有効・無効を切り替えられます
- 「📖」アイコンをクリックするとタグのWikiページを開きます。キーボードで選択中の場合は `F1` キーで開くことが出来ます
## 関連タグ ## 関連タグ
@@ -55,9 +52,24 @@
- 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます - 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます
- ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます - ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます
- ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合はEscキーを押します - ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合はEscキーを押します
- ヘッダーのタグをクリックするとタグのWikiページを開きます
- 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを挿入しようとした場合、代わりに入力済みのタグを選択状態にします - 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを挿入しようとした場合、代わりに入力済みのタグを選択状態にします
- `Ctrl+Shift+Space` キーでカーソル位置の関連タグを表示できます - `Ctrl+Shift+Space` キーでカーソル位置の関連タグを表示できます
## 自動フォーマット
テキスト入力エリアがフォーカスを失った際(例:外側をクリック、Tabキーを押す)、プロンプトテキストを自動的にフォーマットする機能です。大量のテキストを編集する際に可読性を向上させる事が出来ます。
詳細な動作は以下になります:
- 各タグの後に自動的にカンマとスペースを追加し、適切に区切ります
- タグ間の余分なカンマとスペースを削除します
- キーボードショートカット `Alt+Shift+F` を使って手動でフォーマットすることも出来ます(キーバインドはComfyUIの設定から変更可能です)
- 設定から機能の有効化/無効化を切り替えられます
> [!NOTE]
> 一部のノードはエラーに繋がるため、自動フォーマットは実行されません。
> 例: [Power Puter (rgthree)](https://github.com/rgthree/rgthree-comfy/wiki/Node:-Power-Puter) の `code` フィールド、 [LoraLoaderBlockWeight (Inspire)](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) の `block_vector` フィールド
## CSV データ ## CSV データ
動作には基本となる CSV データが2つ必要です。これらは [HuggingFace](https://huggingface.co/datasets/newtextdoc1111/danbooru-tag-csv) で管理されており、 ComfyUI にインストール後の初回起動時に自動でダウンロードされるのでセットアップは不要です。 動作には基本となる CSV データが2つ必要です。これらは [HuggingFace](https://huggingface.co/datasets/newtextdoc1111/danbooru-tag-csv) で管理されており、 ComfyUI にインストール後の初回起動時に自動でダウンロードされるのでセットアップは不要です。
@@ -90,7 +102,12 @@
### ユーザーCSV ### ユーザーCSV
ユーザーが自身で用意した CSV を使用することも可能です。例として、よく使うメタタグを `danbooru_tags_meta.csv` の名前で `data` フォルダーに配置することでオートコンプリート候補に追加できます。 ユーザーが自身で用意した CSV を使用することも可能です。 CSV ファイルは以下の命名規則に従って `data` フォルダーに配置してください。
- **オートコンプリート用 CSV**: <danbooru | e621>_tags*.csv
- **関連タグ用 CSV**: <danbooru | e621>_tags_cooccurrence*.csv
例として、よく使うメタタグを `danbooru_tags_meta.csv` の名前で `data` フォルダーに配置することでオートコンプリート候補に追加できます。
ヘッダー行はなくても構いません。反映にはブラウザのリロードが必要です。 ヘッダー行はなくても構いません。反映にはブラウザのリロードが必要です。
**メタタグの例** **メタタグの例**
@@ -104,14 +121,40 @@ low_quality,5,9999999,
worst_quality,5,9999999, worst_quality,5,9999999,
``` ```
ブラウザリロード時、ロードされる CSV ファイル一覧をComfyUIのコンソールのログで確認出来ます。ログ出力に含まれていない場合はファイル名が命名規則に沿っているか確認してください。
**ComfyUIコンソールログ出力の例:**
```
[Autocomplete-Plus] CSV file status:
* Danbooru -> base: True, extra: danbooru_tags_meta.csv // ここに表示されていればメタタグを入力補完できます
* E621 -> base: False, extra:
```
>[!NOTE] >[!NOTE]
> ユーザー CSV が複数ある場合アルファベット順に読み込まれます。同じタグが複数のファイルに存在する場合は先に読み込まれた方が保持されます。基本 CSV は最後にロードされます。 > ユーザー CSV が複数ある場合アルファベット順に読み込まれます。同じタグが複数のファイルに存在する場合は先に読み込まれた方が保持されます。基本 CSV は最後にロードされます。
### 複数タグの一括挿入機能(疑似 Chants)
`""` (ダブルクォーテーション)で複数タグを囲むことで、よく使うタグを一括で挿入できます。
これは [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete?tab=readme-ov-file#chants) で **Chants** と呼ばれる機能に似ています。
例として以下のCSVを用意することで、`<c:Basic-HighQuality>` や `<c:Basic-Negative>` と入力して対応したタグを素早く挿入出来ます。
**`danbooru_tags_chants.csv`:**
```
"masterpiece, best quality, high quality, highres, ultra-detailed",5,9999999,<c:Basic-HighQuality>
"(worst quality, low quality:1.4), normal quality",5,9999999,<c:Basic-Negative>
```
>[!TIP]
> * `""` で囲まれたテキストは `()` (括弧)がエスケープされません。元々括弧が含まれるタグはエスケープした状態でCSVに記述してください。 例: `copyright_(series)` -> `copyright_\(series\)`
> * エイリアス列も `""` に対応しているので、複数のエイリアスを付けられます
## 設定 ## 設定
### タグソース ### タグソース
> [!TIP] > [!NOTE]
> Danbooruやe621等のタグデータの提供元を「タグソース」と呼びます > Danbooruやe621等のタグデータの提供元を「タグソース」と呼びます
- **Autocomplete Tag Source**: オートコンプリート候補に表示するタグソース。「all」を選択するとロード済みの全てのタグソースを表示します - **Autocomplete Tag Source**: オートコンプリート候補に表示するタグソース。「all」を選択するとロード済みの全てのタグソースを表示します
@@ -121,18 +164,83 @@ worst_quality,5,9999999,
### オートコンプリート ### オートコンプリート
- **Enable Autocomplete**: オートコンプリート機能の有効化/無効化 - **Enable Autocomplete**: オートコンプリート機能の有効化/無効化
- **Max suggestions**: オートコンプリート候補の最大表示件数 - **Max Suggestions**: オートコンプリート候補の最大表示件数
- **Auto-Insert Comma**: タグの挿入時、末尾にカンマを追加する
- **Replace '_' with 'Space'**: タグ挿入時にアンダースコアをスペースに置き換えます。この設定は関連タグ表示にも影響します
- **String to add before artist tags**: アーティストタグを挿入する際に付加する文字列。 Anima モデルの場合は「@」を指定します
- **Enable Loras and Embeddings**: LoraとEmbeddingを候補に表示する
- **Use Fast Search**: オートコンプリート候補の検索を高速な処理に切り替える(詳細は [オートコンプリートの高速検索について](#オートコンプリートの高速検索について) を確認してください)
### 関連タグ ### 関連タグ
- **Enable Related Tags**: 関連タグ機能の有効化/無効化 - **Enable Related Tags**: 関連タグ機能の有効化/無効化
- **Max related tags**: 関連タグの最大表示件数 - **Max related tags**: 関連タグの最大表示件数
- **Default Display Position**: ComfyUI起動時のデフォルト表示位置 - **Default Display Position**: ComfyUI起動時のデフォルト表示位置
- **Related Tags Trigger Mode** : 関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック) - **Related Tags Trigger Mode** : 入力済みのタグの関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック)
### その他 ### 表示
- **Check CSV updates**: 「Check Now」ボタンを押すと新しい CSV ファイルがHuggingFaceにあるか確認し、必要に応じてダウンロードを行います - **Hide Alias**: オートコンプリートと関連タグで表示されるエイリアス列の非表示/表示を切り替え(デフォルトは表示です)
### 自動フォーマット
- **Enable Auto Format**: テキストエリアがフォーカスを失った際にプロンプトテキストを自動的にフォーマットする機能の有効化/無効化
- **Auto Format Trigger**: フォーマットを適用するタイミングを選択します
- **自動**: テキスト欄からフォーカスが外れた際に自動でフォーマットします
- **手動**: キーボードショートカットでのみフォーマットします(デフォルト: `Alt+Shift+F`)
- **行末にカンマを使用**: 有効時、フォーマット時にすべての行末がカンマで終わるようになります。\n無効にすると行末のカンマが削除されます
- **先頭・末尾の空白を削除**: 有効時、プロンプトの先頭と末尾にある空行やスペースをすべて削除します
## 上級者向け設定
### 起動時のCSV更新チェックを無効化する
デフォルトの動作では、ComfyUI起動時に一定の間隔で CSV ファイルの更新チェックとダウンロード行います。
インターネットにアクセス出来ない環境で起動した場合、タイムアウトが発生するまで起動が遅延する事があります。
以下の手順を行う事により、ComfyUI起動時のチェック処理をスキップする事が出来ます。
1. このカスタムノードをインストールした状態でComfyUIを一度起動し、 `csv_meta.json` ファイルを生成する
`csv_meta.json` はこのカスタムノードのフォルダー直下に作成されます
2. `csv_meat.json` をテキストエディターで開き、`check_updates_on_startup` の値を `true` -> `false` に変更し保存する
`check_updates_on_startup` が存在しない場合、 `version` の下に追記してください
**変更後の `csv_meta.json`:**
```json
{
"version": 1,
"check_updates_on_startup": false,
...
}
```
**補足事項:**
- `check_updates_on_startup` の値を再び `true` にするか、 `version` が切り替わるまでチェック処理は行われなくなります
- `check_updates_on_startup` が `false` でも、Autocompelte Plusの設定から `Check CSV updates` のボタンを押す事で手動チェックが可能です
## 動作に関する詳細
## オートコンプリートの高速検索について
`v1.3.0` にて、オートコンプリートにタグの検索を高速化する機能を追加しました。設定画面から有効にする事でテキスト入力時のタグ検索処理が高速に動作し、応答性が改善されます。
どのような場面でも高速な動作が期待できますが、特に以下のユースケースで **有効** にすることをお勧めします。
- 読み込む CSV ファイルに大量のタグやエイリアスが含まれている場合。タグの合計が **10万件** を超える場合は特に有用です
- プロンプト入力でタグのカンマ区切りの代わりに自然言語を使う場合
**ブラウザ起動時の動作**
高速検索はタグのインデックス構築が必要なため、ブラウザ起動直後は利用できません。構築が完了するまでは従来の検索処理で動作します。
`v1.3.0` 時点では構築が完了したタイミングはブラウザーの開発ツールにのみが表示されます。これは将来のバージョンで改善予定です。
例として、約22万件のタグのインデックス構築が完了した際は以下のようなログが記録されます。
```
[Autocomplete-Plus] Building 221787 index for danbooru took 9398.70ms.
```
> [!NOTE]
> - インデックス構築は設定から高速検索を無効にしてもバックグラウンドで行われます
> - 高速検索の実現に全文検索ライブラリの [nextapps-de/flexsearch](https://github.com/nextapps-de/flexsearch) を使用しています
## 既知の問題 ## 既知の問題
@@ -148,7 +256,9 @@ worst_quality,5,9999999,
## クレジット ## クレジット
- [ComfyUI-Custom-Node](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) - [pythongosssss/ComfyUI-Custom-Node](https://github.com/pythongosssss/ComfyUI-Custom-Scripts)
- オートコンプリート機能の実装にあたり参考にしました - オートコンプリート機能の実装にあたり参考にしました
- [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete) - [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete)
- オートコンプリートの主要な機能や CSV の仕様で参考にしました - オートコンプリートの主要な機能や CSV の仕様で参考にしました
- [nextapps-de/flexsearch](https://github.com/nextapps-de/flexsearch)
- オートコンプリートの高速なタグ検索処理の実装に利用させてもらいました
+10
View File
@@ -0,0 +1,10 @@
{
"settingsCategories": {
"Autocomplete Plus": "Autocomplete Plus",
"Tag Source": "Tag Source",
"Autocompletion": "Autocompletion",
"Related Tags": "Related Tags",
"Display": "Display",
"AutoFormatter": "AutoFormatter"
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"AutocompletePlus_TagSource_IconPosition": {
"name": "Tag Source Icon Position",
"options": {
"left": "Left",
"right": "Right",
"hidden": "Hidden"
}
},
"AutocompletePlus_TagSource_PrimaryTagSource": {
"name": "Primary source for 'all' Source",
"tooltip": "When 'Autocomplete Tag Source' is 'all', this determines which source's tags appear first in suggestions.",
"options": {
"danbooru": "Danbooru",
"e621": "e621"
}
},
"AutocompletePlus_TagSource": {
"name": "Autocomplete Tag Source",
"tooltip": "Select the tag source for autocomplete suggestions. 'all' includes tags from all loaded sources.",
"options": {
"danbooru": "Danbooru",
"e621": "e621",
"all": "all"
}
},
"AutocompletePlus_Autocompletion_UseFastSearch": {
"name": "Use Fast Search",
"tooltip": "Tag search processing during text input operates faster, improving responsiveness."
},
"AutocompletePlus_Autocompletion_EnableModels": {
"name": "Enable Loras and Embeddings"
},
"AutocompletePlus_Autocompletion_PrefixArtist": {
"name": "String to add before artist tags",
"tooltip": "Text to prepend when inserting an artist tag via autocomplete.\ne.g. '{'@'}' -> '{'@'}artist_name'."
},
"AutocompletePlus_Autocompletion_ReplaceUnderscoreWithSpace": {
"name": "Replace '_' with 'Space'",
"tooltip": "This setting also affects related tags display."
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "Auto-Insert Comma",
"tooltip": "Automatically insert a comma after tags when inserting from autocomplete."
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
"name": "Max suggestions"
},
"AutocompletePlus_Autocompletion_Enable": {
"name": "Enable Autocomplete"
},
"AutocompletePlus_RelatedTags_RelatedTagsTriggerMode": {
"name": "Related Tags Trigger Mode",
"tooltip": "Which action will trigger displaying related tags for the entered tag (click only, Ctrl+click).",
"options": {
"click": "Click",
"ctrl+Click": "Ctrl+Click"
}
},
"AutocompletePlus_RelatedTags_DisplayPosition": {
"name": "Default Display Position",
"tooltip": "Display position (relative to Textarea).",
"options": {
"horizontal": "Horizontal",
"vertical": "Vertical"
}
},
"AutocompletePlus_RelatedTags_MaxRelatedTags": {
"name": "Max related tags"
},
"AutocompletePlus_RelatedTags_Enable": {
"name": "Enable Related Tags"
},
"AutocompletePlus_Display_HideAlias": {
"name": "Hide Alias",
"tooltip": "Hide alias column in the autocomplete and related tags display."
},
"AutocompletePlus_AutoFormatter_Trigger": {
"name": "Auto Format Trigger",
"tooltip": "Auto: Format automatically when leaving text field.\nManual: Format only via keyboard shortcut. default keybind: (Alt+Shift+F)",
"options": {
"auto": "Auto",
"manual": "Manual"
}
},
"AutocompletePlus_AutoFormatter_UseTrailingComma": {
"name": "Use Trailing Comma",
"tooltip": "When enabled, ensures all lines end with a trailing comma.\nWhen disabled, removes trailing commas."
},
"AutocompletePlus_AutoFormatter_EnableAutoFormat": {
"name": "Enable Auto Format"
},
"AutocompletePlus_AutoFormatter_TrimSurroundingSpaces": {
"name": "Trim Surrounding Spaces",
"tooltip": "When enabled, trim any blank lines from the beginning and end of the prompt."
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"settingsCategories": {
"Autocomplete Plus": "Autocomplete Plus",
"Tag Source": "タグソース",
"Autocompletion": "オートコンプリート",
"Related Tags": "関連タグ",
"Display": "表示",
"AutoFormatter": "自動フォーマット"
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"AutocompletePlus_TagSource_IconPosition": {
"name": "タグソースのアイコン位置",
"options": {
"left": "左",
"right": "右",
"hidden": "非表示"
}
},
"AutocompletePlus_TagSource_PrimaryTagSource": {
"name": "優先して表示するタグソース",
"tooltip": "オートコンプリートのタグソースが「全て」のとき、ここで指定したタグソースが優先して表示されます",
"options": {
"danbooru": "Danbooru",
"e621": "e621"
}
},
"AutocompletePlus_TagSource": {
"name": "オートコンプリートのタグソース",
"tooltip": "オートコンプリート候補に表示するタグソース。\n「全て」を選択するとロード済みの全てのタグソースを表示します",
"options": {
"danbooru": "Danbooru",
"e621": "e621",
"all": "全て"
}
},
"AutocompletePlus_Autocompletion_UseFastSearch": {
"name": "高速検索の有効化",
"tooltip": "候補の検索を高速な処理に切り替えます。\n入力時の遅延が改善される場合があります"
},
"AutocompletePlus_Autocompletion_EnableModels": {
"name": "LoraとEmbeddingを候補に表示する"
},
"AutocompletePlus_Autocompletion_PrefixArtist": {
"name": "アーティストタグの前に追加する文字列",
"tooltip": "オートコンプリートからアーティストタグを挿入する際に付加する文字列。\n例: '{'@'}' -> '{'@'}artist_name'."
},
"AutocompletePlus_Autocompletion_ReplaceUnderscoreWithSpace": {
"name": "'_' を 'スペース' に置き換える",
"tooltip": "この設定は関連タグ表示にも影響します"
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "カンマを自動で追加する",
"tooltip": "オートコンプリート確定時、タグの末尾に自動でカンマを追加する"
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
"name": "候補の最大表示件数"
},
"AutocompletePlus_Autocompletion_Enable": {
"name": "オートコンプリート機能の有効化"
},
"AutocompletePlus_RelatedTags_RelatedTagsTriggerMode": {
"name": "関連タグを表示する操作",
"tooltip": "入力済みのタグの関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック)",
"options": {
"click": "クリック",
"ctrl+Click": "Ctrl+クリック"
}
},
"AutocompletePlus_RelatedTags_DisplayPosition": {
"name": "デフォルト表示位置",
"tooltip": "入力中のテキストエリアを基準に、どの位置に表示するか",
"options": {
"horizontal": "水平",
"vertical": "垂直"
}
},
"AutocompletePlus_RelatedTags_MaxRelatedTags": {
"name": "関連タグの最大表示件数"
},
"AutocompletePlus_RelatedTags_Enable": {
"name": "関連タグ機能の有効化"
},
"AutocompletePlus_Display_HideAlias": {
"name": "エイリアスを非表示",
"tooltip": "オートコンプリートと関連タグで表示されるエイリアス列を非表示にします"
},
"AutocompletePlus_AutoFormatter_Trigger": {
"name": "自動フォーマットのトリガー",
"tooltip": "自動: テキスト欄からフォーカスが外れた際に自動でフォーマットします\n手動: キーボードショートカットでのみフォーマットします。デフォルトのキーバインド: (Alt+Shift+F)",
"options": {
"auto": "自動",
"manual": "手動"
}
},
"AutocompletePlus_AutoFormatter_UseTrailingComma": {
"name": "行末にカンマを使用",
"tooltip": "有効にすると、すべての行末がカンマで終わるようになります。\n無効にすると行末のカンマが削除されます。"
},
"AutocompletePlus_AutoFormatter_EnableAutoFormat": {
"name": "自動フォーマット機能の有効化"
},
"AutocompletePlus_AutoFormatter_TrimSurroundingSpaces": {
"name": "先頭・末尾の空白を削除",
"tooltip": "有効時、プロンプトの先頭と末尾にある空行やスペースをすべて削除します。"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"settingsCategories": {
"Autocomplete Plus": "Autocomplete Plus",
"Tag Source": "標籤來源",
"Autocompletion": "自動完成",
"Related Tags": "相關標籤",
"Display": "顯示",
"AutoFormatter": "自動格式化"
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"AutocompletePlus_TagSource_IconPosition": {
"name": "標籤來源圖示位置",
"options": {
"left": "左",
"right": "右",
"hidden": "隱藏"
}
},
"AutocompletePlus_TagSource_PrimaryTagSource": {
"name": "「全部」選擇時的主要來源",
"tooltip": "當自動完成標籤來源為「全部」時,此設定決定哪個來源的標籤優先顯示在建議中。",
"options": {
"danbooru": "Danbooru",
"e621": "e621"
}
},
"AutocompletePlus_TagSource": {
"name": "自動完成標籤來源",
"tooltip": "選擇自動完成建議的標籤來源。選擇「全部」將包含所有已載入來源的標籤。",
"options": {
"danbooru": "Danbooru",
"e621": "e621",
"all": "全部"
}
},
"AutocompletePlus_Autocompletion_UseFastSearch": {
"name": "啟用快速搜尋",
"tooltip": "標籤搜尋處理更快,提高文字輸入時的回應速度"
},
"AutocompletePlus_Autocompletion_EnableModels": {
"name": "顯示Lora和Embedding"
},
"AutocompletePlus_Autocompletion_PrefixArtist": {
"name": "在藝術家標籤前添加的字串",
"tooltip": "透過自動完成插入藝術家標籤時,在標籤前添加的文字。\n例: '{'@'}' -> '{'@'}artist_name'."
},
"AutocompletePlus_Autocompletion_ReplaceUnderscoreWithSpace": {
"name": "將'_'替換為'空格'",
"tooltip": "此設定也會影響相關標籤顯示"
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "自動插入逗號",
"tooltip": "從自動完成插入標籤時自動在標籤後添加逗號"
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
"name": "最大提示數"
},
"AutocompletePlus_Autocompletion_Enable": {
"name": "啟用自動完成"
},
"AutocompletePlus_RelatedTags_RelatedTagsTriggerMode": {
"name": "顯示相關標籤的操作",
"tooltip": "指定觸發顯示已輸入標籤的相關標籤的操作(僅點擊、Ctrl+點擊)",
"options": {
"click": "點擊",
"ctrl+Click": "Ctrl+點擊"
}
},
"AutocompletePlus_RelatedTags_DisplayPosition": {
"name": "預設顯示位置",
"tooltip": "相對於文字區域的顯示位置",
"options": {
"horizontal": "水平",
"vertical": "垂直"
}
},
"AutocompletePlus_RelatedTags_MaxRelatedTags": {
"name": "最大相關標籤數"
},
"AutocompletePlus_RelatedTags_Enable": {
"name": "啟用相關標籤"
},
"AutocompletePlus_Display_HideAlias": {
"name": "隱藏別名",
"tooltip": "隱藏自動完成和相關標籤顯示中的別名欄"
},
"AutocompletePlus_AutoFormatter_Trigger": {
"name": "自動格式化觸發方式",
"tooltip": "自動: 離開文字方塊時自動格式化\n手動: 僅透過鍵盤快速鍵格式化。預設快速鍵: (Alt+Shift+F)",
"options": {
"auto": "自動",
"manual": "手動"
}
},
"AutocompletePlus_AutoFormatter_UseTrailingComma": {
"name": "在行末添加逗號",
"tooltip": "啟用時,確保所有行都以逗號結尾。\n停用時,移除結尾逗號。"
},
"AutocompletePlus_AutoFormatter_EnableAutoFormat": {
"name": "啟用自動格式化"
},
"AutocompletePlus_AutoFormatter_TrimSurroundingSpaces": {
"name": "去除首尾空白",
"tooltip": "啟用時,清除提示詞開頭和結尾的所有空行和空格。"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"settingsCategories": {
"Autocomplete Plus": "Autocomplete Plus",
"Tag Source": "标签来源",
"Autocompletion": "自动完成",
"Related Tags": "相关标签",
"Display": "显示",
"AutoFormatter": "自动格式化"
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"AutocompletePlus_TagSource_IconPosition": {
"name": "标签来源图标位置",
"options": {
"left": "左",
"right": "右",
"hidden": "隐藏"
}
},
"AutocompletePlus_TagSource_PrimaryTagSource": {
"name": "'全部'选择时的主要来源",
"tooltip": "当自动完成标签来源为\"全部\"时,此设置决定哪个来源的标签优先显示在建议中。",
"options": {
"danbooru": "Danbooru",
"e621": "e621"
}
},
"AutocompletePlus_TagSource": {
"name": "自动完成标签来源",
"tooltip": "选择自动完成建议的标签来源。选择\"全部\"将包含所有已加载来源的标签。",
"options": {
"danbooru": "Danbooru",
"e621": "e621",
"all": "全部"
}
},
"AutocompletePlus_Autocompletion_UseFastSearch": {
"name": "启用快速搜索",
"tooltip": "标签搜索处理更快,提高文本输入时的响应速度"
},
"AutocompletePlus_Autocompletion_EnableModels": {
"name": "显示Lora和Embedding"
},
"AutocompletePlus_Autocompletion_PrefixArtist": {
"name": "在艺术家标签前添加的字符串",
"tooltip": "通过自动完成插入艺术家标签时,在标签前添加的文本。\n例: '{'@'}' -> '{'@'}artist_name'."
},
"AutocompletePlus_Autocompletion_ReplaceUnderscoreWithSpace": {
"name": "将'_'替换为'空格'",
"tooltip": "此设置也会影响相关标签显示"
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "自动插入逗号",
"tooltip": "从自动完成插入标签时自动在标签后添加逗号"
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
"name": "最大提示数"
},
"AutocompletePlus_Autocompletion_Enable": {
"name": "启用自动完成"
},
"AutocompletePlus_RelatedTags_RelatedTagsTriggerMode": {
"name": "显示相关标签的操作",
"tooltip": "指定触发显示已输入标签的相关标签的操作(仅点击、Ctrl+点击)",
"options": {
"click": "点击",
"ctrl+Click": "Ctrl+点击"
}
},
"AutocompletePlus_RelatedTags_DisplayPosition": {
"name": "默认显示位置",
"tooltip": "相对于文本区域的显示位置",
"options": {
"horizontal": "水平",
"vertical": "垂直"
}
},
"AutocompletePlus_RelatedTags_MaxRelatedTags": {
"name": "最大相关标签数"
},
"AutocompletePlus_RelatedTags_Enable": {
"name": "启用相关标签"
},
"AutocompletePlus_Display_HideAlias": {
"name": "隐藏别名",
"tooltip": "隐藏自动完成和相关标签显示中的别名列"
},
"AutocompletePlus_AutoFormatter_Trigger": {
"name": "自动格式化触发方式",
"tooltip": "自动: 离开文本框时自动格式化\n手动: 仅通过键盘快捷键格式化。默认快捷键: (Alt+Shift+F)",
"options": {
"auto": "自动",
"manual": "手动"
}
},
"AutocompletePlus_AutoFormatter_UseTrailingComma": {
"name": "在行末添加逗号",
"tooltip": "启用时,确保所有行都以逗号结尾。\n禁用时,移除结尾逗号。"
},
"AutocompletePlus_AutoFormatter_EnableAutoFormat": {
"name": "启用自动格式化"
},
"AutocompletePlus_AutoFormatter_TrimSurroundingSpaces": {
"name": "去除首尾空白",
"tooltip": "启用时,清除提示词开头和结尾的所有空行和空格。"
}
}
+97 -68
View File
@@ -1,7 +1,10 @@
import os
import json import json
import os
import folder_paths
import server import server
from aiohttp import web from aiohttp import web
from . import downloader as dl from . import downloader as dl
# Get the absolute path to the 'data' directory # Get the absolute path to the 'data' directory
@@ -10,11 +13,12 @@ from . import downloader as dl
# os.path.join(..., '..', 'data') goes up one level and then into 'data' # os.path.join(..., '..', 'data') goes up one level and then into 'data'
DATA_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data")) DATA_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data"))
DANBOORU_PREFIX = 'danbooru' DANBOORU_PREFIX = "danbooru"
E621_PREFIX = 'e621' E621_PREFIX = "e621"
TAGS_SUFFIX = "tags"
COOCCURRENCE_SUFFIX = "tags_cooccurrence"
TAGS_SUFFIX = 'tags'
COOCCURRENCE_SUFFIX = 'tags_cooccurrence'
def get_csv_file_status(): def get_csv_file_status():
""" """
@@ -23,17 +27,17 @@ def get_csv_file_status():
data = { data = {
DANBOORU_PREFIX: { DANBOORU_PREFIX: {
'base_tags': False, "base_tags": False,
'extra_tags': [], "extra_tags": [],
'base_cooccurrence': False, "base_cooccurrence": False,
'extra_cooccurrence': [], "extra_cooccurrence": [],
}, },
E621_PREFIX: { E621_PREFIX: {
'base_tags': False, "base_tags": False,
'extra_tags': [], "extra_tags": [],
'base_cooccurrence': False, "base_cooccurrence": False,
'extra_cooccurrence': [], "extra_cooccurrence": [],
} },
} }
for prefix in [DANBOORU_PREFIX, E621_PREFIX]: for prefix in [DANBOORU_PREFIX, E621_PREFIX]:
@@ -46,27 +50,28 @@ def get_csv_file_status():
tags_extra_files = [] tags_extra_files = []
cooccurrence_extra_files = [] cooccurrence_extra_files = []
all_csv_files = [f for f in os.listdir(DATA_DIR) if f.startswith(prefix) and f.endswith('.csv')] all_csv_files = [f for f in os.listdir(DATA_DIR) if f.startswith(prefix) and f.endswith(".csv")]
# Create extra CSV files list # Create extra CSV files list
for filename in all_csv_files: for filename in all_csv_files:
if filename in [base_tags_file, base_cooccurrence_file]: if filename in [base_tags_file, base_cooccurrence_file]:
continue # Skip base files continue # Skip base files
if COOCCURRENCE_SUFFIX in filename.lower(): if COOCCURRENCE_SUFFIX in filename.lower():
cooccurrence_extra_files.append(filename) cooccurrence_extra_files.append(filename)
elif TAGS_SUFFIX in filename.lower(): elif TAGS_SUFFIX in filename.lower():
tags_extra_files.append(filename) tags_extra_files.append(filename)
data[prefix] = { data[prefix] = {
'base_tags': tags_base_exists, "base_tags": tags_base_exists,
'extra_tags': tags_extra_files, "extra_tags": tags_extra_files,
'base_cooccurrence': cooccurrence_base_exists, "base_cooccurrence": cooccurrence_base_exists,
'extra_cooccurrence': cooccurrence_extra_files, "extra_cooccurrence": cooccurrence_extra_files,
} }
# Return the lists of extra files # Return the lists of extra files
return data return data
def get_last_check_time_from_metadata(): def get_last_check_time_from_metadata():
""" """
Helper function to get the last remote check timestamp from csv_meta.json. Helper function to get the last remote check timestamp from csv_meta.json.
@@ -76,7 +81,7 @@ def get_last_check_time_from_metadata():
if not os.path.exists(dl.CSV_META_FILE): if not os.path.exists(dl.CSV_META_FILE):
return None return None
with open(dl.CSV_META_FILE, 'r', encoding='utf-8') as f: with open(dl.CSV_META_FILE, "r", encoding="utf-8") as f:
metadata = json.load(f) metadata = json.load(f)
datasets = metadata.get("hf_datasets", []) datasets = metadata.get("hf_datasets", [])
@@ -89,9 +94,11 @@ def get_last_check_time_from_metadata():
print(f"[Autocomplete-Plus] Error reading csv_meta.json: {e}") print(f"[Autocomplete-Plus] Error reading csv_meta.json: {e}")
return None return None
# --- API Endpoints --- # --- API Endpoints ---
@server.PromptServer.instance.routes.get('/autocomplete-plus/csv')
@server.PromptServer.instance.routes.get("/autocomplete-plus/csv")
async def get_csv_list(_request): async def get_csv_list(_request):
""" """
Returns CSV file status. Returns CSV file status.
@@ -102,28 +109,38 @@ async def get_csv_list(_request):
response = { response = {
DANBOORU_PREFIX: { DANBOORU_PREFIX: {
'base_tags': csv_file_status[DANBOORU_PREFIX]['base_tags'], "base_tags": csv_file_status[DANBOORU_PREFIX]["base_tags"],
'extra_tags': len(csv_file_status[DANBOORU_PREFIX]['extra_tags']), "extra_tags": csv_file_status[DANBOORU_PREFIX]["extra_tags"],
'base_cooccurrence': csv_file_status[DANBOORU_PREFIX]['base_cooccurrence'], "base_cooccurrence": csv_file_status[DANBOORU_PREFIX]["base_cooccurrence"],
'extra_cooccurrence': len(csv_file_status[DANBOORU_PREFIX]['extra_cooccurrence']), "extra_cooccurrence": csv_file_status[DANBOORU_PREFIX]["extra_cooccurrence"],
}, },
E621_PREFIX: { E621_PREFIX: {
'base_tags': csv_file_status[E621_PREFIX]['base_tags'], "base_tags": csv_file_status[E621_PREFIX]["base_tags"],
'extra_tags': len(csv_file_status[E621_PREFIX]['extra_tags']), "extra_tags": csv_file_status[E621_PREFIX]["extra_tags"],
'base_cooccurrence': csv_file_status[E621_PREFIX]['base_cooccurrence'], "base_cooccurrence": csv_file_status[E621_PREFIX]["base_cooccurrence"],
'extra_cooccurrence': len(csv_file_status[E621_PREFIX]['extra_cooccurrence']), "extra_cooccurrence": csv_file_status[E621_PREFIX]["extra_cooccurrence"],
} },
} }
# Print csv file status to the console for debugging
print(f"""[Autocomplete-Plus] CSV file status:
* Danbooru -> base: {response[DANBOORU_PREFIX]["base_tags"]}, extra: [{", ".join(response[DANBOORU_PREFIX]["extra_tags"])}]
* E621 -> base: {response[E621_PREFIX]["base_tags"]}, extra: [{", ".join(response[E621_PREFIX]["extra_tags"])}]""")
return web.json_response(response) return web.json_response(response)
@server.PromptServer.instance.routes.get('/autocomplete-plus/csv/{source}/{suffix}/base')
@server.PromptServer.instance.routes.get("/autocomplete-plus/csv/{source}/{suffix}/base")
async def get_base_tags_file(request): async def get_base_tags_file(request):
""" """
Returns the base tags CSV file. Returns the base tags CSV file.
""" """
source = str(request.match_info['source']) source = str(request.match_info["source"])
suffix = str(request.match_info['suffix']) suffix = str(request.match_info["suffix"])
if source not in [DANBOORU_PREFIX, E621_PREFIX] or suffix not in [TAGS_SUFFIX, COOCCURRENCE_SUFFIX]: if source not in [DANBOORU_PREFIX, E621_PREFIX] or suffix not in [
TAGS_SUFFIX,
COOCCURRENCE_SUFFIX,
]:
return web.json_response({"error": "Invalid tag source or suffix"}, status=400) return web.json_response({"error": "Invalid tag source or suffix"}, status=400)
file_path = os.path.join(DATA_DIR, f"{source}_{suffix}.csv") file_path = os.path.join(DATA_DIR, f"{source}_{suffix}.csv")
@@ -132,7 +149,8 @@ async def get_base_tags_file(request):
return web.FileResponse(file_path) return web.FileResponse(file_path)
@server.PromptServer.instance.routes.get('/autocomplete-plus/csv/{source}/{suffix}/extra/{index}')
@server.PromptServer.instance.routes.get("/autocomplete-plus/csv/{source}/{suffix}/extra/{index}")
async def get_extra_tags_file(request): async def get_extra_tags_file(request):
""" """
Returns the extra tags CSV file at the specified index. Returns the extra tags CSV file at the specified index.
@@ -140,16 +158,19 @@ async def get_extra_tags_file(request):
try: try:
csv_file_status = get_csv_file_status() csv_file_status = get_csv_file_status()
source = str(request.match_info['source']) source = str(request.match_info["source"])
suffix = str(request.match_info['suffix']) suffix = str(request.match_info["suffix"])
if source not in [DANBOORU_PREFIX, E621_PREFIX] or suffix not in [TAGS_SUFFIX, COOCCURRENCE_SUFFIX]: if source not in [DANBOORU_PREFIX, E621_PREFIX] or suffix not in [
TAGS_SUFFIX,
COOCCURRENCE_SUFFIX,
]:
return web.json_response({"error": "Invalid tag source or suffix"}, status=400) return web.json_response({"error": "Invalid tag source or suffix"}, status=400)
index = int(request.match_info['index']) index = int(request.match_info["index"])
if index < 0 or index >= len(csv_file_status[source][f'extra_{suffix}']): if index < 0 or index >= len(csv_file_status[source][f"extra_{suffix}"]):
return web.json_response({"error": "Invalid index"}, status=404) return web.json_response({"error": "Invalid index"}, status=404)
file_path = os.path.join(DATA_DIR, csv_file_status[source][f'extra_{suffix}'][index]) file_path = os.path.join(DATA_DIR, csv_file_status[source][f"extra_{suffix}"][index])
if not os.path.exists(file_path): if not os.path.exists(file_path):
return web.json_response({"error": "Extra tags file not found"}, status=404) return web.json_response({"error": "Extra tags file not found"}, status=404)
@@ -158,7 +179,8 @@ async def get_extra_tags_file(request):
except ValueError: except ValueError:
return web.json_response({"error": "Invalid index format"}, status=400) return web.json_response({"error": "Invalid index format"}, status=400)
@server.PromptServer.instance.routes.post('/autocomplete-plus/csv/force-check-updates')
@server.PromptServer.instance.routes.post("/autocomplete-plus/csv/force-check-updates")
async def force_check_csv_updates(request): async def force_check_csv_updates(request):
""" """
Forces a check for CSV file updates from HuggingFace, ignoring cooldown. Forces a check for CSV file updates from HuggingFace, ignoring cooldown.
@@ -175,20 +197,20 @@ async def force_check_csv_updates(request):
# Get the updated last check time # Get the updated last check time
last_check_time = get_last_check_time_from_metadata() last_check_time = get_last_check_time_from_metadata()
return web.json_response({ return web.json_response(
"success": True, {
"message": "Force check completed successfully", "success": True,
"last_check_time": last_check_time "message": "Force check completed successfully",
}) "last_check_time": last_check_time,
}
)
except Exception as e: except Exception as e:
print(f"[Autocomplete-Plus] Error during forced check: {e}") print(f"[Autocomplete-Plus] Error during forced check: {e}")
return web.json_response({ return web.json_response({"success": False, "error": str(e)}, status=500)
"success": False,
"error": str(e)
}, status=500)
@server.PromptServer.instance.routes.get('/autocomplete-plus/csv/last-check-time')
@server.PromptServer.instance.routes.get("/autocomplete-plus/csv/last-check-time")
async def get_last_check_time(_request): async def get_last_check_time(_request):
""" """
Returns the last remote check timestamp from csv_meta.json. Returns the last remote check timestamp from csv_meta.json.
@@ -196,26 +218,33 @@ async def get_last_check_time(_request):
""" """
try: try:
if not os.path.exists(dl.CSV_META_FILE): if not os.path.exists(dl.CSV_META_FILE):
return web.json_response({ return web.json_response({"last_check_time": None, "message": "csv_meta.json file not found"})
"last_check_time": None,
"message": "csv_meta.json file not found"
})
last_check_time = get_last_check_time_from_metadata() last_check_time = get_last_check_time_from_metadata()
if last_check_time is not None: if last_check_time is not None:
return web.json_response({ return web.json_response({"last_check_time": last_check_time})
"last_check_time": last_check_time
})
else: else:
return web.json_response({ return web.json_response({"last_check_time": None, "message": "No datasets found in metadata"})
"last_check_time": None,
"message": "No datasets found in metadata"
})
except (IOError, json.JSONDecodeError) as e: except (IOError, json.JSONDecodeError) as e:
print(f"[Autocomplete-Plus] Error reading csv_meta.json: {e}") print(f"[Autocomplete-Plus] Error reading csv_meta.json: {e}")
return web.json_response({ return web.json_response({"last_check_time": None, "error": str(e)}, status=500)
"last_check_time": None,
"error": str(e)
}, status=500) @server.PromptServer.instance.routes.get("/autocomplete-plus/embeddings")
async def get_embeddings(request):
"""
Returns a list of embedding files.
"""
embeddings = folder_paths.get_filename_list("embeddings")
return web.json_response(list(map(lambda a: os.path.splitext(a)[0], embeddings)))
@server.PromptServer.instance.routes.get("/autocomplete-plus/loras")
async def get_loras(request):
"""
Returns a list of lora files.
"""
loras = folder_paths.get_filename_list("loras")
return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras)))
+62 -42
View File
@@ -20,7 +20,8 @@ CSV_META_FILE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", C
DEFAULT_CSV_METADATA = { DEFAULT_CSV_METADATA = {
"version": 1, "version": 1,
"hf_datasets" : [ "check_updates_on_startup": True,
"hf_datasets": [
{ {
"hf_dataset_id": "newtextdoc1111/danbooru-tag-csv", "hf_dataset_id": "newtextdoc1111/danbooru-tag-csv",
"last_remote_check_timestamp": None, "last_remote_check_timestamp": None,
@@ -34,12 +35,15 @@ DEFAULT_CSV_METADATA = {
"file_name": "danbooru_tags_cooccurrence.csv", "file_name": "danbooru_tags_cooccurrence.csv",
"last_download": None, "last_download": None,
"last_modified_on_hf": None, "last_modified_on_hf": None,
} },
] ],
} }
] ],
} }
# --- HuggingFace Constants ---
HUGGINGFACE_URL = "https://huggingface.co"
# --- Helper Functions --- # --- Helper Functions ---
def get_file_path(file_name: str) -> str: def get_file_path(file_name: str) -> str:
@@ -82,12 +86,14 @@ class Downloader:
return default_metadata return default_metadata
try: try:
with open(CSV_META_FILE, 'r', encoding='utf-8') as f: with open(CSV_META_FILE, "r", encoding="utf-8") as f:
metadata = json.load(f) metadata = json.load(f)
if not isinstance(metadata, dict) or metadata.get("version") != DEFAULT_CSV_METADATA["version"]: if not isinstance(metadata, dict) or metadata.get("version") != DEFAULT_CSV_METADATA["version"]:
print(f"[Autocomplete-Plus] Metadata version mismatch. Expected {DEFAULT_CSV_METADATA['version']}, " print(
f"found {metadata.get('version')}. Using default metadata.") f"[Autocomplete-Plus] Metadata version mismatch. Expected {DEFAULT_CSV_METADATA['version']}, "
f"found {metadata.get('version')}. Using default metadata."
)
return default_metadata return default_metadata
else: else:
self.csv_meta_file_exists_at_start = True self.csv_meta_file_exists_at_start = True
@@ -101,7 +107,7 @@ class Downloader:
"""Saves metadata to CSV_META_FILE.""" """Saves metadata to CSV_META_FILE."""
try: try:
os.makedirs(os.path.dirname(CSV_META_FILE), exist_ok=True) os.makedirs(os.path.dirname(CSV_META_FILE), exist_ok=True)
with open(CSV_META_FILE, 'w', encoding='utf-8') as f: with open(CSV_META_FILE, "w", encoding="utf-8") as f:
json.dump(self.metadata, f, indent=2) json.dump(self.metadata, f, indent=2)
except IOError as e: except IOError as e:
print(f"[Autocomplete-Plus] Error saving metadata to {CSV_META_FILE}: {e}") print(f"[Autocomplete-Plus] Error saving metadata to {CSV_META_FILE}: {e}")
@@ -111,12 +117,15 @@ class Downloader:
Retrieves the Last-Modified header for a file on HuggingFace and returns it as an ISO 8601 string. Retrieves the Last-Modified header for a file on HuggingFace and returns it as an ISO 8601 string.
hf_filename should be the full filename, e.g., "danbooru_tags.csv". hf_filename should be the full filename, e.g., "danbooru_tags.csv".
""" """
url = f"https://huggingface.co/datasets/{dataset_repo_id}/resolve/main/{hf_filename}" url = f"{HUGGINGFACE_URL}/datasets/{dataset_repo_id}/resolve/main/{hf_filename}"
try: try:
req = urllib.request.Request(url, method='HEAD', req = urllib.request.Request(
headers={"User-Agent": "ComfyUI-Autocomplete-Plus (Python urllib)"}) url,
method="HEAD",
headers={"User-Agent": "ComfyUI-Autocomplete-Plus (Python urllib)"},
)
with urllib.request.urlopen(req, timeout=10) as response: with urllib.request.urlopen(req, timeout=10) as response:
last_modified_http = response.getheader('Last-Modified') last_modified_http = response.getheader("Last-Modified")
if last_modified_http: if last_modified_http:
dt_object = parsedate_to_datetime(last_modified_http) dt_object = parsedate_to_datetime(last_modified_http)
if dt_object.tzinfo is None or dt_object.tzinfo.utcoffset(dt_object) is None: if dt_object.tzinfo is None or dt_object.tzinfo.utcoffset(dt_object) is None:
@@ -141,7 +150,7 @@ class Downloader:
""" """
Downloads a file synchronously with progress display to a temporary location. Downloads a file synchronously with progress display to a temporary location.
""" """
download_url = f"https://huggingface.co/datasets/{hf_dataset_id}/resolve/main/{file_name}" download_url = f"{HUGGINGFACE_URL}/datasets/{hf_dataset_id}/resolve/main/{file_name}"
final_path = get_file_path(file_name) final_path = get_file_path(file_name)
temp_path = get_temp_download_path(file_name) temp_path = get_temp_download_path(file_name)
now_utc = datetime.now(timezone.utc).isoformat() now_utc = datetime.now(timezone.utc).isoformat()
@@ -155,18 +164,28 @@ class Downloader:
if os.path.exists(temp_path): if os.path.exists(temp_path):
os.remove(temp_path) os.remove(temp_path)
req = urllib.request.Request(download_url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 11.0; Win64)"}) req = urllib.request.Request(
download_url,
headers={"User-Agent": "Mozilla/5.0 (Windows NT 11.0; Win64)"},
)
with urllib.request.urlopen(req) as response: with urllib.request.urlopen(req) as response:
total_size_str = response.getheader("Content-Length") total_size_str = response.getheader("Content-Length")
total_size = int(total_size_str) if total_size_str else None total_size = int(total_size_str) if total_size_str else None
chunk_size = 8192 chunk_size = 8192
with open(temp_path, "wb") as f_out, \ with (
tqdm(total=total_size, unit='B', unit_scale=True, unit_divisor=1024, open(temp_path, "wb") as f_out,
desc=f"[Autocomplete-Plus] Downloading {file_name}", leave=False, tqdm(
bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}{postfix}]' total=total_size,
) as pbar: unit="B",
unit_scale=True,
unit_divisor=1024,
desc=f"[Autocomplete-Plus] Downloading {file_name}",
leave=False,
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}{postfix}]",
) as pbar,
):
while True: while True:
chunk = response.read(chunk_size) chunk = response.read(chunk_size)
if not chunk: if not chunk:
@@ -197,12 +216,15 @@ class Downloader:
if os.path.exists(final_path): if os.path.exists(final_path):
try: try:
file_size_at_final = os.path.getsize(final_path) file_size_at_final = os.path.getsize(final_path)
if file_size_at_final == 0 or \ if (
(total_size and total_size > 0 and file_size_at_final < total_size) or \ file_size_at_final == 0
(not total_size and downloaded_size > 0 and file_size_at_final < downloaded_size): or (total_size and total_size > 0 and file_size_at_final < total_size)
or (not total_size and downloaded_size > 0 and file_size_at_final < downloaded_size)
):
os.remove(final_path) os.remove(final_path)
print( print(
f"[Autocomplete-Plus] Removed potentially corrupted file at final destination: {final_path}") f"[Autocomplete-Plus] Removed potentially corrupted file at final destination: {final_path}"
)
except OSError as rm_e: except OSError as rm_e:
print(f"[Autocomplete-Plus] Error removing potentially corrupted file {final_path}: {rm_e}") print(f"[Autocomplete-Plus] Error removing potentially corrupted file {final_path}: {rm_e}")
@@ -213,15 +235,7 @@ class Downloader:
def _check_new_csv_from_hf_dataset(self, dataset_meta: dict, now_utc: datetime, force_check: bool = False): def _check_new_csv_from_hf_dataset(self, dataset_meta: dict, now_utc: datetime, force_check: bool = False):
"""Checks HuggingFace for file updates and updates metadata.""" """Checks HuggingFace for file updates and updates metadata."""
perform_hf_check = True perform_hf_check = force_check
if not force_check and dataset_meta.get("last_remote_check_timestamp"):
try:
last_check_dt = datetime.fromisoformat(dataset_meta["last_remote_check_timestamp"])
if now_utc - last_check_dt < timedelta(days=7):
perform_hf_check = False
except (ValueError, KeyError, TypeError):
print(
"[Autocomplete-Plus] Invalid or missing timestamp for last_remote_check_timestamp. Will perform remote check.")
if perform_hf_check: if perform_hf_check:
huggingface_dataset_id = dataset_meta["hf_dataset_id"] huggingface_dataset_id = dataset_meta["hf_dataset_id"]
@@ -246,7 +260,8 @@ class Downloader:
dataset_meta["last_remote_check_timestamp"] = now_utc.isoformat() dataset_meta["last_remote_check_timestamp"] = now_utc.isoformat()
else: else:
print( print(
"[Autocomplete-Plus] Could not update all remote timestamps from HuggingFace. Will try again later.") "[Autocomplete-Plus] Could not update all remote timestamps from HuggingFace. Will try again later."
)
def _download_csv_files_if_needed(self, dataset_meta: dict): def _download_csv_files_if_needed(self, dataset_meta: dict):
"""Downloads CSV files if they are missing, outdated, or previously failed.""" """Downloads CSV files if they are missing, outdated, or previously failed."""
@@ -279,15 +294,16 @@ class Downloader:
return f"File {file_name} is missing or empty locally." return f"File {file_name} is missing or empty locally."
# Check if the last modified date on HuggingFace is newer than the last download date # Check if the last modified date on HuggingFace is newer than the last download date
try: # Temporarily comment out until changes to remote files can be detected
last_download_dt = datetime.fromisoformat(file_meta_entry["last_download"]) # try:
hf_modified_dt = datetime.fromisoformat(file_meta_entry["last_modified_on_hf"]) # last_download_dt = datetime.fromisoformat(file_meta_entry["last_download"])
if hf_modified_dt > last_download_dt: # hf_modified_dt = datetime.fromisoformat(file_meta_entry["last_modified_on_hf"])
return f"Remote file {file_name} is newer (HF: {last_download_dt}, Local Download: {hf_modified_dt})." # if hf_modified_dt > last_download_dt:
except (ValueError, TypeError): # return f"Remote file {file_name} is newer (HF: {hf_modified_dt}, Local Download: {last_download_dt})."
file_meta_entry["last_download"] = None # except (ValueError, TypeError):
file_meta_entry["last_modified_on_hf"] = None # file_meta_entry["last_download"] = None
return f"Invalid timestamp format for {file_name}. Forcing download to ensure integrity." # file_meta_entry["last_modified_on_hf"] = None
# return f"Invalid timestamp format for {file_name}. Forcing download to ensure integrity."
# If the file is missing or empty, but the last download timestamp exists, we need to retry. # If the file is missing or empty, but the last download timestamp exists, we need to retry.
if not check_file_valid(local_file_path) and file_meta_entry.get("last_download") is not None: if not check_file_valid(local_file_path) and file_meta_entry.get("last_download") is not None:
@@ -303,6 +319,10 @@ class Downloader:
Args: Args:
force_check: If True, forces a check of HuggingFace regardless of the last check timestamp. force_check: If True, forces a check of HuggingFace regardless of the last check timestamp.
""" """
# If check_updates_on_startup is False and force_check is not set, skip the check
if not (self.metadata.get("check_updates_on_startup", True) or force_check):
print('[Autocomplete-Plus] "check_updates_on_startup" is disabled. Skipping CSV update check and download.')
return
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
+1536 -745
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,13 +1,14 @@
{ {
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "jest" "test": "env-cmd -e test -- jest"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.27.1", "@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2", "@babel/preset-env": "^7.27.2",
"babel-jest": "^29.7.0", "babel-jest": "^30.2.0",
"jest": "^29.7.0", "env-cmd": "^10.1.0",
"jest": "^30.2.0",
"stylelint": "^16.19.1", "stylelint": "^16.19.1",
"stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard": "^38.0.0" "stylelint-config-standard": "^38.0.0"
+10 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-autocomplete-plus" name = "comfyui-autocomplete-plus"
description = "Autocomplete and Related Tag display for ComfyUI" description = "Autocomplete and Related Tag display for ComfyUI"
version = "1.0.1" version = "1.11.0"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = ["",] dependencies = ["",]
@@ -9,7 +9,16 @@ dependencies = ["",]
Repository = "https://github.com/newtextdoc1111/ComfyUI-Autocomplete-Plus" Repository = "https://github.com/newtextdoc1111/ComfyUI-Autocomplete-Plus"
# Used by Comfy Registry https://comfyregistry.org # Used by Comfy Registry https://comfyregistry.org
[dependency-groups]
dev = [
"ruff>=0.12.2",
]
[tool.comfy] [tool.comfy]
PublisherId = "newtextdoc1111" PublisherId = "newtextdoc1111"
DisplayName = "ComfyUI-Autocomplete-Plus" DisplayName = "ComfyUI-Autocomplete-Plus"
Icon = "" Icon = ""
[tool.ruff]
# Allow lines to be as long as 120.
line-length = 120
+271
View File
@@ -0,0 +1,271 @@
import {
formatPromptText,
__test__
} from "../../web/js/auto-formatter.js";
import { settingValues } from "../../web/js/settings.js";
const {
shouldAutoFormat
} = __test__;
describe('AutoFormatter Functions', () => {
describe('shouldAutoFormat', () => {
const mockNodeInfo = (nodeType, inputName) => ({
nodeType,
inputName
});
// Store original setting value to restore after tests
const originalTrimSurroundingSpaces = settingValues.trimSurroundingSpaces;
afterEach(() => {
// Restore original setting after each test
settingValues.trimSurroundingSpaces = originalTrimSurroundingSpaces;
});
test('should return false for empty text', () => {
expect(shouldAutoFormat('', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat(' ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
test('should return false for blocklisted nodes', () => {
expect(shouldAutoFormat('some text,', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat('some text,', mockNodeInfo('LoraLoaderBlockWeight //Inspire', 'block_vector'))).toBe(false);
});
test('should return false for numeric data or single-letter placeholders', () => {
expect(shouldAutoFormat('0,0,0,1,1,1', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('0.5, -1.2, 0.8', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('A,B,R', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('X, 1.5, Y', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
test('should return true for text with "word + comma" pattern', () => {
expect(shouldAutoFormat('1girl, blue hair,', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
expect(shouldAutoFormat('tag1, tag2', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
});
test('should return false if "word + comma" pattern is not found', () => {
expect(shouldAutoFormat('hello world', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('tag1 tag2', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
describe('blocklist priority with trimSurroundingSpaces', () => {
test('should prioritize blocklist over trimSurroundingSpaces when disabled', () => {
settingValues.trimSurroundingSpaces = false;
// Blocklisted nodes should return false even with surrounding spaces
expect(shouldAutoFormat(' some code ', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat('\nsome code\n', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat(' 0,0,0,1,1,1 ', mockNodeInfo('LoraLoaderBlockWeight //Inspire', 'block_vector'))).toBe(false);
});
test('should prioritize blocklist over trimSurroundingSpaces when enabled', () => {
settingValues.trimSurroundingSpaces = true;
// Blocklisted nodes should return false even with trimSurroundingSpaces enabled and surrounding spaces
expect(shouldAutoFormat(' some code ', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat('\nsome code\n', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat(' function() { } ', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat(' 0,0,0,1,1,1 ', mockNodeInfo('LoraLoaderBlockWeight //Inspire', 'block_vector'))).toBe(false);
});
});
describe('trimSurroundingSpaces with non-comma-separated text', () => {
test('should return false for non-comma-separated text when trimSurroundingSpaces is disabled', () => {
settingValues.trimSurroundingSpaces = false;
// Text without word+comma pattern should return false
expect(shouldAutoFormat(' hello world ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('\nhello world\n', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat(' tag1 tag2 ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
test('should return true for non-comma-separated text with surrounding spaces when trimSurroundingSpaces is enabled', () => {
settingValues.trimSurroundingSpaces = true;
// Text with surrounding spaces should return true to format (trim them)
expect(shouldAutoFormat(' hello world ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
expect(shouldAutoFormat('\nhello world\n', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
expect(shouldAutoFormat(' tag1 tag2 ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
expect(shouldAutoFormat(' \nsome text\n ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
});
test('should return false for text without surrounding spaces even when trimSurroundingSpaces is enabled', () => {
settingValues.trimSurroundingSpaces = true;
// Text without word+comma pattern and without surrounding spaces should return false
expect(shouldAutoFormat('hello world', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('tag1 tag2', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
});
});
describe('formatPromptText', () => {
// Store original setting value to restore after tests
const originalUseTrailingComma = settingValues.useTrailingComma;
const originalTrimSurroundingSpaces = settingValues.trimSurroundingSpaces;
afterEach(() => {
// Restore original setting after each test
settingValues.useTrailingComma = originalUseTrailingComma;
settingValues.trimSurroundingSpaces = originalTrimSurroundingSpaces;
});
describe('with useTrailingComma enabled', () => {
beforeEach(() => {
settingValues.useTrailingComma = true;
settingValues.trimSurroundingSpaces = false;
});
test('should format text by adding comma and space after tags', () => {
const input = 'tag1,tag2,tag3';
const expected = 'tag1, tag2, tag3, ';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove extra spaces around tags', () => {
const input = ' tag1 , tag2 ';
const expected = 'tag1, tag2, ';
expect(formatPromptText(input)).toBe(expected);
});
test('should preserve special syntax like weights', () => {
const input = '(tag1:1.2), [tag2]';
// Note: The current implementation splits by comma.
// If the input is "(tag1:1.2), [tag2]", it splits into "(tag1:1.2)" and "[tag2]".
// Then joins with ", ".
const expected = '(tag1:1.2), [tag2], ';
expect(formatPromptText(input)).toBe(expected);
});
test('should handle multiple lines', () => {
const input = 'tag1, tag2\ntag3, tag4';
const expected = 'tag1, tag2, \ntag3, tag4, ';
expect(formatPromptText(input)).toBe(expected);
});
test('should keep empty lines unchanged', () => {
const input = 'tag1, tag2\n\ntag3, tag4';
const expected = 'tag1, tag2, \n\ntag3, tag4, ';
expect(formatPromptText(input)).toBe(expected);
});
test('should not modify text that already has trailing comma', () => {
const input = 'tag1, tag2, ';
const expected = 'tag1, tag2, ';
expect(formatPromptText(input)).toBe(expected);
});
});
describe('with useTrailingComma disabled', () => {
beforeEach(() => {
settingValues.useTrailingComma = false;
settingValues.trimSurroundingSpaces = false;
});
test('should format text by adding comma and space after tags without trailing comma', () => {
const input = 'tag1,tag2,tag3';
const expected = 'tag1, tag2, tag3';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove extra spaces around tags without trailing comma', () => {
const input = ' tag1 , tag2 ';
const expected = 'tag1, tag2';
expect(formatPromptText(input)).toBe(expected);
});
test('should preserve special syntax like weights without trailing comma', () => {
const input = '(tag1:1.2), [tag2]';
// Note: The current implementation splits by comma.
// If the input is "(tag1:1.2), [tag2]", it splits into "(tag1:1.2)" and "[tag2]".
// Then joins with ", ".
const expected = '(tag1:1.2), [tag2]';
expect(formatPromptText(input)).toBe(expected);
});
test('should handle multiple lines without trailing comma', () => {
const input = 'tag1, tag2\ntag3, tag4';
const expected = 'tag1, tag2\ntag3, tag4';
expect(formatPromptText(input)).toBe(expected);
});
test('should keep empty lines unchanged without trailing comma', () => {
const input = 'tag1, tag2\n\ntag3, tag4';
const expected = 'tag1, tag2\n\ntag3, tag4';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove existing trailing comma when disabled', () => {
const input = 'tag1, tag2, ';
const expected = 'tag1, tag2';
expect(formatPromptText(input)).toBe(expected);
});
});
describe('with trimSurroundingSpaces enabled', () => {
beforeEach(() => {
settingValues.trimSurroundingSpaces = true;
settingValues.useTrailingComma = false;
});
test('should remove trailing newlines and spaces', () => {
const input = 'tag1, tag2\n\n ';
const expected = 'tag1, tag2';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove leading newlines and spaces', () => {
const input = ' \n\ntag1, tag2';
const expected = 'tag1, tag2';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove both leading and trailing whitespaces but keep middle empty lines', () => {
const input = ' \n\ntag1\n\ntag2\n\n ';
const expected = 'tag1\n\ntag2';
expect(formatPromptText(input)).toBe(expected);
});
test('should return empty string if input is only whitespace', () => {
const input = ' \n ';
expect(formatPromptText(input)).toBe('');
});
});
describe('with trimSurroundingSpaces disabled', () => {
beforeEach(() => {
settingValues.trimSurroundingSpaces = false;
settingValues.useTrailingComma = false;
});
test('should preserve trailing newlines', () => {
const input = 'tag1, tag2\n\n';
const expected = 'tag1, tag2\n\n';
expect(formatPromptText(input)).toBe(expected);
});
test('should preserve leading spaces (as first line content)', () => {
const input = ' \ntag1';
const expected = '\ntag1';
expect(formatPromptText(input)).toBe(expected);
});
test('should handle input with only spaces', () => {
expect(formatPromptText(' ')).toBe(' ');
});
});
test('should handle empty input', () => {
expect(formatPromptText('')).toBe('');
expect(formatPromptText(null)).toBe(null);
expect(formatPromptText(undefined)).toBe(undefined);
});
test('should handle input with only spaces', () => {
expect(formatPromptText(' ')).toBe(' ');
});
});
});
+389
View File
@@ -0,0 +1,389 @@
import {
__test__
} from "../../web/js/autocomplete.js";
import {
TagData,
TagSource,
ModelTagSource,
autoCompleteData
} from "../../web/js/data.js";
import {
createFlexSearchDocument,
createFlexSearchDocumentForModel
} from "../../web/js/searchengine.js"
const {
searchCompletionCandidates,
sequentialSearch,
searchWithFlexSearch,
matchWord,
getCurrentPartialTag,
insertTagToTextArea
} = __test__;
// Helper function to create mock textarea element
function createMockTextarea(value, selectionStart, selectionEnd = -1) {
return {
value: value,
selectionStart: selectionStart,
selectionEnd: selectionEnd > selectionStart ? selectionEnd : selectionStart,
nodeName: 'TEXTAREA',
focus() { document.setFocus(this); },
setSelectionRange: () => { },
getBoundingClientRect: () => ({
top: 0,
left: 0,
width: 300,
height: 100
}),
scrollTop: 0,
scrollLeft: 0,
ownerDocument: {
defaultView: typeof window !== 'undefined' ? window : {},
documentElement: typeof document !== 'undefined' ? document.documentElement : {}
},
dispatchEvent: () => { }
};
}
// Test data setup function
function setupTestData() {
// Clear existing data
Object.keys(autoCompleteData).forEach(key => delete autoCompleteData[key]);
// Create mock AutocompleteData structure for each source
const sources = [...Object.values(TagSource), ...Object.values(ModelTagSource)];
sources.forEach(source => {
autoCompleteData[source] = {
flexSearchDocument: null,
sortedTags: [],
tagMap: new Map(),
aliasMap: new Map(),
cooccurrenceMap: new Map(),
isInitializing: false,
initialized: true,
baseLoadingProgress: {
cooccurrence: 0
}
};
});
// Add sample tag data for testing
const sampleTags = [
new TagData('1girl', 0, 1000000, ['one_girl'], TagSource.Danbooru),
new TagData('blue_hair', 0, 500000, ['blue hair'], TagSource.Danbooru),
new TagData('long_hair', 0, 800000, [], TagSource.Danbooru),
new TagData('__wildcard__', 0, 100, [], TagSource.Danbooru),
new TagData(':d', 0, 50000, [], TagSource.Danbooru),
new TagData('test_tag', 0, 1000, ['test'], TagSource.E621),
new TagData('<lora:test_model>', 0, 0, [], ModelTagSource.Lora),
new TagData('embedding:test_embedding', 0, 0, [], ModelTagSource.Embeddings)
];
// Distribute tags across sources
sampleTags.forEach(tagData => {
const source = tagData.source;
autoCompleteData[source].sortedTags.push(tagData);
autoCompleteData[source].tagMap.set(tagData.tag, tagData);
// Add aliases to alias map
if (tagData.alias && Array.isArray(tagData.alias)) {
tagData.alias.forEach(alias => {
autoCompleteData[source].aliasMap.set(alias, tagData.tag);
});
}
});
// Sort tags and build flexsearch index
sources.forEach(source => {
autoCompleteData[source].sortedTags.sort((a, b) => b.count - a.count);
const isModelSrc = Object.values(ModelTagSource).includes(source);
const doc = isModelSrc ? createFlexSearchDocumentForModel() : createFlexSearchDocument();
autoCompleteData[source].sortedTags.forEach((tagData, i) => {
doc.add(i, tagData);
});
autoCompleteData[source].flexSearchDocument = doc;
});
}
describe('Autocomplete Functions', () => {
beforeEach(() => {
setupTestData();
// Mock global document
global.document = {
execCommand: (commandId, showUI, value) => {
switch (commandId) {
case "insertText":
if (document.focusedElement) {
document.focusedElement.value += value;
}
break;
default:
throw new Error('Not implemented!');
}
},
createElement: () => ({
id: '',
style: {},
innerHTML: '',
appendChild: () => { },
getBoundingClientRect: () => ({
top: 0,
left: 0,
width: 300,
height: 100
})
}),
setFocus: (element) => {
document.focusedElement = element;
},
body: {
appendChild: () => { },
removeChild: () => { }
},
focusedElement: null
};
// Mock global window
global.window = {
getComputedStyle: () => ({
lineHeight: '20px',
fontSize: '14px',
fontFamily: 'Arial'
})
};
});
describe('matchWord', () => {
test('should match exact queries', () => {
const queries = new Set(['1girl', '1girls']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(true);
});
test('should match partial queries', () => {
const queries = new Set(['girl']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should handle wildcard prefixes', () => {
const queries = new Set(['__wild']);
const result = matchWord('__wildcard__', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should handle symbol-only queries', () => {
const queries = new Set([':d']);
const result = matchWord(':d', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(true);
});
test('should match after removing common symbols', () => {
const queries = new Set(['blue hair']);
const result = matchWord('blue_hair', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should not match unrelated queries', () => {
const queries = new Set(['test']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(false);
expect(result.isExactMatch).toBe(false);
});
});
describe('getCurrentPartialTag', () => {
test('should extract partial tag before cursor', () => {
const textarea = createMockTextarea('1girl, blue_hair', 14);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('blue_ha');
});
test('should return full tag when cursor is at the end', () => {
const textarea = createMockTextarea('1girl', 5);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('1girl');
});
test('should handle empty textarea', () => {
const textarea = createMockTextarea('', 0);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('');
});
test('should handle newline separators', () => {
const textarea = createMockTextarea('1girl\nblue_h', 12);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('blue_h');
});
});
describe('searchCompletionCandidates', () => {
test('should return an empty array for empty input', () => {
const textarea = createMockTextarea('', 0);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return an empty array for null input', () => {
const results = searchCompletionCandidates(null);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return an empty array for undefined input', () => {
const results = searchCompletionCandidates(undefined);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return multiple candidates for valid input', () => {
const textarea = createMockTextarea('test', 4);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
test('should find wildcard tag', () => {
const textarea = createMockTextarea('__wild', 6);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining(['__wildcard__']));
});
test('should find emoticon tag', () => {
const textarea = createMockTextarea(':d', 2);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([':d']));
});
});
describe('sequentialSearch', () => {
test('should find and return matching tags', () => {
const partialTag = 'test';
const queryVariations = new Set([partialTag.toLowerCase()]);
const results = sequentialSearch(partialTag, queryVariations);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
});
describe('searchWithFlexSearch', () => {
test('should find and return matching tags using FlexSearch', () => {
const partialTag = 'test';
const queryVariations = new Set([partialTag.toLowerCase()]);
const results = searchWithFlexSearch(partialTag, queryVariations);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
});
describe('insertTagToTextArea', () => {
test('should insert tag and replace underscore with space', () => {
const textarea = createMockTextarea('1girl, ', 7);
const tagData = { tag: 'blue_hair', source: 'danbooru' };
expect(() => {
insertTagToTextArea(textarea, tagData);
}).not.toThrow();
expect(textarea.value).toBe('1girl, blue hair, ');
});
test('should complete model tag and add trailing comma', () => {
const textarea = createMockTextarea('<lora:', 6);
const tagData = { tag: '<lora:my_model>', source: 'lora' };
expect(() => {
insertTagToTextArea(textarea, tagData);
}).not.toThrow();
expect(textarea.value).toBe('<lora:my_model:1.0>, ');
});
test('should complete embedding tag without adding weight', () => {
const textarea = createMockTextarea('embedding:', 10);
const tagData = { tag: 'embedding:my_embedding', source: 'embeddings' };
expect(() => {
insertTagToTextArea(textarea, tagData);
}).not.toThrow();
expect(textarea.value).toBe('embedding:my_embedding, ');
});
test('should handle wildcard tag insertion', () => {
const textarea = createMockTextarea('__wild', 6);
const tagData = { tag: '__wildcard__', source: 'danbooru' };
insertTagToTextArea(textarea, tagData);
expect(textarea.value).toBe('__wildcard__, ');
});
test('should handle emoticon tag insertion', () => {
const textarea = createMockTextarea(':d', 2);
const tagData = { tag: ':d', source: 'danbooru' };
insertTagToTextArea(textarea, tagData);
expect(textarea.value).toBe(':d, ');
});
});
});
+387
View File
@@ -0,0 +1,387 @@
import {
createFlexSearchDocument,
createFlexSearchDocumentForModel,
__test__
} from "../../web/js/searchengine.js";
const { createTagEncoder, createCJKEncoder, createModelEncoder } = __test__;
function parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}
describe('FlexSearch Integration', () => {
const commonCSV = `
1girl,0,6008644,"1girls,sole_female"
highres,5,5256195,"high_res,high_resolution,hires"
solo,0,5000954,"alone,female_solo,single,solo_female,solo_in_panel"
long_hair,0,4350743,"/lh,longhair,very_long_hair"
one_two_three,0,29389,
`;
const cjkAliasCSV = `
blue_hair,0,676176,"青髪,青い髪,水色髪"
red_hair,0,413261,"赤髪,紅髪,红发,빨강머리,빨간머리"
smile,0,2294308,"笑い,スマイル,笑顔,笑顏,守りたい、この笑顔,笑,笑容,微笑み,微笑,微笑む,미소,守りたいこの笑顔"
gloves,0,1105296,"手袋,裸手袋,手袋コキ,てぶくろ,장갑,手套"
dragon_girl,0,37930,"竜娘,ドラゴン娘,龍娘,龙娘,メスドラ,辰娘"
double_bun,0,103538,"お団子頭,お団子"
sanshoku_dango,0,2061,"三色団子,三色团子,花見団子,花见团子"
`;
const specialCharCSV = `
:d,0,436700,
>:),0,11041,
year:1999,0,1999,
d.d.,0,1999,
copyright_(series),2,1298,"copyright,コピーライト (シリーズ),コピーライト名,コピーライト,著作"
__wildcard__,0,0,
`;
const ModelCSV = `
<lora:my_lora1>,0,0,
<lora:日本語Lora_v1>,0,0,
embedding: my_embedding,0,0,
`;
const mockCSV = [
commonCSV, cjkAliasCSV, specialCharCSV
].map(csv => csv.trim()).join('\n');
let mockTags, mockModelTags;
let tagEncoder, cjkEncoder, modelEncoder;
let document, modelDocument;
let performSearch = function (query, limit = 100) {
const ids1 = document.search(query, {
field: ["tag", "alias"],
limit: limit,
suggest: false,
merge: true,
}).map(r => r.id);
const result1 = mockTags.filter(tag => ids1.includes(tag.id)).map(tag => tag.tag);
const ids2 = modelDocument.search(query, {
field: ["tag", "alias"],
limit: limit,
suggest: false,
merge: true,
}).map(r => r.id);
const result2 = mockModelTags.filter(tag => ids2.includes(tag.id)).map(tag => tag.tag);
return [...result1, ...result2];
}
beforeEach(() => {
mockTags = mockCSV.split('\n').map((line, id) => {
const [tag, category, count, alias] = parseCSVLine(line);
return { id, tag, category: parseInt(category), count: parseInt(count), alias };
});
tagEncoder = createTagEncoder();
cjkEncoder = createCJKEncoder();
document = createFlexSearchDocument();
mockTags.forEach(data => document.add(data));
mockModelTags = ModelCSV.split('\n').map((line, id) => {
const [tag, category, count, alias] = parseCSVLine(line);
return { id, tag, category: parseInt(category), count: parseInt(count), alias };
});
modelEncoder = createModelEncoder();
modelDocument = createFlexSearchDocumentForModel();
mockModelTags.forEach(data => modelDocument.add(data));
});
describe('Encoder', () => {
test('should split underscore-separated tags', () => {
const encoded = tagEncoder.encode('sanshoku_dango');
expect(encoded).toEqual(['sanshoku', 'dango']);
});
test('should extract words from parentheses', () => {
const encoded = tagEncoder.encode('copyright_(series)');
expect(encoded).toEqual(['copyright', 'series']);
});
test('should preserve colon-separated special tags', () => {
const encoded = tagEncoder.encode('year:1234');
expect(encoded).toEqual(['year:1234']);
});
test('should preserve dot-separated tags', () => {
const encoded = tagEncoder.encode('d.d.');
expect(encoded).toEqual(['d.d.']);
});
test('should preserve double underscore wildcard tags', () => {
const encoded = tagEncoder.encode('__wildcard__');
expect(encoded).toEqual(['__wildcard__']);
});
test('should convert katakana to hiragana', () => {
const encoded = cjkEncoder.encode('ガーデン');
expect(encoded).toEqual(['がーでん']);
});
test('should remove trailing underscores when splitting', () => {
const encoded = tagEncoder.encode('one_two_');
expect(encoded).toEqual(['one', 'two']);
});
test('should properly encode embedding notation', () => {
let encoded = modelEncoder.encode('embedding:path/to/my_embed1');
expect(encoded).toEqual(['embedding:', 'path', 'to', 'my', 'embed1']);
expect(
modelEncoder.encode('embedding:path\\to\\my-embed1')
).toEqual(['embedding:', 'path', 'to', 'my', 'embed1']);
expect(
modelEncoder.encode('embedding:path\\to\\this is my embed. my-negative01 (v1)__by me')
).toEqual(['embedding:', 'path', 'to', 'this', 'is', 'my', 'embed', 'my', 'negative01', 'v1', 'by', 'me']);
});
test('should properly encode lora notation', () => {
expect(
modelEncoder.encode('<lora:path/to/my_lora1>')
).toEqual(['lora:', 'path', 'to', 'my', 'lora1']);
expect(
modelEncoder.encode('<lora:path\\to\\my-lora1>')
).toEqual(['lora:', 'path', 'to', 'my', 'lora1']);
expect(
modelEncoder.encode('<lora:path\\to\\this is my lora. my-style01 (v1)__by me>')
).toEqual(['lora:', 'path', 'to', 'this', 'is', 'my', 'lora', 'my', 'style01', 'v1', 'by', 'me']);
});
});
describe('Basic Search', () => {
test('should find a tag by exact match', () => {
const results = performSearch('1girl');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('1girl');
});
test('should find a tag by partial match (substring)', () => {
const results = performSearch('blue');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('blue_hair');
});
test('should be case-insensitive', () => {
const results = performSearch('BLUE_HAIR');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('blue_hair');
});
test('should find a tag by backward', () => {
const results = performSearch('gon');
expect(results.length).toEqual(1);
expect(results).toContain('dragon_girl');
});
test('should find a tag for terms contain space', () => {
const results = performSearch('double ');
expect(results.length).toEqual(1);
expect(results).toContain('double_bun');
});
test('should find a tag for terms contain underscore', () => {
const results = performSearch('double_');
expect(results.length).toEqual(1);
expect(results).toContain('double_bun');
});
});
describe('Alias Search', () => {
test('should find a tag by its Japanese alias', () => {
const results = performSearch('髪');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('blue_hair');
});
test('should find a tag by one of its multiple aliases', () => {
const results = performSearch('笑顔');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('smile');
});
test('should find a tag by its partial Japanese alias', () => {
const results = performSearch('青い');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('blue_hair');
});
test('should find a tag by katakana', () => {
const results = performSearch('テブクロ');
expect(results.length).toEqual(1);
expect(results).toContain('gloves');
});
test('should find a tag by hiragana', () => {
const results = performSearch('すまいる');
expect(results.length).toEqual(1);
expect(results).toContain('smile');
});
test('should not find a tag by english alias substring', () => {
const results = performSearch('meg');
expect(results.length).toEqual(0);
});
test('should find a tag by japanese alias substring', () => {
const results = performSearch('団子');
expect(results.length).toEqual(2);
expect(results).toContain('sanshoku_dango');
expect(results).toContain('double_bun');
});
});
describe('Special Characters and Edge Cases', () => {
test('should find a tag with parentheses', () => {
const results = performSearch('copyright_(series)');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('copyright_(series)');
});
test('should find a tag by searching for content inside parentheses', () => {
const results = performSearch('series');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('copyright_(series)');
});
test('should find a tag by partial word', () => {
const results = performSearch('right');
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('copyright_(series)');
});
test('should return an empty array for a non-existent tag', () => {
const results = performSearch('non_existent_tag_xyz');
expect(results).toEqual([]);
});
test('should match to special character only tag', () => {
const tag = '>:)';
const results = performSearch(tag);
expect(results.length).toEqual(1);
expect(results).toContain(tag);
});
test('should match to contain special character tag', () => {
const tag = ':d';
const results = performSearch(tag);
expect(results.length).toEqual(1);
expect(results).toContain(tag);
});
test('should match to contain special character tag2', () => {
const tag = 'year:1999';
const results = performSearch(tag);
expect(results.length).toEqual(1);
expect(results).toContain(tag);
});
test('should match to contain special character tag3', () => {
const tag = 'd.d.';
const results = performSearch(tag);
expect(results.length).toEqual(1);
expect(results).toContain(tag);
});
test('should match to wildcard tag', () => {
const tag = '__';
const results = performSearch(tag);
expect(results.length).toEqual(1);
expect(results).toContain('__wildcard__');
});
test('should match to lora tag', () => {
const tag = '<lora';
const results = performSearch(tag);
expect(results.length).toEqual(2);
expect(results).toContain("<lora:my_lora1>");
expect(results).toContain("<lora:日本語Lora_v1>");
});
test('should match to lora tag2', () => {
const tag = 'lora:';
const results = performSearch(tag);
expect(results.length).toEqual(2);
expect(results).toContain("<lora:my_lora1>");
expect(results).toContain("<lora:日本語Lora_v1>");
});
test('should match to lora that contain CJK characters', () => {
const word = 'lora: 日本語';
const results = performSearch(word);
expect(results.length).toEqual(1);
expect(results).toContain("<lora:日本語Lora_v1>");
});
});
describe('Search Options', () => {
test('should respect the limit option', () => {
const results = performSearch('hair', 1);
expect(results.length).toEqual(1);
});
test('should return all matches when limit is higher than results', () => {
const results = performSearch('hair', 5);
expect(results.length).toBeGreaterThan(0);
expect(results).toHaveLength(3);
expect(results).toContain('long_hair');
expect(results).toContain('blue_hair');
expect(results).toContain('red_hair');
});
});
});
+173 -1
View File
@@ -1,9 +1,11 @@
import { settingValues } from '../../web/js/settings.js';
import { import {
extractTagsFromTextArea, extractTagsFromTextArea,
normalizeTagToSearch, normalizeTagToSearch,
normalizeTagToInsert, normalizeTagToInsert,
getCurrentTagRange, getCurrentTagRange,
findAllTagPositions findAllTagPositions,
addWeightToLora
} from '../../web/js/utils.js'; } from '../../web/js/utils.js';
// filepath: web/js/utils.test.js // filepath: web/js/utils.test.js
@@ -135,6 +137,11 @@ describe('normalizeTagToSearch', () => {
}); });
describe('normalizeTagToInsert', () => { describe('normalizeTagToInsert', () => {
const originalValue = settingValues.replaceUnderscoreWithSpace;
afterEach(() => {
settingValues.replaceUnderscoreWithSpace = originalValue;
});
test('should return null or empty string for invalid inputs', () => { test('should return null or empty string for invalid inputs', () => {
expect(normalizeTagToInsert(null)).toBeNull(); expect(normalizeTagToInsert(null)).toBeNull();
expect(normalizeTagToInsert(undefined)).toBeUndefined(); expect(normalizeTagToInsert(undefined)).toBeUndefined();
@@ -167,6 +174,73 @@ describe('normalizeTagToInsert', () => {
expect(normalizeTagToInsert('year:2000')).toBe('year:2000'); expect(normalizeTagToInsert('year:2000')).toBe('year:2000');
expect(normalizeTagToInsert('foo:bar')).toBe('foo:bar'); expect(normalizeTagToInsert('foo:bar')).toBe('foo:bar');
}); });
test('should respect replaceUnderscoreWithSpace setting when true', () => {
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('blue_hair')).toBe('blue hair');
expect(normalizeTagToInsert('red_eyes')).toBe('red eyes');
expect(normalizeTagToInsert('long_curly_hair')).toBe('long curly hair');
});
test('should respect replaceUnderscoreWithSpace setting when false', () => {
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('blue_hair')).toBe('blue_hair');
expect(normalizeTagToInsert('red_eyes')).toBe('red_eyes');
expect(normalizeTagToInsert('long_curly_hair')).toBe('long_curly_hair');
});
test('should respect replaceUnderscoreWithSpace setting with parentheses', () => {
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue hair\\(style\\)');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue_hair\\(style\\)');
});
test('should not replace underscores in wildcard syntax regardless of setting', () => {
// Wildcard syntax should preserve underscores regardless of setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
});
test('should not replace underscores in symbol-only tags regardless of setting', () => {
// Symbol-only tags should not be affected by the setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
});
test('should not escape parentheses when tag contains commas (multi-tags)', () => {
const multiTag = 'masterpiece, best quality, (high quality), ultra-detailed';
expect(normalizeTagToInsert(multiTag)).toBe('masterpiece, best quality, (high quality), ultra-detailed');
});
test('should not escape parentheses in multi-tag strings with parentheses', () => {
const multiTag = '(worst quality, low quality, normal quality), bad anatomy';
expect(normalizeTagToInsert(multiTag)).toBe('(worst quality, low quality, normal quality), bad anatomy');
});
test('should handle multi-tags with both underscores and parentheses correctly', () => {
settingValues.replaceUnderscoreWithSpace = true;
const multiTag = 'best_quality, (high_quality:1.2), ultra_detailed';
expect(normalizeTagToInsert(multiTag)).toBe('best quality, (high quality:1.2), ultra detailed');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert(multiTag)).toBe('best_quality, (high_quality:1.2), ultra_detailed');
});
test('should still escape parentheses for single tags without commas', () => {
// Single tags (not multi-tags) should still have parentheses escaped
expect(normalizeTagToInsert('blue(hair)')).toBe('blue\\(hair\\)');
expect(normalizeTagToInsert('(tag)')).toBe('\\(tag\\)');
});
}); });
describe('getCurrentTagRange', () => { describe('getCurrentTagRange', () => {
@@ -367,3 +441,101 @@ describe('findAllTagPositions', () => {
expect(positions[5].tag).toBe('last'); expect(positions[5].tag).toBe('last');
}); });
}); });
describe('addWeightToModel', () => {
test('should return null or empty string for invalid inputs', () => {
expect(addWeightToLora(null)).toBeNull();
expect(addWeightToLora(undefined)).toBeUndefined();
expect(addWeightToLora('')).toBe('');
});
// LoRA format tests
test('should add default weight to LoRA without weight', () => {
expect(addWeightToLora('<lora:my_style01>')).toBe('<lora:my_style01:1.0>');
expect(addWeightToLora('<lora:anime_style>')).toBe('<lora:anime_style:1.0>');
expect(addWeightToLora('<lora:character_lora>')).toBe('<lora:character_lora:1.0>');
});
test('should preserve existing weight in LoRA', () => {
expect(addWeightToLora('<lora:my_style01:0.8>')).toBe('<lora:my_style01:0.8>');
expect(addWeightToLora('<lora:anime_style:1.2>')).toBe('<lora:anime_style:1.2>');
expect(addWeightToLora('<lora:character_lora:0.5>')).toBe('<lora:character_lora:0.5>');
});
test('should handle LoRA with path separators', () => {
expect(addWeightToLora('<lora:folder/my_style01>')).toBe('<lora:folder/my_style01:1.0>');
expect(addWeightToLora('<lora:folder/subfolder/style>')).toBe('<lora:folder/subfolder/style:1.0>');
expect(addWeightToLora('<lora:folder/my_style01:0.7>')).toBe('<lora:folder/my_style01:0.7>');
});
test('should handle LoRA with symbols', () => {
expect(addWeightToLora('<lora:folder/myLora style>')).toBe('<lora:folder/myLora style:1.0>');
expect(addWeightToLora('<lora:folder/myLora-style>')).toBe('<lora:folder/myLora-style:1.0>');
expect(addWeightToLora('<lora:folder/myLora[style]>')).toBe('<lora:folder/myLora[style]:1.0>');
expect(addWeightToLora('<lora:folder/myLora v1.0>')).toBe('<lora:folder/myLora v1.0:1.0>');
expect(addWeightToLora('<lora:folder/myLora v1.0:0.75>')).toBe('<lora:folder/myLora v1.0:0.75>');
});
test('should handle case-insensitive LoRA tags', () => {
expect(addWeightToLora('<LORA:my_style01>')).toBe('<LORA:my_style01:1.0>');
expect(addWeightToLora('<LoRa:anime_style>')).toBe('<LoRa:anime_style:1.0>');
expect(addWeightToLora('<Lora:character_lora:0.9>')).toBe('<Lora:character_lora:0.9>');
});
// Custom default weight tests
test('should use custom default weight for LoRA', () => {
expect(addWeightToLora('<lora:my_style01>', 0.5)).toBe('<lora:my_style01:0.5>');
expect(addWeightToLora('<lora:anime_style>', 1.5)).toBe('<lora:anime_style:1.5>');
expect(addWeightToLora('<lora:character_lora>', 2.0)).toBe('<lora:character_lora:2.0>');
});
test('should not override existing weight even with custom default', () => {
expect(addWeightToLora('<lora:my_style01:0.8>', 2.0)).toBe('<lora:my_style01:0.8>');
});
// Non-model format tests
test('should return non-model tags as-is', () => {
expect(addWeightToLora('blue_hair')).toBe('blue_hair');
expect(addWeightToLora('1girl')).toBe('1girl');
expect(addWeightToLora('standing:1.2')).toBe('standing:1.2');
expect(addWeightToLora('<other:tag>')).toBe('<other:tag>');
expect(addWeightToLora('random:text:format')).toBe('random:text:format');
});
// Edge cases
test('should handle malformed LoRA tags', () => {
expect(addWeightToLora('<lora:>')).toBe('<lora:>');
expect(addWeightToLora('<lora>')).toBe('<lora>');
expect(addWeightToLora('lora:my_style01')).toBe('lora:my_style01');
});
test('should handle decimal weights correctly', () => {
expect(addWeightToLora('<lora:style:0.123>')).toBe('<lora:style:0.123>');
expect(addWeightToLora('<lora:style:1.999>')).toBe('<lora:style:1.999>');
});
test('should handle special characters in model names', () => {
expect(addWeightToLora('<lora:my-style_01>')).toBe('<lora:my-style_01:1.0>');
expect(addWeightToLora('<lora:style.v2>')).toBe('<lora:style.v2:1.0>');
});
// Embedding format tests - should not be modified
test('should not modify embedding tags without weight', () => {
expect(addWeightToLora('embedding:my_embedding')).toBe('embedding:my_embedding');
expect(addWeightToLora('embedding:character_emb')).toBe('embedding:character_emb');
expect(addWeightToLora('embedding:style_embedding')).toBe('embedding:style_embedding');
});
test('should not modify embedding tags with existing weight', () => {
expect(addWeightToLora('embedding:my_embedding:0.8')).toBe('embedding:my_embedding:0.8');
expect(addWeightToLora('embedding:character_emb:1.2')).toBe('embedding:character_emb:1.2');
expect(addWeightToLora('embedding:style_embedding:0.5')).toBe('embedding:style_embedding:0.5');
});
test('should not modify embedding tags regardless of case', () => {
expect(addWeightToLora('EMBEDDING:my_embedding')).toBe('EMBEDDING:my_embedding');
expect(addWeightToLora('Embedding:character_emb')).toBe('Embedding:character_emb');
expect(addWeightToLora('EmBeDdInG:style_emb')).toBe('EmBeDdInG:style_emb');
expect(addWeightToLora('EMBEDDING:my_embedding:0.9')).toBe('EMBEDDING:my_embedding:0.9');
});
});
+235 -167
View File
@@ -1,15 +1,21 @@
/* Light Theme */ /* Light Theme */
body { body {
--autocomplete-plus-text-color-cat-blue: var(--p-blue-600); --autocomplete-plus-text-color-cat-blue: var(--p-blue-700);
--autocomplete-plus-text-color-cat-red: var(--p-red-600); --autocomplete-plus-text-color-cat-red: var(--p-red-600);
--autocomplete-plus-text-color-cat-purple: var(--p-purple-600); --autocomplete-plus-text-color-cat-purple: var(--p-purple-600);
--autocomplete-plus-text-color-cat-green: var(--p-green-600); --autocomplete-plus-text-color-cat-green: var(--p-green-700);
--autocomplete-plus-text-color-cat-yellow: var(--p-yellow-600); --autocomplete-plus-text-color-cat-yellow: var(--p-yellow-600);
--autocomplete-plus-text-color-cat-gray: var(--p-gray-600); --autocomplete-plus-text-color-cat-gray: var(--p-gray-600);
--autocomplete-plus-text-color-cat-sky: var(--p-sky-500); --autocomplete-plus-text-color-cat-sky: var(--p-sky-600);
--autocomplete-plus-text-color-cat-orange: var(--p-orange-600); --autocomplete-plus-text-color-cat-orange: var(--p-orange-600);
--autocomplete-plus-text-color-cat-white: var(--p-neutral-700); --autocomplete-plus-text-color-cat-white: var(--p-neutral-700);
--autocomplete-plus-text-color-disabled: var(--p-gray-400); --autocomplete-plus-text-color-disabled: var(--p-gray-400);
--autocomplete-plus-list-color-even: var(--p-neutral-300);
--autocomplete-plus-list-color-odd: var(--p-neutral-400);
--wiki-icon-text-color: var(--input-text);
--wiki-icon-opacity-normal: 0.7;
--wiki-icon-opacity-hover: 1.0;
--wiki-icon-opacity-disabled: 0;
} }
/* Dark Theme */ /* Dark Theme */
@@ -24,6 +30,12 @@ body.dark-theme {
--autocomplete-plus-text-color-cat-orange: var(--p-orange-400); --autocomplete-plus-text-color-cat-orange: var(--p-orange-400);
--autocomplete-plus-text-color-cat-white: var(--p-neutral-200); --autocomplete-plus-text-color-cat-white: var(--p-neutral-200);
--autocomplete-plus-text-color-disabled: var(--p-gray-500); --autocomplete-plus-text-color-disabled: var(--p-gray-500);
--autocomplete-plus-list-color-even: var(--p-neutral-800);
--autocomplete-plus-list-color-odd: var(--p-neutral-900);
--wiki-icon-text-color: var(--input-text);
--wiki-icon-opacity-normal: 0.5;
--wiki-icon-opacity-hover: 0.9;
--wiki-icon-opacity-disabled: 0;
} }
#autocomplete-plus-root { #autocomplete-plus-root {
@@ -37,101 +49,101 @@ body.dark-theme {
color: var(--input-text); color: var(--input-text);
} }
#autocomplete-plus-root .danbooru[data-tag-category="general"] {
color: var(--autocomplete-plus-text-color-cat-blue);
}
#autocomplete-plus-root .danbooru[data-tag-category="artist"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
#autocomplete-plus-root .danbooru[data-tag-category="copyright"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
#autocomplete-plus-root .danbooru[data-tag-category="character"] {
color: var(--autocomplete-plus-text-color-cat-green);
}
#autocomplete-plus-root .danbooru[data-tag-category="meta"] {
color: var(--autocomplete-plus-text-color-cat-yellow);
}
#autocomplete-plus-root .danbooru[data-tag-category="unknown"] {
color: var(--autocomplete-plus-text-color-cat-gray);
}
#autocomplete-plus-root .e621[data-tag-category="general"] {
color: var(--autocomplete-plus-text-color-cat-sky);
}
#autocomplete-plus-root .e621[data-tag-category="artist"] {
color: var(--autocomplete-plus-text-color-cat-orange);
}
#autocomplete-plus-root .e621[data-tag-category="copyright"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
#autocomplete-plus-root .e621[data-tag-category="character"] {
color: var(--autocomplete-plus-text-color-cat-green);
}
#autocomplete-plus-root .e621[data-tag-category="species"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
#autocomplete-plus-root .e621[data-tag-category="meta"] {
color: var(--autocomplete-plus-text-color-cat-white);
}
#autocomplete-plus-root .e621[data-tag-category="lore"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
#autocomplete-plus-root .e621[data-tag-category="unknown"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
#autocomplete-plus-list { #autocomplete-plus-list {
display: grid; display: grid;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%); box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
grid-auto-rows: auto; grid-auto-rows: auto;
grid-template-columns: max-content 1fr auto auto; grid-template-columns: max-content max-content 1fr auto;
overflow-y: auto; overflow-y: auto;
} }
#autocomplete-plus-list.no-alias {
grid-template-columns: max-content max-content auto;
}
.autocomplete-plus-item { .autocomplete-plus-item {
display: grid; display: grid;
border-bottom: 1px solid var(--border-color);
cursor: pointer; cursor: pointer;
grid-column: 1 / -1; grid-column: 1 / -1;
grid-template-columns: subgrid; grid-template-columns: subgrid;
} }
.autocomplete-plus-item.danbooru[data-tag-category="general"] {
color: var(--autocomplete-plus-text-color-cat-blue);
}
.autocomplete-plus-item.danbooru[data-tag-category="artist"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
.autocomplete-plus-item.danbooru[data-tag-category="copyright"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
.autocomplete-plus-item.danbooru[data-tag-category="character"] {
color: var(--autocomplete-plus-text-color-cat-green);
}
.autocomplete-plus-item.danbooru[data-tag-category="meta"] {
color: var(--autocomplete-plus-text-color-cat-yellow);
}
.autocomplete-plus-item.danbooru[data-tag-category="unknown"] {
color: var(--autocomplete-plus-text-color-cat-gray);
}
.autocomplete-plus-item.e621[data-tag-category="general"] {
color: var(--autocomplete-plus-text-color-cat-sky);
}
.autocomplete-plus-item.e621[data-tag-category="artist"] {
color: var(--autocomplete-plus-text-color-cat-orange);
}
.autocomplete-plus-item.e621[data-tag-category="copyright"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
.autocomplete-plus-item.e621[data-tag-category="character"] {
color: var(--autocomplete-plus-text-color-cat-green);
}
.autocomplete-plus-item.e621[data-tag-category="species"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
.autocomplete-plus-item.e621[data-tag-category="meta"] {
color: var(--autocomplete-plus-text-color-cat-white);
}
.autocomplete-plus-item.e621[data-tag-category="lore"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
.autocomplete-plus-item.e621[data-tag-category="unknown"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
.autocomplete-plus-item span { .autocomplete-plus-item span {
align-content: center; align-content: center;
padding: 4px 8px; padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
} }
/* Alternating row colors */ /* Alternating row colors */
.autocomplete-plus-item:nth-child(even) span { .autocomplete-plus-item:nth-child(even) {
background-color: var(--comfy-menu-bg); background-color: var(--autocomplete-plus-list-color-even);
} }
.autocomplete-plus-item:nth-child(odd) span { .autocomplete-plus-item:nth-child(odd) {
background-color: var(--comfy-menu-secondary-bg); background-color: var(--autocomplete-plus-list-color-odd);
} }
.autocomplete-plus-item.selected span { .autocomplete-plus-item.selected {
background-color: var( background-color: var(--border-color);
--comfy-menu-bg-selected,
var(--comfy-button-bg)
);
font-weight: bold;
} }
.autocomplete-plus-item:hover span { .autocomplete-plus-item:hover {
background-color: var(--comfy-hover-bg); background-color: var(--comfy-hover-bg);
} }
@@ -160,6 +172,28 @@ body.dark-theme {
text-align: right; text-align: right;
} }
.autocomplete-plus-item .autocomplete-plus-wiki-icon {
padding: 0 8px;
color: var(--wiki-icon-text-color);
cursor: pointer;
font-size: 1.2em;
opacity: var(--wiki-icon-opacity-normal);
text-align: center;
vertical-align: middle;
}
.autocomplete-plus-item .autocomplete-plus-wiki-icon:hover {
filter: brightness(1.1);
opacity: var(--wiki-icon-opacity-hover);
}
/* Hide wiki icon for tags without wiki pages */
.autocomplete-plus-item .autocomplete-plus-wiki-icon.disabled {
cursor: default;
opacity: var(--wiki-icon-opacity-disabled);
pointer-events: none;
}
/* Related Tags UI Styles */ /* Related Tags UI Styles */
#related-tags-root { #related-tags-root {
position: absolute; position: absolute;
@@ -172,26 +206,97 @@ body.dark-theme {
color: var(--input-text); color: var(--input-text);
} }
#related-tags-root .danbooru[data-tag-category="general"] {
color: var(--autocomplete-plus-text-color-cat-blue);
}
#related-tags-root .danbooru[data-tag-category="artist"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
#related-tags-root .danbooru[data-tag-category="copyright"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
#related-tags-root .danbooru[data-tag-category="character"] {
color: var(--autocomplete-plus-text-color-cat-green);
}
#related-tags-root .danbooru[data-tag-category="meta"] {
color: var(--autocomplete-plus-text-color-cat-yellow);
}
#related-tags-root .danbooru[data-tag-category="unknown"] {
color: var(--autocomplete-plus-text-color-cat-gray);
}
#related-tags-root .e621[data-tag-category="general"] {
color: var(--autocomplete-plus-text-color-cat-sky);
}
#related-tags-root .e621[data-tag-category="artist"] {
color: var(--autocomplete-plus-text-color-cat-orange);
}
#related-tags-root .e621[data-tag-category="copyright"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
#related-tags-root .e621[data-tag-category="character"] {
color: var(--autocomplete-plus-text-color-cat-green);
}
#related-tags-root .e621[data-tag-category="species"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
#related-tags-root .e621[data-tag-category="meta"] {
color: var(--autocomplete-plus-text-color-cat-white);
}
#related-tags-root .e621[data-tag-category="lore"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
#related-tags-root .e621[data-tag-category="unknown"] {
color: var(--autocomplete-plus-text-color-cat-gray);
}
#related-tags-header { #related-tags-header {
position: sticky; position: sticky;
z-index: 1; z-index: 1;
top: 0; top: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; padding: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
background-color: var(--comfy-menu-bg); background-color: var(--autocomplete-plus-list-color-even);
color: var(--descrip-text); color: var(--descrip-text);
gap: 8px;
} }
.related-tags-header-text { .related-tags-header-text-container {
flex-grow: 1; display:flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.related-tags-header-tag-text {
padding: 2px 0;
line-height: 1.25em;
} }
.related-tags-header-tag-name { .related-tags-header-tag-name {
cursor: pointer;
font-weight: bold; font-weight: bold;
word-break: break-all;
}
.related-tags-header-tag-name.disabled {
cursor: default;
text-decoration: none;
} }
.related-tags-header-tag-name .autocomplete-plus-tag-icon-svg { .related-tags-header-tag-name .autocomplete-plus-tag-icon-svg {
@@ -200,17 +305,22 @@ body.dark-theme {
vertical-align: middle; vertical-align: middle;
} }
.related-tags-header-tag-alias {
overflow: auto;
max-height: 5em;
padding: 2px 4px;
border-radius: 4px;
background-color: var(--bg-color);
color: var(--descrip-text);
line-height: 1.25em;
}
.related-tags-header-controls { .related-tags-header-controls {
display: flex; margin-left: auto;
align-items: center;
margin-left: 16px;
gap: 4px;
} }
.related-tags-layout-toggle { .related-tags-header-controls button {
padding: 2px 6px;
border: none; border: none;
border-radius: 4px;
background: none; background: none;
color: var(--input-text); color: var(--input-text);
cursor: pointer; cursor: pointer;
@@ -218,117 +328,50 @@ body.dark-theme {
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.related-tags-layout-toggle:hover { .related-tags-header-controls button:hover {
background-color: var(--comfy-button-bg); background-color: var(--border-color);
}
.related-tags-pin-toggle {
padding: 2px 6px;
border: none;
border-radius: 4px;
background: none;
color: var(--input-text);
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
} }
#related-tags-list { #related-tags-list {
display: grid; display: grid;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%); box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
grid-auto-rows: auto; grid-auto-rows: auto;
grid-template-columns: max-content 1fr auto auto; grid-template-columns: max-content max-content 1fr auto;
overflow-y: auto; overflow-y: auto;
} }
#related-tags-list.no-alias {
grid-template-columns: max-content max-content auto;
}
.related-tag-item { .related-tag-item {
display: grid; display: grid;
border-bottom: 1px solid var(--border-color);
cursor: pointer; cursor: pointer;
grid-column: 1 / -1; grid-column: 1 / -1;
grid-template-columns: subgrid; grid-template-columns: subgrid;
} }
.related-tag-item.danbooru[data-tag-category="general"] {
color: var(--autocomplete-plus-text-color-cat-blue);
}
.related-tag-item.danbooru[data-tag-category="artist"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
.related-tag-item.danbooru[data-tag-category="copyright"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
.related-tag-item.danbooru[data-tag-category="character"] {
color: var(--autocomplete-plus-text-color-cat-green);
}
.related-tag-item.danbooru[data-tag-category="meta"] {
color: var(--autocomplete-plus-text-color-cat-yellow);
}
.related-tag-item.danbooru[data-tag-category="unknown"] {
color: var(--autocomplete-plus-text-color-cat-gray);
}
.related-tag-item.e621[data-tag-category="general"] {
color: var(--autocomplete-plus-text-color-cat-sky);
}
.related-tag-item.e621[data-tag-category="artist"] {
color: var(--autocomplete-plus-text-color-cat-orange);
}
.related-tag-item.e621[data-tag-category="copyright"] {
color: var(--autocomplete-plus-text-color-cat-purple);
}
.related-tag-item.e621[data-tag-category="character"] {
color: var(--autocomplete-plus-text-color-cat-green);
}
.related-tag-item.e621[data-tag-category="species"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
.related-tag-item.e621[data-tag-category="meta"] {
color: var(--autocomplete-plus-text-color-cat-white);
}
.related-tag-item.e621[data-tag-category="lore"] {
color: var(--autocomplete-plus-text-color-cat-red);
}
.related-tag-item.e621[data-tag-category="unknown"] {
color: var(--autocomplete-plus-text-color-cat-gray);
}
/* stylelint-disable-next-line no-descending-specificity */ /* stylelint-disable-next-line no-descending-specificity */
.related-tag-item span { .related-tag-item span {
align-content: center; align-content: center;
padding: 4px 8px; padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
} }
.related-tag-item:nth-child(even) span { .related-tag-item:nth-child(even) {
background-color: var(--comfy-menu-bg); background-color: var(--autocomplete-plus-list-color-even);
} }
.related-tag-item:nth-child(odd) span { .related-tag-item:nth-child(odd) {
background-color: var(--comfy-menu-secondary-bg); background-color: var(--autocomplete-plus-list-color-odd);
} }
.related-tag-item:hover span { .related-tag-item:hover {
background-color: var(--p-form-field-filled-hover-background); background-color: var(--p-form-field-filled-hover-background);
} }
.related-tag-item.selected span { .related-tag-item.selected {
background-color: var( background-color: var(--border-color);
--comfy-menu-bg-selected,
var(--comfy-button-bg)
);
font-weight: bold;
} }
.related-tag-item .related-tag-name { .related-tag-item .related-tag-name {
@@ -352,9 +395,34 @@ body.dark-theme {
white-space: nowrap; white-space: nowrap;
} }
.related-tags-loading-message { .related-tag-item .related-tag-wiki-icon {
padding: 0 8px;
color: var(--wiki-icon-text-color);
cursor: pointer;
font-size: 1.2em;
opacity: var(--wiki-icon-opacity-normal);
text-align: center;
transition: opacity 0.1s ease;
vertical-align: middle;
}
.related-tag-item .related-tag-wiki-icon:hover {
filter: brightness(1.1);
opacity: var(--wiki-icon-opacity-hover);
}
/* Hide wiki icon for tags without wiki pages */
.related-tag-item .related-tag-wiki-icon.disabled {
cursor: default;
opacity: var(--wiki-icon-opacity-disabled);
pointer-events: none;
}
.related-tags-message {
width: 100%;
padding: 12px; padding: 12px;
font-style: italic; font-style: italic;
grid-column: 1 / -1;
text-align: center; text-align: center;
} }
+225
View File
@@ -0,0 +1,225 @@
import { settingValues } from './settings.js';
import { NodeInfo } from './node-info.js';
/**
* Determines if the text content should be auto-formatted.
*
* Format conditions:
* 1. Skip formatting if node is in blocklist
* 2. If text is empty after trimming, format only if trimSurroundingSpaces is enabled, otherwise don't format
* 3. Skip formatting if text contains only numbers or single letters (separated by commas)
* 4. Format if text contains "word + comma" pattern
* 5. Format if trimSurroundingSpaces is enabled and there are surrounding spaces or line breaks
* 6. Otherwise, don't format
*
* @param {NodeInfo} nodeInfo - The node information.
* @returns {boolean} - True if the text should be formatted, false otherwise.
*/
function shouldAutoFormat(text, nodeInfo) {
if (!text) return false;
// 1. Check if the node name is in the blocklist
const blocklist = [
["Power Puter (rgthree)", "code"],
["LoraLoaderBlockWeight //Inspire", "block_vector"]
];
const isBlocklisted = blocklist.some(([type, input]) =>
type === nodeInfo.nodeType && input === nodeInfo.inputName
);
if (isBlocklisted) {
// console.debug(`[Autocomplete-Plus] auto-formatter on blur => nodeType: ${nodeInfo.nodeType}, inputName: ${nodeInfo.inputName} => blocklisted`);
return false;
} else {
// console.debug(`[Autocomplete-Plus] auto-formatter on blur => nodeType: ${nodeInfo.nodeType}, inputName: ${nodeInfo.inputName}`);
}
const trimmedText = text.trim();
// 2. Check if the text is completely empty after trimming
if (trimmedText.length === 0) {
return settingValues.trimSurroundingSpaces;
}
// 3. Check if the text is purely numeric data or single-letter placeholders with commas
// (e.g., "0,0,0,1,1,1" or "0.5, -1.2, 0.8" or "A,B,R" for LoRA Block Weight)
const elements = trimmedText.split(',').map(el => el.trim());
const isSingleLetterOrNumeric = elements.every(el => {
if (/^[A-Za-z]$/.test(el)) return true;
if (/^-?\d+(\.\d+)?$/.test(el)) return true;
return false;
});
if (isSingleLetterOrNumeric && elements.length > 0) {
return false; // Don't format numeric data or single-letter template patterns
}
// 4. If text contains "word + comma" pattern, format it
const wordCommaPattern = /\w+\s*,/g;
if (trimmedText.match(wordCommaPattern)) {
return true;
}
// 5. If trimSurroundingSpaces is enabled and there are surrounding spaces, format to trim them
if (settingValues.trimSurroundingSpaces && text !== trimmedText) {
return true;
}
return false; // Otherwise, don't format
}
/**
* Format the prompt text: add a comma and space after each tag, and remove extra spaces.
* Preserve special syntax such as weights (tag:1.2), parentheses, and wildcards.
* @param {string} text - The original text.
* @returns {string} - The formatted text.
*/
export function formatPromptText(text) {
// Handle null or undefined input
if (text == null) return text;
// If the text is empty or contains only spaces...
if (text.trim().length === 0) {
if (settingValues.trimSurroundingSpaces) {
// ...And trimSurroundingSpaces is enabled, return an empty string
return '';
} else {
// ...Otherwise, return the original text
return text;
}
}
let processedText = text;
if (settingValues.trimSurroundingSpaces) {
processedText = text.trim();
}
// Split text into individual lines for processing
const lines = processedText.split('\n');
const formattedLines = [];
for (const line of lines) {
const trimmedLine = line.trim();
// Keep empty lines unchanged
if (trimmedLine.length === 0) {
formattedLines.push('');
continue;
}
// Split the line by commas into raw tag segments
const rawTags = trimmedLine.split(',');
const cleanedTags = [];
for (const tag of rawTags) {
// Trim spaces around each tag
const trimmedTag = tag.trim();
// Keep only non-empty tags
if (trimmedTag.length > 0) {
cleanedTags.push(trimmedTag);
}
}
// Rejoin cleaned tags with ", "
let formattedLine = cleanedTags.join(', ');
// Add a trailing comma and space if the setting is enabled and line contains valid tags
if (formattedLine.length > 0 && settingValues.useTrailingComma) {
formattedLine += ', ';
}
formattedLines.push(formattedLine);
}
// Rejoin all lines with newline characters
return formattedLines.join('\n');
}
/**
* Format the content of a textarea when it loses focus.
* @param {HTMLTextAreaElement} textarea - The target textarea element.
*/
export function formatTextareaOnBlur(textarea) {
const originalText = textarea.value;
const formattedText = formatPromptText(originalText);
if (originalText !== formattedText) {
const cursorPos = textarea.selectionStart;
textarea.value = formattedText;
// Try to preserve cursor position
const newCursorPos = Math.min(cursorPos, formattedText.length);
textarea.setSelectionRange(newCursorPos, newCursorPos);
}
}
/**
* Event handler for auto-formatting functionality
*/
export class AutoFormatterEventHandler {
constructor() { }
/**
* Handle blur event to trigger auto-formatting
* @param {Event} event - The blur event
*/
handleBlur(event, nodeInfo) {
if (
settingValues.enableAutoFormat &&
settingValues.autoFormatTrigger === 'auto' &&
event.target.tagName === 'TEXTAREA'
) {
const textarea = event.target;
const text = textarea.value;
// Check if the content should be auto-formatted
if (shouldAutoFormat(text, nodeInfo)) {
formatTextareaOnBlur(textarea);
}
}
}
// Placeholder methods to maintain consistency with other event handlers
handleInput(event) { }
handleFocus(event) { }
handleKeyDown(event) { }
handleKeyUp(event) { }
handleMouseMove(event) { }
handleClick(event) { }
/**
* Format textarea content via manual trigger (e.g., keyboard shortcut)
* @param {HTMLTextAreaElement} textarea - The textarea element to format
* @param {NodeInfo} nodeInfo - The node information
* @returns {boolean} - True if formatting was performed, false otherwise
*/
applyFormatTextarea(textarea, nodeInfo) {
if (!textarea || textarea.tagName !== 'TEXTAREA') {
return false;
}
if (!settingValues.enableAutoFormat) {
return false;
}
const text = textarea.value;
if (shouldAutoFormat(text, nodeInfo)) {
formatTextareaOnBlur(textarea);
return true;
}
return false;
}
}
// Export functions for testing
const isTestEnvironment = typeof process !== 'undefined' && process.env.NODE_ENV === 'test';
export const __test__ = isTestEnvironment
? {
shouldAutoFormat
}
: undefined;
+262 -64
View File
@@ -1,20 +1,25 @@
import { import {
TagCategory, ModelTagSource,
TagData, TagData,
autoCompleteData, autoCompleteData,
getEnabledTagSourceInPriorityOrder getEnabledTagSourceInPriorityOrder
} from './data.js'; } from './data.js';
import { import {
formatCountHumanReadable, isLongText,
hiraToKata, hiraToKata,
kataToHira, kataToHira,
formatCountHumanReadable,
escapeHtml,
isContainsLetterOrNumber, isContainsLetterOrNumber,
normalizeTagToInsert, normalizeTagToInsert,
normalizeTagToSearch, normalizeTagToSearch,
extractTagsFromTextArea, extractTagsFromTextArea,
getCurrentTagRange, getCurrentTagRange,
getViewportMargin, getViewportMargin,
IconSvgHtmlString getScrollbarWidth,
IconSvgHtmlString,
addWeightToLora,
openTagWikiUrl
} from './utils.js'; } from './utils.js';
import { settingValues } from './settings.js'; import { settingValues } from './settings.js';
@@ -78,20 +83,17 @@ function matchWord(target, queries) {
* @returns {Array<TagData>} The list of matching candidates. * @returns {Array<TagData>} The list of matching candidates.
*/ */
function searchCompletionCandidates(textareaElement) { function searchCompletionCandidates(textareaElement) {
const startTime = performance.now(); // Record start time for performance measurement
const ESCAPE_SEQUENCE = ["#", "/"]; // If the first string is that character, autocomplete will not be displayed. const ESCAPE_SEQUENCE = ["#", "/"]; // If the first string is that character, autocomplete will not be displayed.
const partialTag = getCurrentPartialTag(textareaElement); const partialTag = getCurrentPartialTag(textareaElement);
if (!partialTag || partialTag.length <= 0 || ESCAPE_SEQUENCE.some(seq => partialTag.startsWith(seq))) { if (!partialTag || partialTag.length <= 0 ||
ESCAPE_SEQUENCE.some(seq => partialTag.startsWith(seq)) ||
isLongText(partialTag)) {
return []; // No valid input for autocomplete return []; // No valid input for autocomplete
} }
const exactMatches = [];
const partialMatches = [];
const addedTags = new Set();
// Generate Hiragana/Katakana variations if applicable // Generate Hiragana/Katakana variations if applicable
const queryVariations = new Set([partialTag, normalizeTagToSearch(partialTag)]); const queryVariations = new Set([partialTag.toLowerCase(), normalizeTagToSearch(partialTag).toLowerCase()]);
const kataQuery = hiraToKata(partialTag); const kataQuery = hiraToKata(partialTag);
if (kataQuery !== partialTag) { if (kataQuery !== partialTag) {
queryVariations.add(kataQuery); queryVariations.add(kataQuery);
@@ -101,6 +103,26 @@ function searchCompletionCandidates(textareaElement) {
queryVariations.add(hiraQuery); queryVariations.add(hiraQuery);
} }
if (settingValues.useFastSearch) {
return searchWithFlexSearch(partialTag, queryVariations);
} else {
return sequentialSearch(partialTag, queryVariations);
}
}
/**
* Search completion candidates using sequential search.
* @param {string} partialTag
* @param {Set<string>} queryVariations
* @returns
*/
function sequentialSearch(partialTag, queryVariations) {
const startTime = performance.now();
const exactMatches = [];
const partialMatches = [];
const addedTags = new Set();
const sources = getEnabledTagSourceInPriorityOrder(); const sources = getEnabledTagSourceInPriorityOrder();
for (const source of sources) { for (const source of sources) {
// Search in sortedTags (already sorted by count) // Search in sortedTags (already sorted by count)
@@ -110,15 +132,14 @@ function searchCompletionCandidates(textareaElement) {
let matchedAlias = null; let matchedAlias = null;
// Check primary tag against all variations for exact/partial match // Check primary tag against all variations for exact/partial match
const tagMatch = matchWord(tagData.tag, queryVariations); const tagMatch = matchWord(tagData.tag.toLowerCase(), queryVariations);
matched = tagMatch.matched; matched = tagMatch.matched;
isExactMatch = tagMatch.isExactMatch; isExactMatch = tagMatch.isExactMatch;
// If primary tag didn't match, check aliases against all variations // If primary tag didn't match, check aliases against all variations
if (!matched && tagData.alias && Array.isArray(tagData.alias) && tagData.alias.length > 0) { if (!matched && tagData.alias && Array.isArray(tagData.alias) && tagData.alias.length > 0) {
for (const alias of tagData.alias) { for (const alias of tagData.alias) {
const lowerAlias = alias.toLowerCase(); const aliasMatch = matchWord(alias.toLowerCase(), queryVariations);
const aliasMatch = matchWord(lowerAlias, queryVariations);
if (aliasMatch.matched) { if (aliasMatch.matched) {
matched = true; matched = true;
isExactMatch = aliasMatch.isExactMatch; isExactMatch = aliasMatch.isExactMatch;
@@ -170,12 +191,79 @@ function searchCompletionCandidates(textareaElement) {
return candidates; return candidates;
} }
/**
* Search completion candidates using FlexSearch for fast matching.
* @param {string} partialTag
* @param {Set<string>} queryVariations
* @returns
*/
function searchWithFlexSearch(partialTag, queryVariations) {
const startTime = performance.now();
let mergedResult = [];
let totalSearchCount = 0;
const sources = getEnabledTagSourceInPriorityOrder();
for (const source of sources) {
if (!autoCompleteData[source].flexSearchDocument) continue;
if (mergedResult.length >= settingValues.maxSuggestions) break;
// Use the FlexSearch Document to search
// NOTE: The limit param is reflected separately for "tag" and "alias".
let searchResult = autoCompleteData[source].flexSearchDocument.search(partialTag, {
field: ["tag", "alias"],
limit: Math.min(settingValues.maxSuggestions * 10, 500),
merge: true,
suggest: false,
cache: true,
});
if (!searchResult || searchResult.length <= 0) continue;
// Sort results based on exact matches and counts
searchResult = searchResult
.map(r => autoCompleteData[source].sortedTags[r.id])
.sort((aTag, bTag) => {
if (matchWord(bTag.tag, queryVariations).isExactMatch) {
return 999999999999;
}
if (matchWord(aTag.tag, queryVariations).isExactMatch) {
return -999999999999;
}
if (bTag.alias && bTag.alias.some(alias => matchWord(alias, queryVariations).isExactMatch)) {
return 999999999999;
}
if (aTag.alias && aTag.alias.some(alias => matchWord(alias, queryVariations).isExactMatch)) {
return -999999999999;
}
return bTag.count - aTag.count;
});
// Merge results into the final array
mergedResult = mergedResult.concat(searchResult.slice(0, settingValues.maxSuggestions - mergedResult.length));
totalSearchCount += searchResult.length;
}
if (settingValues._logprocessingTime) {
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(`[Autocomplete-Plus] Fast Search for "${partialTag}" took ${duration.toFixed(2)}ms.Found ${mergedResult.length} candidates within ${totalSearchCount} searches from flexsearch.`);
}
return mergedResult;
}
/** /**
* Extracts the current tag being typed before the cursor. * Extracts the current tag being typed before the cursor.
* @param {HTMLTextAreaElement} inputElement * @param {HTMLTextAreaElement} inputElement
* @returns {string} The current partial tag. * @returns {string} The current partial tag.
*/ */
function getCurrentPartialTag(inputElement) { function getCurrentPartialTag(inputElement) {
if (!inputElement) {
return "";
}
const text = inputElement.value; const text = inputElement.value;
const cursorPos = inputElement.selectionStart; const cursorPos = inputElement.selectionStart;
@@ -218,17 +306,41 @@ function getCurrentPartialTag(inputElement) {
* Inserts the selected tag into the textarea, replacing the partial tag, * Inserts the selected tag into the textarea, replacing the partial tag,
* making the change undoable. * making the change undoable.
* @param {HTMLTextAreaElement} inputElement * @param {HTMLTextAreaElement} inputElement
* @param {string} tagToInsert The raw tag string to insert. * @param {TagData} tagDataToInsert
*/ */
function insertTagToTextArea(inputElement, tagToInsert) { function insertTagToTextArea(inputElement, tagDataToInsert) {
if (!inputElement || !tagDataToInsert) {
return;
}
const text = inputElement.value; const text = inputElement.value;
const cursorPos = inputElement.selectionStart; const cursorPos = inputElement.selectionStart;
const { start: tagStart, end: tagEnd, tag: currentTag } = getCurrentTagRange(text, cursorPos); const tagRange = getCurrentTagRange(text, cursorPos);
let tagStart, tagEnd, currentTag;
if (!tagRange) {
// Fallback: insert at cursor position
tagStart = cursorPos;
tagEnd = cursorPos;
currentTag = '';
} else {
({ start: tagStart, end: tagEnd, tag: currentTag } = tagRange);
}
const replaceStart = Math.min(cursorPos, tagStart); const replaceStart = Math.min(cursorPos, tagStart);
let replaceEnd = cursorPos; let replaceEnd = cursorPos;
const normalizedTag = normalizeTagToInsert(tagToInsert); let normalizedTag;
if (tagDataToInsert.source === ModelTagSource.Lora) {
// If the tag is from a LoRA source, add weight to it
normalizedTag = addWeightToLora(tagDataToInsert.tag);
} else if (Object.values(ModelTagSource).includes(tagDataToInsert.source)) {
// If the tag is from other model tag sources (e.g., Embeddings), don't normalize it
normalizedTag = tagDataToInsert.tag;
} else {
normalizedTag = normalizeTagToInsert(tagDataToInsert.tag);
}
const currentTagAfterCursor = text.substring(cursorPos, tagEnd).trimEnd(); const currentTagAfterCursor = text.substring(cursorPos, tagEnd).trimEnd();
if (normalizedTag.lastIndexOf(currentTagAfterCursor) !== -1) { if (normalizedTag.lastIndexOf(currentTagAfterCursor) !== -1) {
@@ -239,11 +351,13 @@ function insertTagToTextArea(inputElement, tagToInsert) {
const needsSpaceBefore = text[replaceStart - 1] === ','; const needsSpaceBefore = text[replaceStart - 1] === ',';
const prefix = needsSpaceBefore ? ' ' : ''; const prefix = needsSpaceBefore ? ' ' : '';
// Standard separator (comma + space) const prefixArtist = tagDataToInsert.categoryText == 'artist' ? settingValues.prefixArtist : '';
const needsSuffixAfter = !",:".includes(text[replaceEnd]); // TODO: If ":" is part of the emoticon, a suffix is ​​required (e.g. ":o")
const suffix = needsSuffixAfter ? ', ' : '';
const textToInsertWithAffixes = prefix + normalizedTag + suffix; // Standard separator (comma + space, or empty if autoInsertComma is disabled)
const needsSuffixAfter = !",:".includes(text[replaceEnd]); // TODO: If ":" is part of the emoticon, a suffix is ​​required (e.g. ":o")
const suffix = (needsSuffixAfter && settingValues.autoInsertComma) ? ', ' : '';
const textToInsertWithAffixes = prefix + prefixArtist + normalizedTag + suffix;
// --- Use execCommand for Undo support --- // --- Use execCommand for Undo support ---
// 1. Select the text range to be replaced // 1. Select the text range to be replaced
@@ -295,9 +409,22 @@ class AutocompleteUI {
// Add event listener for clicks on items // Add event listener for clicks on items
this.tagsList.addEventListener('mousedown', (e) => { this.tagsList.addEventListener('mousedown', (e) => {
// Check if wiki icon was clicked first
const wikiIcon = e.target.closest('.autocomplete-plus-wiki-icon');
if (wikiIcon && !wikiIcon.classList.contains('disabled')) {
openTagWikiUrl(wikiIcon.dataset.tagSource, wikiIcon.dataset.tagName);
e.preventDefault();
e.stopPropagation();
return;
}
// Check if row was clicked (existing behavior)
const row = e.target.closest('.autocomplete-plus-item'); const row = e.target.closest('.autocomplete-plus-item');
if (row && row.dataset.tag) { if (row && row.dataset.index !== undefined) {
this.#insertTag(row.dataset.tag); const tagData = this.candidates[parseInt(row.dataset.index, 10)];
if (tagData) {
this.#insertTag(tagData);
}
e.preventDefault(); // Prevent focus loss from input e.preventDefault(); // Prevent focus loss from input
e.stopPropagation(); e.stopPropagation();
} }
@@ -362,10 +489,12 @@ class AutocompleteUI {
this.#highlightItem(); this.#highlightItem();
} }
/** Selects the currently highlighted item */ /** Selects the currently highlighted item
getSelectedTag() { * @returns {TagData|null} The selected tag data.
*/
getSelectedTagData() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.candidates.length) { if (this.selectedIndex >= 0 && this.selectedIndex < this.candidates.length) {
return this.candidates[this.selectedIndex].tag; return this.candidates[this.selectedIndex];
} }
return null; // No valid selection return null; // No valid selection
@@ -381,28 +510,32 @@ class AutocompleteUI {
return; return;
} }
this.tagsList.classList.toggle('no-alias', settingValues.hideAlias);
const existingTags = extractTagsFromTextArea(this.target); const existingTags = extractTagsFromTextArea(this.target);
const currentTag = getCurrentPartialTag(this.target); const currentTag = getCurrentPartialTag(this.target);
this.candidates.forEach((tagData) => { this.candidates.forEach((tagData, index) => {
const isExactMatch = tagData.tag === currentTag && (existingTags.filter(item => item === currentTag).length == 1); const isExactMatch = tagData.tag === currentTag && (existingTags.filter(item => item === currentTag).length == 1);
const isExistingTag = !isExactMatch && existingTags.includes(tagData.tag); const isExistingTag = !isExactMatch && existingTags.includes(tagData.tag);
this.#createTagElement(tagData, isExistingTag); this.#createTagElement(tagData, index, isExistingTag);
}); });
} }
/** /**
* Creates a tag element for the autocomplete list. * Creates a tag element for the autocomplete list.
* @param {TagData} tagData * @param {TagData} tagData
* @param {number} tagDataIndex
* @param {boolean} isExisting * @param {boolean} isExisting
*/ */
#createTagElement(tagData, isExisting) { #createTagElement(tagData, tagDataIndex, isExisting) {
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown"; const categoryText = tagData.categoryText;
const aliasText = tagData.alias.join(', ');
const tagRow = document.createElement('div'); const tagRow = document.createElement('div');
tagRow.classList.add('autocomplete-plus-item', tagData.source); tagRow.classList.add('autocomplete-plus-item', tagData.source);
tagRow.dataset.tag = tagData.tag; tagRow.dataset.index = tagDataIndex;
tagRow.dataset.tagCategory = categoryText; tagRow.dataset.tagCategory = categoryText; // Used to color by CSS
// Tag icon and name // Tag icon and name
const tagSourceIconHtml = `<svg class="autocomplete-plus-tag-icon-svg"><use xlink:href="#autocomplete-plus-icon-${tagData.source}"></use></svg>`; const tagSourceIconHtml = `<svg class="autocomplete-plus-tag-icon-svg"><use xlink:href="#autocomplete-plus-icon-${tagData.source}"></use></svg>`;
@@ -411,9 +544,10 @@ class AutocompleteUI {
if (settingValues.tagSourceIconPosition == 'hidden') { if (settingValues.tagSourceIconPosition == 'hidden') {
tagName.textContent = tagData.tag; tagName.textContent = tagData.tag;
} else { } else {
const escapedTag = escapeHtml(tagData.tag);
tagName.innerHTML = settingValues.tagSourceIconPosition == 'left' tagName.innerHTML = settingValues.tagSourceIconPosition == 'left'
? `${tagSourceIconHtml} ${tagData.tag}` ? `${tagSourceIconHtml} ${escapedTag}`
: `${tagData.tag} ${tagSourceIconHtml}`; : `${escapedTag} ${tagSourceIconHtml}`;
} }
// grayout tag name if it already exists // grayout tag name if it already exists
@@ -421,31 +555,47 @@ class AutocompleteUI {
tagName.classList.add('autocomplete-plus-already-exists'); tagName.classList.add('autocomplete-plus-already-exists');
} }
// Wiki icon
const wikiIcon = document.createElement('span');
wikiIcon.className = 'autocomplete-plus-wiki-icon';
if (tagData.hasWikiPage) {
wikiIcon.dataset.tagName = tagData.tag;
wikiIcon.dataset.tagSource = tagData.source;
wikiIcon.textContent = '📖'
wikiIcon.title = 'Open wiki page';
} else {
wikiIcon.classList.add('disabled');
}
// Alias // Alias
const alias = document.createElement('span'); const alias = document.createElement('span');
alias.className = 'autocomplete-plus-alias'; alias.className = 'autocomplete-plus-alias';
// Display alias if available // Display alias if available
if (tagData.alias && tagData.alias.length > 0) { if (aliasText.length > 0) {
let aliasText = tagData.alias.join(', ');
alias.textContent = `${aliasText}`; alias.textContent = `${aliasText}`;
alias.title = tagData.alias.join(', '); // Full alias on hover alias.title = aliasText; // Full alias on hover
} }
// Category
const category = document.createElement('span');
category.className = `autocomplete-plus-category`;
category.textContent = `${categoryText.substring(0, 2)}`;
category.title = categoryText; // Full category on hover
// Count // Count
const tagCount = document.createElement('span'); const tagCount = document.createElement('span');
tagCount.className = `autocomplete-plus-tag-count`; tagCount.className = `autocomplete-plus-tag-count`;
tagCount.textContent = formatCountHumanReadable(tagData.count); tagCount.textContent = formatCountHumanReadable(tagData.count);
// Create tooltip with more info
let tooltipText = `Count: ${tagData.count}\nCategory: ${categoryText}`;
if (aliasText.length > 0) {
tooltipText += `\nAlias: ${aliasText}`;
}
tagRow.title = tooltipText;
tagRow.appendChild(tagName); tagRow.appendChild(tagName);
tagRow.appendChild(alias); tagRow.appendChild(wikiIcon);
tagRow.appendChild(category);
if (!settingValues.hideAlias) {
tagRow.appendChild(alias);
}
tagRow.appendChild(tagCount); tagRow.appendChild(tagCount);
this.tagsList.appendChild(tagRow); this.tagsList.appendChild(tagRow);
} }
@@ -483,21 +633,14 @@ class AutocompleteUI {
let topPosition = targetElmOffset.top + ((caretTop - targetElmOffset.top) + caretLineHeight) * scale; let topPosition = targetElmOffset.top + ((caretTop - targetElmOffset.top) + caretLineHeight) * scale;
let leftPosition = targetElmOffset.left + (caretLeft - targetElmOffset.left) * scale;; let leftPosition = targetElmOffset.left + (caretLeft - targetElmOffset.left) * scale;;
const maxWidth = Math.min(rootRect.width, viewportWidth / 2);
const naturalHeight = rootRect.height; const naturalHeight = rootRect.height;
//Horizontal Collision Detection and Adjustment
if (leftPosition + maxWidth > viewportWidth - margin.right) {
leftPosition = viewportWidth - maxWidth - margin.right;
}
if (leftPosition < margin.left) {
leftPosition = margin.left;
}
// Vertical Collision Detection and Adjustment // Vertical Collision Detection and Adjustment
const availableSpaceBelow = viewportHeight - topPosition - margin.bottom; const availableSpaceBelow = viewportHeight - topPosition - margin.bottom;
const availableSpaceAbove = caretTop - margin.top; const availableSpaceAbove = caretTop - margin.top;
let needsVerticalScroll = false;
let maxHeight = rootRect.height;
if (naturalHeight <= availableSpaceBelow) { if (naturalHeight <= availableSpaceBelow) {
// Fits perfectly below the caret // Fits perfectly below the caret
@@ -509,14 +652,15 @@ class AutocompleteUI {
topPosition = caretTop - naturalHeight - margin.top; topPosition = caretTop - naturalHeight - margin.top;
} else { } else {
// Doesn't fit perfectly either below or above, needs scrolling. // Doesn't fit perfectly either below or above, needs scrolling.
needsVerticalScroll = true;
// Choose the position (above or below) that offers more space. // Choose the position (above or below) that offers more space.
if (availableSpaceBelow >= availableSpaceAbove) { if (availableSpaceBelow >= availableSpaceAbove) {
// Scroll below: topPosition remains as initially calculated // Scroll below: topPosition remains as initially calculated
this.tagsList.style.maxHeight = `${availableSpaceBelow}px`; maxHeight = availableSpaceBelow;
} else { } else {
// Scroll above: Position near the top edge and set max-height // Scroll above: Position near the top edge and set max-height
topPosition = margin.top; topPosition = margin.top;
this.tagsList.style.maxHeight = `${availableSpaceAbove}px`; maxHeight = availableSpaceAbove;
} }
} }
} }
@@ -525,21 +669,35 @@ class AutocompleteUI {
if (topPosition < margin.top) { if (topPosition < margin.top) {
topPosition = margin.top; topPosition = margin.top;
// If pushed down, recalculate max-height if it was set based on top alignment // If pushed down, recalculate max-height if it was set based on top alignment
if (this.tagsList.style.maxHeight && availableSpaceBelow < availableSpaceAbove) { if (availableSpaceBelow < availableSpaceAbove) {
// Recalculate max-height based on space from the top margin // Recalculate max-height based on space from the top margin
this.tagsList.style.maxHeight = `${viewportHeight - margin.top - margin.bottom}px`; maxHeight = viewportHeight - margin.top - margin.bottom;
needsVerticalScroll = true;
} }
} }
// Calculate maxWidth considering scrollbar width if vertical scrolling is needed
const scrollbarWidth = needsVerticalScroll ? getScrollbarWidth() : 0;
const maxWidth = Math.min(rootRect.width + scrollbarWidth, viewportWidth / 2);
// Horizontal Collision Detection and Adjustment
if (leftPosition + maxWidth > viewportWidth - margin.right) {
leftPosition = viewportWidth - maxWidth - margin.right;
}
if (leftPosition < margin.left) {
leftPosition = margin.left;
}
// Apply the calculated position and display the element // Apply the calculated position and display the element
this.root.style.left = `${leftPosition}px`; this.root.style.left = `${leftPosition}px`;
this.root.style.top = `${topPosition}px`; this.root.style.top = `${topPosition}px`;
this.root.style.maxWidth = `${maxWidth}px`; this.root.style.maxWidth = `${maxWidth}px`;
this.tagsList.style.maxHeight = `${maxHeight}px`;
} }
/** Highlights the item (row) at the given index */ /** Highlights the item (row) at the given index */
#highlightItem() { #highlightItem() {
if (!this.getSelectedTag()) return; // No valid selection if (!this.getSelectedTagData()) return; // No valid selection
const items = this.tagsList.children; // Get rows from tbody const items = this.tagsList.children; // Get rows from tbody
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
@@ -554,10 +712,10 @@ class AutocompleteUI {
/** /**
* Handles the selection of an item * Handles the selection of an item
* @param {string} selectedTag The tag to insert. * @param {TagData} selectedTag The tag to insert.
*/ */
#insertTag(selectedTag) { #insertTag(selectedTag) {
if (!this.target || !selectedTag || selectedTag.length <= 0) { if (!this.target || !selectedTag) {
this.hide(); this.hide();
return; return;
} }
@@ -783,6 +941,25 @@ export class AutocompleteEventHandler {
constructor() { constructor() {
this.autocompleteUI = new AutocompleteUI(); this.autocompleteUI = new AutocompleteUI();
this.keyDownWithModifier = new Map(); // Keep track of keydown events with modifiers this.keyDownWithModifier = new Map(); // Keep track of keydown events with modifiers
this._debounceTimer = null; // Timer ID for debounced search
}
/**
* Calls autocompleteUI.updateDisplay() with debounce for sequential search, or immediately for fast search.
* @param {HTMLTextAreaElement} target
*/
_triggerUpdateDisplay(target) {
if (settingValues.useFastSearch || settingValues.searchDebounceTime <= 0) {
// FastSearch or debounce disabled: immediate update
this.autocompleteUI.updateDisplay(target);
} else {
// Sequential search: debounced update
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
this.autocompleteUI.updateDisplay(target);
this._debounceTimer = null;
}, settingValues.searchDebounceTime);
}
} }
/** /**
@@ -796,6 +973,7 @@ export class AutocompleteEventHandler {
const partialTag = getCurrentPartialTag(event.target); const partialTag = getCurrentPartialTag(event.target);
if (partialTag.length <= 0) { if (partialTag.length <= 0) {
clearTimeout(this._debounceTimer); // Cancel pending debounced search
this.autocompleteUI.hide(); this.autocompleteUI.hide();
} }
} }
@@ -840,12 +1018,19 @@ export class AutocompleteEventHandler {
case 'Enter': case 'Enter':
case 'Tab': case 'Tab':
const modifierKeyPressed = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey; const modifierKeyPressed = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
if (!modifierKeyPressed && this.autocompleteUI.getSelectedTag() !== null) { if (!modifierKeyPressed && this.autocompleteUI.getSelectedTagData() !== null) {
event.preventDefault(); event.preventDefault();
insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTag()); insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTagData());
} }
this.autocompleteUI.hide(); this.autocompleteUI.hide();
break; break;
case 'F1':
event.preventDefault();
const tagData = this.autocompleteUI.getSelectedTagData();
if (tagData && tagData.hasWikiPage) {
openTagWikiUrl(tagData.source, tagData.tag);
}
break;
case 'Escape': case 'Escape':
event.preventDefault(); event.preventDefault();
this.autocompleteUI.hide(); this.autocompleteUI.hide();
@@ -899,7 +1084,7 @@ export class AutocompleteEventHandler {
// and default action is not prevented, update the display. // and default action is not prevented, update the display.
// This will typically be for character inputs, Delete, Backspace or IME composition. // This will typically be for character inputs, Delete, Backspace or IME composition.
if (!event.defaultPrevented) { if (!event.defaultPrevented) {
this.autocompleteUI.updateDisplay(event.target); this._triggerUpdateDisplay(event.target);
} }
} }
@@ -919,3 +1104,16 @@ export class AutocompleteEventHandler {
handleClick(event) { handleClick(event) {
} }
} }
// Export functions for testing
const isTestEnvironment = typeof process !== 'undefined' && process.env.NODE_ENV === 'test';
export const __test__ = isTestEnvironment
? {
searchCompletionCandidates,
sequentialSearch,
searchWithFlexSearch,
matchWord,
getCurrentPartialTag,
insertTagToTextArea
}
: undefined;
+216 -39
View File
@@ -1,13 +1,20 @@
import { settingValues } from "./settings.js"; import { settingValues, updateMaxTagLength } from "./settings.js";
import { createFlexSearchDocument, createFlexSearchDocumentForModel } from "./searchengine.js";
// --- Constants --- // --- Constants ---
// Tag data sources // Tag sources for booru-like tag data.
export const TagSource = { export const TagSource = {
Danbooru: 'danbooru', Danbooru: 'danbooru',
E621: 'e621', E621: 'e621',
} }
// Tag sources for model based tag data.
export const ModelTagSource = {
Embeddings: 'embeddings',
Lora: 'lora'
}
export const TagCategory = { export const TagCategory = {
'danbooru': [ 'danbooru': [
'general', 'general',
@@ -27,6 +34,12 @@ export const TagCategory = {
'invalid', 'invalid',
'meta', 'meta',
'lore', 'lore',
],
'embeddings': [
'embeddings'
],
'lora': [
'lora'
] ]
} }
@@ -39,19 +52,19 @@ export class TagData {
/** /**
* Create a tag data object * Create a tag data object
* @param {string} tag - The tag name * @param {string} tag - The tag name
* @param {string[]} [alias=[]] - Array of aliases for the tag * @param {number} [category] - Category index of the tag
* @param {string} [category='general'] - Category of the tag
* @param {number} [count=0] - Frequency count/popularity of the tag * @param {number} [count=0] - Frequency count/popularity of the tag
* @param {string} [source=TagSources.Danbooru] - The source of the tag data * @param {string[]} [alias=[]] - Array of aliases for the tag
* @param {string} [source=TagSource.Danbooru] - The source of the tag data
*/ */
constructor(tag, alias = [], category = 'general', count = 0, source = TagSource.Danbooru) { constructor(tag, category, count = 0, alias = [], source = TagSource.Danbooru) {
/** @type {string} */ /** @type {string} */
this.tag = tag; this.tag = tag;
/** @type {string[]} */ /** @type {string[]} */
this.alias = alias; this.alias = alias;
/** @type {string} */ /** @type {number} */
this.category = category; this.category = category;
/** @type {number} */ /** @type {number} */
@@ -59,10 +72,30 @@ export class TagData {
this.source = source; this.source = source;
} }
/**
* Get the category text
* @returns {string}
*/
get categoryText() {
return TagCategory[this.source][this.category] || "unknown";
}
/**
* Check if the tag has a wiki page
* @returns {boolean}
*/
get hasWikiPage() {
return Object.values(TagSource).includes(this.source)
&& ['general', 'artist', 'copyright', 'character', 'species', 'lore'].includes(this.categoryText);
}
} }
class AutocompleteData { class AutocompleteData {
constructor() { constructor() {
/** @type {Document} */
this.flexSearchDocument = null;
/** @type {TagData[]} */ /** @type {TagData[]} */
this.sortedTags = []; this.sortedTags = [];
@@ -80,7 +113,6 @@ class AutocompleteData {
// Progress of "base" csv loading // Progress of "base" csv loading
this.baseLoadingProgress = { this.baseLoadingProgress = {
// tags: 0,
cooccurrence: 0 cooccurrence: 0
}; };
} }
@@ -95,24 +127,32 @@ export const autoCompleteData = {};
const TAGS_CSV_HEADER = 'tag,category,count,alias'; const TAGS_CSV_HEADER = 'tag,category,count,alias';
const TAGS_CSV_HEADER_COLUMNS = TAGS_CSV_HEADER.split(','); const TAGS_CSV_HEADER_COLUMNS = TAGS_CSV_HEADER.split(',');
const TAG_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('tag'); const TAG_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('tag');
const ALIAS_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('alias');
const CATEGORY_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('category'); const CATEGORY_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('category');
const COUNT_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('count'); const COUNT_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('count');
const ALIAS_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('alias');
// --- Helder Functions --- // --- Helder Functions ---
/** /**
* Get the available tag sources in priority order based on the current settings. * Get the available tag sources in priority order based on the current settings.
* @returns {string[]} Array of available tag sources in priority order * @returns {string[]} Array of available tag sources in priority order
*/ */
export function getEnabledTagSourceInPriorityOrder() { export function getEnabledTagSourceInPriorityOrder() {
return Object.values(TagSource) let enabledTagSources = Object.values(TagSource)
.filter((s) => { .filter((s) => {
return settingValues.tagSource === s || settingValues.tagSource === 'all'; return settingValues.tagSource === s || settingValues.tagSource === 'all';
}) })
.toSorted((a, b) => { .toSorted((a, b) => {
return a === settingValues.primaryTagSource ? -1 : 1; return a === settingValues.primaryTagSource ? -1 : 1;
}); });
// Append Loras and Embeddings if enabled
if (settingValues.enableModels) {
enabledTagSources = [...enabledTagSources, ...Object.values(ModelTagSource)];
}
return enabledTagSources;
} }
// --- Data Loading Functions --- // --- Data Loading Functions ---
@@ -156,21 +196,13 @@ async function loadTags(csvUrl, siteName) {
const aliases = aliasStr ? aliasStr.split(',').map(a => a.trim()).filter(a => a.length > 0) : []; const aliases = aliasStr ? aliasStr.split(',').map(a => a.trim()).filter(a => a.length > 0) : [];
// Create a TagData instance instead of a plain object // Create a TagData instance instead of a plain object
const tagData = new TagData(tag, aliases, category, count, siteName); const tagData = new TagData(tag, category, count, aliases, siteName);
updateMaxTagLength(tag.length);
autoCompleteData[siteName].sortedTags.push(tagData); autoCompleteData[siteName].sortedTags.push(tagData);
} else {
console.warn(`[Autocomplete-Plus] Invalid CSV format in line ${i + 1} of ${csvUrl}: ${line}. Expected ${TAGS_CSV_HEADER_COLUMNS.length} columns, but got ${columns.length}.`);
continue;
}
}
// Sort by count in descending order // Set the tag and its alias in the maps
autoCompleteData[siteName].sortedTags.sort((a, b) => b.count - a.count);
// Build maps as before, but ensure not to overwrite if already processed from extra files
autoCompleteData[siteName].sortedTags.forEach(tagData => {
if (!autoCompleteData[siteName].tagMap.has(tagData.tag)) {
autoCompleteData[siteName].tagMap.set(tagData.tag, tagData); autoCompleteData[siteName].tagMap.set(tagData.tag, tagData);
if (tagData.alias && Array.isArray(tagData.alias)) { if (tagData.alias && Array.isArray(tagData.alias)) {
tagData.alias.forEach(alias => { tagData.alias.forEach(alias => {
@@ -179,14 +211,63 @@ async function loadTags(csvUrl, siteName) {
} }
}); });
} }
} else {
console.warn(`[Autocomplete-Plus] Invalid CSV format in line ${i + 1} of ${csvUrl}: ${line}. Expected ${TAGS_CSV_HEADER_COLUMNS.length} columns, but got ${columns.length}.`);
continue;
} }
}); }
} catch (error) { } catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${csvUrl}:`, error); console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${csvUrl}:`, error);
} }
} }
/**
* Build FlexSearch index for the given site name.
* @param {string} siteName
*/
async function buildFlexSearchIndex(siteName) {
try {
if (autoCompleteData[siteName].sortedTags.length === 0) {
return;
}
let document = null;
if (Object.values(TagSource).includes(siteName)) {
document = createFlexSearchDocument();
} else if (Object.values(ModelTagSource).includes(siteName)) {
document = createFlexSearchDocumentForModel();
} else {
throw new Error(`[Autocomplete-Plus] Invalid site name: ${siteName}`);
}
let startIdx = 0;
const startTime = performance.now();
function processChunkTasks() {
const chunkSize = 1000;
const end = Math.min(startIdx + chunkSize, autoCompleteData[siteName].sortedTags.length);
for (; startIdx < end; startIdx++) {
const tagData = autoCompleteData[siteName].sortedTags[startIdx];
document.add(startIdx, tagData);
}
if (startIdx < autoCompleteData[siteName].sortedTags.length) {
setTimeout(processChunkTasks, 0);
// console.log(`[Autocomplete-Plus] Current porcess: ${startIdx}`);
} else {
autoCompleteData[siteName].flexSearchDocument = document;
const endTime = performance.now();
const duration = endTime - startTime;
console.info(`[Autocomplete-Plus] Building ${autoCompleteData[siteName].sortedTags.length} index for ${siteName} took ${duration.toFixed(2)}ms.`);
}
}
processChunkTasks();
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to building flexSearch index`, error);
}
}
/** /**
* Loads co-occurrence data from a single CSV file. * Loads co-occurrence data from a single CSV file.
* @param {string} csvUrl - The URL of the CSV file to load. * @param {string} csvUrl - The URL of the CSV file to load.
@@ -297,7 +378,11 @@ function parseCSVLine(line) {
return result; return result;
} }
export async function fetchCsvList() { /**
* Fetch the list of CSV files from the API endpoint
* @returns {Promise<void>}
*/
async function fetchCsvList() {
try { try {
const response = await fetch('/autocomplete-plus/csv'); const response = await fetch('/autocomplete-plus/csv');
if (!response.ok) { if (!response.ok) {
@@ -313,9 +398,8 @@ export async function fetchCsvList() {
/** /**
* Initializes the autocomplete data by fetching the list of CSV files and loading them. * Initializes the autocomplete data by fetching the list of CSV files and loading them.
* This function is called when the extension is initialized.
*/ */
export async function initializeData(csvListData, source) { async function initializeDataFromCSV(csvListData, source) {
if (autoCompleteData.hasOwnProperty(source) === false) { if (autoCompleteData.hasOwnProperty(source) === false) {
autoCompleteData[source] = new AutocompleteData(); autoCompleteData[source] = new AutocompleteData();
} }
@@ -326,7 +410,6 @@ export async function initializeData(csvListData, source) {
const startTime = performance.now(); const startTime = performance.now();
autoCompleteData[source].isInitializing = true; autoCompleteData[source].isInitializing = true;
// console.log("[Autocomplete-Plus] Initializing autocomplete data...");
try { try {
// Store functions that return Promises (Promise Factories) // Store functions that return Promises (Promise Factories)
@@ -340,8 +423,8 @@ export async function initializeData(csvListData, source) {
return; return;
} }
const extraTagsCount = csvListData[source].extra_tags || 0; const extraTagsFileList = csvListData[source].extra_tags || [];
const extraCooccurrenceCount = csvListData[source].extra_cooccurrence || 0; const extraCooccurrenceFileList = csvListData[source].extra_cooccurrence || [];
const tagsUrl = `/autocomplete-plus/csv/${source}/tags`; const tagsUrl = `/autocomplete-plus/csv/${source}/tags`;
const cooccurrenceUrl = `/autocomplete-plus/csv/${source}/tags_cooccurrence`; const cooccurrenceUrl = `/autocomplete-plus/csv/${source}/tags_cooccurrence`;
@@ -349,7 +432,7 @@ export async function initializeData(csvListData, source) {
// Factory for loading tags for the current sourte // Factory for loading tags for the current sourte
const siteTagsLoaderFactory = async () => { const siteTagsLoaderFactory = async () => {
let promiseChain = Promise.resolve(); let promiseChain = Promise.resolve();
for (let i = 0; i < extraTagsCount; i++) { for (let i = 0; i < extraTagsFileList.length; i++) {
promiseChain = promiseChain.then(() => loadTags(`${tagsUrl}/extra/${i}`, source)); promiseChain = promiseChain.then(() => loadTags(`${tagsUrl}/extra/${i}`, source));
} }
if (csvListData[source].base_tags) { if (csvListData[source].base_tags) {
@@ -362,7 +445,7 @@ export async function initializeData(csvListData, source) {
// Factory for loading cooccurrence data for the current sourte // Factory for loading cooccurrence data for the current sourte
const siteCooccurrenceLoaderFactory = async () => { const siteCooccurrenceLoaderFactory = async () => {
let promiseChain = Promise.resolve(); let promiseChain = Promise.resolve();
for (let i = 0; i < extraCooccurrenceCount; i++) { for (let i = 0; i < extraCooccurrenceFileList.length; i++) {
promiseChain = promiseChain.then(() => loadCooccurrence(`${cooccurrenceUrl}/extra/${i}`, source)); promiseChain = promiseChain.then(() => loadCooccurrence(`${cooccurrenceUrl}/extra/${i}`, source));
} }
if (csvListData[source].base_cooccurrence) { if (csvListData[source].base_cooccurrence) {
@@ -375,16 +458,25 @@ export async function initializeData(csvListData, source) {
// Now, execute all promise factories and wait for their completion. // Now, execute all promise factories and wait for their completion.
// The actual loading (fetch calls) will start when the factories are invoked here. // The actual loading (fetch calls) will start when the factories are invoked here.
await Promise.all([ await Promise.all([
Promise.all(tagsLoadPromiseFactories.map(factory => factory())).then(() => { Promise.all(tagsLoadPromiseFactories.map(factory => factory()))
const endTime = performance.now(); .then(() => {
console.log(`[Autocomplete-Plus] "${source}" Tags loading complete in ${(endTime - startTime).toFixed(2)}ms`); // Sort by count in descending order
}), autoCompleteData[source].sortedTags.sort((a, b) => b.count - a.count);
// Build FlexSearch index after tags are loaded
return buildFlexSearchIndex(source);
})
.then(() => {
const endTime = performance.now();
if (csvListData[source].base_tags) {
console.log(`[Autocomplete-Plus] "${source}" Tags loading complete in ${(endTime - startTime).toFixed(2)}ms`);
}
}),
Promise.all(cooccurrenceLoadPromiseFactories.map(factory => factory())).then(() => { Promise.all(cooccurrenceLoadPromiseFactories.map(factory => factory())).then(() => {
const endTime = performance.now(); const endTime = performance.now();
// The original log for extraCooccurrenceCount was potentially misleading as it showed if (csvListData[source].base_cooccurrence) {
// the count for the last processed sourte. Removed for clarity. console.log(`[Autocomplete-Plus] "${source}" Co-occurrence loading complete in ${(endTime - startTime).toFixed(2)}ms.`);
// If a total count is needed, it should be calculated across all sites. }
console.log(`[Autocomplete-Plus] "${source}" Co-occurrence loading complete in ${(endTime - startTime).toFixed(2)}ms.`);
}) })
]); ]);
@@ -395,3 +487,88 @@ export async function initializeData(csvListData, source) {
autoCompleteData[source].isInitializing = false; autoCompleteData[source].isInitializing = false;
} }
} }
/**
* Load Embeddings data from the API endpoint
* @returns {Promise<void>}
*/
async function loadEmbeddings() {
try {
const response = await fetch('/autocomplete-plus/embeddings', { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const embeddings = await response.json();
const source = ModelTagSource.Embeddings;
if (autoCompleteData.hasOwnProperty(source) === false) {
autoCompleteData[source] = new AutocompleteData();
}
embeddings.forEach(embedding => {
if (!autoCompleteData[source].tagMap.has(embedding)) {
const tagData = new TagData(`embedding:${embedding}`, 0, 0, [], source);
autoCompleteData[source].sortedTags.push(tagData);
autoCompleteData[source].tagMap.set(embedding, tagData);
updateMaxTagLength(embedding.length);
}
});
await buildFlexSearchIndex(ModelTagSource.Embeddings);
console.log(`[Autocomplete-Plus] Loaded ${embeddings.length} Embeddings`);
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch Embeddings data:`, error);
}
}
/**
* Load LoRA data from the API endpoint
* @returns {Promise<void>}
*/
async function loadLoras() {
try {
const response = await fetch('/autocomplete-plus/loras', { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const loraNames = await response.json();
const source = ModelTagSource.Lora;
if (autoCompleteData.hasOwnProperty(source) === false) {
autoCompleteData[source] = new AutocompleteData();
}
loraNames.forEach(loraName => {
if (!autoCompleteData[source].tagMap.has(loraName)) {
const tagData = new TagData(`<lora:${loraName}>`, 0, 0, [], source);
autoCompleteData[source].sortedTags.push(tagData);
autoCompleteData[source].tagMap.set(loraName, tagData);
updateMaxTagLength(loraName.length);
}
});
await buildFlexSearchIndex(ModelTagSource.Lora);
console.log(`[Autocomplete-Plus] Loaded ${loraNames.length} LoRA models`);
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch LoRA data:`, error);
}
}
/**
* Load all data sources asynchronously.
*/
export async function loadDataAsync() {
return Promise.all([
fetchCsvList().then((csvList) => {
Object.values(TagSource).forEach((source) => {
initializeDataFromCSV(csvList, source);
});
}),
loadEmbeddings(),
loadLoras(),
]);
}
+254 -71
View File
@@ -3,26 +3,30 @@ import { $el } from "/scripts/ui.js";
import { ComfyWidgets } from "/scripts/widgets.js"; import { ComfyWidgets } from "/scripts/widgets.js";
import { settingValues } from "./settings.js"; import { settingValues } from "./settings.js";
import { loadCSS } from "./utils.js"; import { loadCSS } from "./utils.js";
import { TagSource, fetchCsvList, initializeData } from "./data.js"; import { TagSource, loadDataAsync } from "./data.js";
import { AutocompleteEventHandler } from "./autocomplete.js"; import { AutocompleteEventHandler } from "./autocomplete.js";
import { RelatedTagsEventHandler } from "./related-tags.js"; import { RelatedTagsEventHandler } from "./related-tags.js";
import { AutoFormatterEventHandler } from "./auto-formatter.js";
import { NodeInfo } from "./node-info.js";
// --- Constants --- // --- Constants ---
const id = "AutocompletePlus"; const id = "AutocompletePlus";
const name = "Autocomplete Plus"; const name = "Autocomplete Plus";
// --- Module-level variables ---
const autocompleteEventHandler = new AutocompleteEventHandler();
const relatedTagsEventHandler = new RelatedTagsEventHandler();
const autoFormatterEventHandler = new AutoFormatterEventHandler();
const attachedElementNodeInfoMap = new WeakMap(); // Map to track attached elements and their node info
// --- Functions --- // --- Functions ---
/** /**
* Initialize event handlers for the autocomplete and related tags features. * Initialize event handlers for the autocomplete and related tags features.
*/ */
function initializeEventHandlers() { function initializeEventHandlers() {
const autocompleteEventHandler = new AutocompleteEventHandler();
const relatedTagsEventHandler = new RelatedTagsEventHandler();
const attachedElements = new WeakSet(); // Keep track of elements that have listeners attached
// Function to attach listeners // Function to attach listeners
function attachListeners(element) { function attachListeners(element, nodeInfo) {
if (attachedElements.has(element)) return; // Prevent double attachment if (attachedElementNodeInfoMap.has(element)) return; // Prevent double attachment
element.addEventListener('input', handleInput); element.addEventListener('input', handleInput);
element.addEventListener('focus', handleFocus); element.addEventListener('focus', handleFocus);
@@ -35,7 +39,7 @@ function initializeEventHandlers() {
element.addEventListener('mousemove', handleMouseMove); element.addEventListener('mousemove', handleMouseMove);
element.addEventListener('click', handleClick); element.addEventListener('click', handleClick);
attachedElements.add(element); // Mark as attached attachedElementNodeInfoMap.set(element, nodeInfo); // Mark as attached and store node info
} }
// Attempt Widget Override as the primary method // Attempt Widget Override as the primary method
@@ -46,14 +50,19 @@ function initializeEventHandlers() {
ComfyWidgets.STRING = function (node, inputName, inputData, appInstance) { // Use appInstance to avoid conflict with global app ComfyWidgets.STRING = function (node, inputName, inputData, appInstance) { // Use appInstance to avoid conflict with global app
const result = originalStringWidget.apply(this, arguments); const result = originalStringWidget.apply(this, arguments);
// Check if the widget has an inputEl and if it's a TEXTAREA // Check if the widget has an element and if it's a TEXTAREA
// This is to ensure we are targeting multiline text inputs, related to '.comfy-multiline-input' // This is to ensure we are targeting multiline text inputs, related to '.comfy-multiline-input'
if (result && result.widget && result.widget.inputEl && result.widget.inputEl.tagName === 'TEXTAREA') { if (result && result.widget) {
const widgetConfig = inputData && inputData[1] ? inputData[1] : {}; // fallback for older Comfyui frontend versions
// Future: Add checks for Autocomplete Plus specific configurations if needed const inputEl = result.widget.element ?? result.widget.inputEl;
// e.g., if (widgetConfig["AutocompletePlus.enabled"] === false) return result; if (inputEl && inputEl.tagName === 'TEXTAREA' && !inputEl.readOnly) {
const widgetConfig = inputData && inputData[1] ? inputData[1] : {};
// Future: Add checks for Autocomplete Plus specific configurations if needed
// e.g., if (widgetConfig["AutocompletePlus.enabled"] === false) return result;
attachListeners(result.widget.inputEl); const nodeInfo = new NodeInfo(node.comfyClass || node.constructor.name, inputName);
attachListeners(inputEl, nodeInfo);
}
} }
return result; return result;
}; };
@@ -73,9 +82,11 @@ function initializeEventHandlers() {
targetSelectors.forEach(selector => { targetSelectors.forEach(selector => {
// Check if the added node itself matches or contains matching elements // Check if the added node itself matches or contains matching elements
if (node.matches(selector)) { if (node.matches(selector)) {
attachListeners(node); attachListeners(node, new NodeInfo('Fallback', 'unknown'));
} else { } else {
node.querySelectorAll(selector).forEach(attachListeners); node.querySelectorAll(selector).forEach(el => {
attachListeners(el, new NodeInfo('Fallback', 'unknown'));
});
} }
}); });
} }
@@ -85,48 +96,72 @@ function initializeEventHandlers() {
// Initial scan for existing elements // Initial scan for existing elements
targetSelectors.forEach(selector => { targetSelectors.forEach(selector => {
document.querySelectorAll(selector).forEach(attachListeners); document.querySelectorAll(selector).forEach(el => {
attachListeners(el, new NodeInfo('Fallback', 'unknown'));
});
}); });
// Start observing the document body for changes // Start observing the document body for changes
observer.observe(document.body, { childList: true, subtree: true }); observer.observe(document.body, { childList: true, subtree: true });
} }
/**
* Get NodeInfo for the event target element
* @param {Event} event - The DOM event
* @returns {Object|null} NodeInfo object or undefined if not found
*/
function getNodeInfo(event) {
const nodeInfo = attachedElementNodeInfoMap.get(event.target);
if (!nodeInfo) {
console.warn('[Autocomplete-Plus] Node info not found for element in ', event.target);
return null;
}
return nodeInfo;
}
function handleInput(event) { function handleInput(event) {
autocompleteEventHandler.handleInput(event); autocompleteEventHandler.handleInput(event);
relatedTagsEventHandler.handleInput(event); relatedTagsEventHandler.handleInput(event);
autoFormatterEventHandler.handleInput(event);
} }
function handleFocus(event) { function handleFocus(event) {
autocompleteEventHandler.handleFocus(event); autocompleteEventHandler.handleFocus(event);
relatedTagsEventHandler.handleFocus(event); relatedTagsEventHandler.handleFocus(event);
autoFormatterEventHandler.handleFocus(event);
} }
function handleBlur(event) { function handleBlur(event) {
const nodeInfo = getNodeInfo(event); // Get node info to pass to auto formatter
autocompleteEventHandler.handleBlur(event); autocompleteEventHandler.handleBlur(event);
relatedTagsEventHandler.handleBlur(event); relatedTagsEventHandler.handleBlur(event);
autoFormatterEventHandler.handleBlur(event, nodeInfo);
} }
function handleKeyDown(event) { function handleKeyDown(event) {
autocompleteEventHandler.handleKeyDown(event); autocompleteEventHandler.handleKeyDown(event);
relatedTagsEventHandler.handleKeyDown(event); relatedTagsEventHandler.handleKeyDown(event);
autoFormatterEventHandler.handleKeyDown(event);
} }
function handleKeyUp(event) { function handleKeyUp(event) {
autocompleteEventHandler.handleKeyUp(event); autocompleteEventHandler.handleKeyUp(event);
relatedTagsEventHandler.handleKeyUp(event); relatedTagsEventHandler.handleKeyUp(event);
autoFormatterEventHandler.handleKeyUp(event);
} }
// New event handler for mousemove to show related tags on hover
function handleMouseMove(event) { function handleMouseMove(event) {
autocompleteEventHandler.handleMouseMove(event); autocompleteEventHandler.handleMouseMove(event);
relatedTagsEventHandler.handleMouseMove(event); relatedTagsEventHandler.handleMouseMove(event);
autoFormatterEventHandler.handleMouseMove(event);
} }
// New event handler for click to show related tags
function handleClick(event) { function handleClick(event) {
autocompleteEventHandler.handleClick(event); autocompleteEventHandler.handleClick(event);
relatedTagsEventHandler.handleClick(event); relatedTagsEventHandler.handleClick(event);
autoFormatterEventHandler.handleClick(event);
} }
} }
@@ -190,40 +225,41 @@ async function addExtraSettings() {
} }
// Add extra setting for checking new CSV updates // Add extra setting for checking new CSV updates
app.ui.settings.addSetting({ // Note: Temporarily comment out until changes to remote files can be detected
id: id + ".check_new_csv", // app.ui.settings.addSetting({
defaultValue: null, // id: id + ".check_new_csv",
name: "Check CSV updates", // defaultValue: null,
category: [name, "Misc", "Check new CSV"], // name: "Check CSV updates",
type: () => { // category: [name, "Misc", "Check new CSV"],
const lastCheckSpan = $el("span", { // type: () => {
textContent: lastCheckTimeText, // const lastCheckSpan = $el("span", {
className: "text-sm text-gray-500", // textContent: lastCheckTimeText,
style: { // className: "text-sm text-gray-500",
marginRight: "16px" // style: {
} // marginRight: "16px"
}); // }
// });
const checkButton = $el("button", { // const checkButton = $el("button", {
textContent: "Check now", // textContent: "Check now",
className: "p-button p-component p-button-primary", // className: "p-button p-component p-button-primary",
onclick: async () => { // onclick: async () => {
await performUpdateCheck(checkButton, lastCheckSpan); // await performUpdateCheck(checkButton, lastCheckSpan);
} // }
}); // });
return $el("div", { // return $el("div", {
className: "flex-row items-center gap-2", // className: "flex-row items-center gap-2",
}, [ // }, [
$el("div", { // $el("div", {
className: "p-component", // className: "p-component",
}, [ // }, [
lastCheckSpan, // lastCheckSpan,
checkButton, // checkButton,
]), // ]),
]); // ]);
} // }
}); // });
} }
/** /**
@@ -240,18 +276,54 @@ app.registerExtension({
let rootPath = import.meta.url.replace("js/main.js", ""); let rootPath = import.meta.url.replace("js/main.js", "");
loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete
fetchCsvList().then((csvList) => { await loadDataAsync();
Object.values(TagSource).forEach((source) => {
initializeData(csvList, source);
});
});
}, },
// --- Commands ---
commands: [
{
id: id + ".formatPrompt",
label: name + ": Format Prompt",
function: () => {
const activeEl = document.activeElement;
if (!activeEl || activeEl.tagName !== 'TEXTAREA') {
// console.debug('[Autocomplete-Plus] Format command: No textarea is currently focused');
return;
}
const nodeInfo = attachedElementNodeInfoMap.get(activeEl);
if (!nodeInfo) {
console.warn('[Autocomplete-Plus] Format command: Node info not found for focused textarea');
// Use fallback NodeInfo
const fallbackNodeInfo = new NodeInfo('Unknown', 'unknown');
autoFormatterEventHandler.applyFormatTextarea(activeEl, fallbackNodeInfo);
return;
}
const formatted = autoFormatterEventHandler.applyFormatTextarea(activeEl, nodeInfo);
if (formatted) {
// console.debug('[Autocomplete-Plus] Format command: Formatting applied');
} else {
// console.debug('[Autocomplete-Plus] Format command: Formatting skipped (blocklisted or not applicable)');
}
}
}
],
// --- Keybindings ---
keybindings: [
{
combo: { key: "f", alt: true, shift: true },
commandId: id + ".formatPrompt"
}
],
// One the Settings Screen, displays reverse order in same category // One the Settings Screen, displays reverse order in same category
settings: [ settings: [
// --- Tag source Settings --- // --- Tag source Settings ---
{ {
id: id + ".tag_source_icon_position", id: id + ".TagSource.IconPosition",
name: "Tag Source Icon Position", name: "Tag Source Icon Position",
type: "combo", type: "combo",
options: ["left", "right", "hidden"], options: ["left", "right", "hidden"],
@@ -262,7 +334,7 @@ app.registerExtension({
} }
}, },
{ {
id: id + ".primary_tag_source", id: id + ".TagSource.PrimaryTagSource",
name: "Primary source for 'all' Source", name: "Primary source for 'all' Source",
tooltip: "When 'Autocomplete Tag Source' is 'all', this determines which source's tags appear first in suggestions.", tooltip: "When 'Autocomplete Tag Source' is 'all', this determines which source's tags appear first in suggestions.",
type: "combo", type: "combo",
@@ -274,7 +346,7 @@ app.registerExtension({
} }
}, },
{ {
id: id + ".tag_source", id: id + ".TagSource",
name: "Autocomplete Tag Source", name: "Autocomplete Tag Source",
tooltip: "Select the tag source for autocomplete suggestions. 'all' includes tags from all loaded sources.", tooltip: "Select the tag source for autocomplete suggestions. 'all' includes tags from all loaded sources.",
type: "combo", type: "combo",
@@ -285,9 +357,64 @@ app.registerExtension({
settingValues.tagSource = newVal; settingValues.tagSource = newVal;
} }
}, },
// --- Autocomplete Settings --- // --- Autocomplete Settings ---
{ {
id: id + ".max_suggestions", id: id + ".Autocompletion.UseFastSearch",
name: "Use Fast Search",
tooltip: "Tag search processing during text input operates faster, improving responsiveness.",
type: "boolean",
defaultValue: false,
category: [name, "Autocompletion", "Use Fast Search"],
onChange: (newVal, oldVal) => {
settingValues.useFastSearch = newVal;
}
},
{
id: id + ".Autocompletion.EnableModels",
name: "Enable Loras and Embeddings",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Enable Loras and Embeddings"],
onChange: (newVal, oldVal) => {
settingValues.enableModels = newVal;
}
},
{
id: id + ".Autocompletion.PrefixArtist",
name: "String to add before artist tags",
tooltip: "Text to prepend when inserting an artist tag via autocomplete.\ne.g. '@' -> '@artist_name'.",
type: "text",
defaultValue: '',
category: [name, "Autocompletion", "String to add before artist tags"],
onChange: (newVal, oldVal) => {
settingValues.prefixArtist = newVal;
}
},
{
id: id + ".Autocompletion.ReplaceUnderscoreWithSpace",
name: "Replace '_' with 'Space'",
tooltip: "This setting also affects related tags display.",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Replace Underscore with Space"],
onChange: (newVal, oldVal) => {
settingValues.replaceUnderscoreWithSpace = newVal;
}
},
{
id: id + ".Autocompletion.AutoInsertComma",
name: "Auto-Insert Comma",
tooltip: "Automatically insert a comma after tags when inserting from autocomplete.",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Auto-Insert Comma"],
onChange: (newVal, oldVal) => {
settingValues.autoInsertComma = newVal;
}
},
{
id: id + ".Autocompletion.MaxSuggestions",
name: "Max suggestions", name: "Max suggestions",
type: "slider", type: "slider",
attrs: { attrs: {
@@ -302,9 +429,8 @@ app.registerExtension({
} }
}, },
{ {
id: id + ".boolean", id: id + ".Autocompletion.Enable",
name: "Enable Autocomplete", name: "Enable Autocomplete",
description: "Enable or disable the autocomplete feature.",
type: "boolean", type: "boolean",
defaultValue: true, defaultValue: true,
category: [name, "Autocompletion", "Enable Autocomplete"], category: [name, "Autocompletion", "Enable Autocomplete"],
@@ -315,9 +441,9 @@ app.registerExtension({
// --- Related Tags Settings --- // --- Related Tags Settings ---
{ {
id: id + ".related_tags_trigger_mode", id: id + ".RelatedTags.RelatedTagsTriggerMode",
name: "Related Tags Trigger Mode", name: "Related Tags Trigger Mode",
description: "Trigger mode for related tags (click or ctrl+click)", tooltip: "Which action will trigger displaying related tags for the entered tag (click only, Ctrl+click).",
type: "combo", type: "combo",
options: ["click", "ctrl+Click"], options: ["click", "ctrl+Click"],
defaultValue: "click", defaultValue: "click",
@@ -327,9 +453,9 @@ app.registerExtension({
} }
}, },
{ {
id: id + ".related_tags_position", id: id + ".RelatedTags.DisplayPosition",
name: "Default Display Position", name: "Default Display Position",
description: "Display position (relative to Textarea)", tooltip: "Display position (relative to Textarea).",
type: "combo", type: "combo",
options: ["horizontal", "vertical"], options: ["horizontal", "vertical"],
defaultValue: "horizontal", defaultValue: "horizontal",
@@ -339,9 +465,8 @@ app.registerExtension({
} }
}, },
{ {
id: id + ".max_related_tags", id: id + ".RelatedTags.MaxRelatedTags",
name: "Max related tags", name: "Max related tags",
description: "Maximum number of related tags to display",
type: "slider", type: "slider",
attrs: { attrs: {
min: 5, min: 5,
@@ -355,15 +480,73 @@ app.registerExtension({
} }
}, },
{ {
id: id + ".related_tags_enable", id: id + ".RelatedTags.Enable",
name: "Enable Related Tags", name: "Enable Related Tags",
description: "Enable or disable the related tags feature.",
type: "boolean", type: "boolean",
defaultValue: true, defaultValue: true,
category: [name, "Related Tags", "Enable Related Tags"], category: [name, "Related Tags", "Enable Related Tags"],
onChange: (newVal, oldVal) => { onChange: (newVal, oldVal) => {
settingValues.enableRelatedTags = newVal; settingValues.enableRelatedTags = newVal;
} }
} },
// --- Display settings ---
{
id: id + ".Display.HideAlias",
name: "Hide Alias",
tooltip: "Hide alias column in the autocomplete and related tags display.",
type: "boolean",
defaultValue: false,
category: [name, "Display", "Hide Alias"],
onChange: (newVal, oldVal) => {
settingValues.hideAlias = newVal;
}
},
// --- Auto format settings ---
{
id: id + '.AutoFormatter.TrimSurroundingSpaces',
name: 'Trim Surrounding Spaces',
tooltip: 'When enabled, trim any blank lines from the beginning and end of the prompt.',
type: 'boolean',
defaultValue: false,
category: [name, 'AutoFormatter', 'Trim Surrounding Spaces'],
onChange: (newVal, oldVal) => {
settingValues.trimSurroundingSpaces = newVal;
},
},
{
id: id + '.AutoFormatter.UseTrailingComma',
name: 'Use Trailing Comma',
tooltip: 'When enabled, ensures all lines end with a trailing comma.\nWhen disabled, removes trailing commas.',
type: 'boolean',
defaultValue: false,
category: [name, 'AutoFormatter', 'Use Trailing Comma'],
onChange: (newVal, oldVal) => {
settingValues.useTrailingComma = newVal;
},
},
{
id: id + '.AutoFormatter.Trigger',
name: 'Auto Format Trigger',
tooltip: 'Auto: Format automatically when leaving text field.\nManual: Format only via keyboard shortcut. default keybind: (Alt+Shift+F)',
type: 'combo',
options: ['auto', 'manual'],
defaultValue: 'auto',
category: [name, 'AutoFormatter', 'Auto Format Trigger'],
onChange: (newVal, oldVal) => {
settingValues.autoFormatTrigger = newVal;
},
},
{
id: id + '.AutoFormatter.EnableAutoFormat',
name: 'Enable Auto Format',
type: 'boolean',
defaultValue: true,
category: [name, 'AutoFormatter', 'Enable Auto Format'],
onChange: (newVal, oldVal) => {
settingValues.enableAutoFormat = newVal;
},
},
] ]
}); });
+14
View File
@@ -0,0 +1,14 @@
/**
* Class to hold information about the node attached to an input element.
* Used to control behavior based on node information.
*/
export class NodeInfo {
/**
* @param {string} nodeType - The type/class name of the node
* @param {string} inputName - The name of the input widget
*/
constructor(nodeType, inputName) {
this.nodeType = nodeType;
this.inputName = inputName;
}
}
+164 -53
View File
@@ -1,13 +1,16 @@
import { TagSource, TagCategory, TagData, autoCompleteData } from './data.js'; import { TagCategory, TagData, TagSource, autoCompleteData, getEnabledTagSourceInPriorityOrder } from './data.js';
import { settingValues } from './settings.js'; import { settingValues } from './settings.js';
import { import {
extractTagsFromTextArea, extractTagsFromTextArea,
findAllTagPositions, findAllTagPositions,
getCurrentTagRange,
getScrollbarWidth,
getViewportMargin, getViewportMargin,
isLongText,
isValidTag, isValidTag,
normalizeTagToInsert, normalizeTagToInsert,
normalizeTagToSearch, normalizeTagToSearch,
getCurrentTagRange, openTagWikiUrl
} from './utils.js'; } from './utils.js';
// --- RelatedTags Logic --- // --- RelatedTags Logic ---
@@ -67,7 +70,7 @@ function searchRelatedTags(tag) {
const tagSource = TagSource.Danbooru; // TODO: Leave the tag source as Danbooru until e621_tags_cooccurrence.csv is ready const tagSource = TagSource.Danbooru; // TODO: Leave the tag source as Danbooru until e621_tags_cooccurrence.csv is ready
if (!tag || !autoCompleteData[tagSource].cooccurrenceMap.has(tag)) { if (!tag || !autoCompleteData[tagSource].initialized || !autoCompleteData[tagSource].cooccurrenceMap.has(tag)) {
return []; return [];
} }
@@ -93,6 +96,8 @@ function searchRelatedTags(tag) {
category: tagData.category, category: tagData.category,
source: tagData.source, source: tagData.source,
count: tagData.count, count: tagData.count,
categoryText: tagData.categoryText,
hasWikiPage: tagData.hasWikiPage
}); });
}); });
@@ -199,11 +204,20 @@ class RelatedTagsUI {
this.header = document.createElement('div'); this.header = document.createElement('div');
this.header.id = 'related-tags-header'; this.header.id = 'related-tags-header';
this.headerTextContainer = document.createElement('div');
this.headerTextContainer.className = 'related-tags-header-text-container';
this.header.appendChild(this.headerTextContainer);
// Create header text div for the left side // Create header text div for the left side
this.headerText = document.createElement('div'); this.headerText = document.createElement('div');
this.headerText.className = 'related-tags-header-text'; this.headerText.className = 'related-tags-header-tag-text';
this.headerText.textContent = 'Related Tags'; this.headerText.textContent = 'Related Tags';
this.header.appendChild(this.headerText); this.headerTextContainer.appendChild(this.headerText);
// Create header alias div for the 2nd line
this.headerAlias = document.createElement('div');
this.headerAlias.className = 'related-tags-header-tag-alias';
this.headerTextContainer.appendChild(this.headerAlias);
// Create header controls for the right side // Create header controls for the right side
this.headerControls = document.createElement('div'); this.headerControls = document.createElement('div');
@@ -267,11 +281,31 @@ class RelatedTagsUI {
// Timer ID for auto-refresh // Timer ID for auto-refresh
this.autoRefreshTimerId = null; this.autoRefreshTimerId = null;
// Add click handler for wiki link in header tag name
this.headerText.addEventListener('mousedown', (e) => {
const tagNameEl = e.target.closest('.related-tags-header-tag-name');
if (tagNameEl && !tagNameEl.classList.contains('disabled')) {
openTagWikiUrl(tagNameEl.dataset.tagSource, tagNameEl.dataset.tagName);
e.preventDefault();
e.stopPropagation();
return;
}
});
// Add click handler for tag selection // Add click handler for tag selection
this.tagsContainer.addEventListener('mousedown', (e) => { this.tagsContainer.addEventListener('mousedown', (e) => {
// Check if wiki icon was clicked first
const wikiIcon = e.target.closest('.related-tag-wiki-icon');
if (wikiIcon && !wikiIcon.classList.contains('disabled')) {
openTagWikiUrl(wikiIcon.dataset.tagSource, wikiIcon.dataset.tagName);
e.preventDefault();
e.stopPropagation();
return;
}
const row = e.target.closest('.related-tag-item'); const row = e.target.closest('.related-tag-item');
if (row && row.dataset.tag) { if (row && row.dataset.tagName) {
this.#insertTag(row.dataset.tag); this.#insertTag(row.dataset.tagName);
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
@@ -300,7 +334,7 @@ class RelatedTagsUI {
const currentTag = getTagFromCursorPosition(textareaElement); const currentTag = getTagFromCursorPosition(textareaElement);
if (!this.isPinned) { if (!this.isPinned) {
if (isValidTag(currentTag)) { if (!isLongText(currentTag) && isValidTag(currentTag)) {
this.currentTag = currentTag this.currentTag = currentTag
} else { } else {
this.hide(); this.hide();
@@ -311,9 +345,6 @@ class RelatedTagsUI {
this.target = textareaElement; this.target = textareaElement;
this.relatedTags = searchRelatedTags(this.currentTag); this.relatedTags = searchRelatedTags(this.currentTag);
if (this.selectedIndex == -1) {
this.selectedIndex = 0; // Reset selection to the first item
}
this.#updateHeader(); this.#updateHeader();
this.#updateContent(); this.#updateContent();
@@ -355,10 +386,18 @@ class RelatedTagsUI {
} }
} }
/** Moves the selection up or down */ /** Moves the selection up or down
* @param {direction} 1 for down, -1 for up
*/
navigate(direction) { navigate(direction) {
if (this.relatedTags.length === 0) return; if (this.relatedTags.length === 0) return;
this.selectedIndex += direction;
if (this.selectedIndex == -1) {
// Initialize selection based on navigation direction
this.selectedIndex = direction == 1 ? 0 : this.relatedTags.length - 1;
} else {
this.selectedIndex += direction;
}
if (this.selectedIndex < 0) { if (this.selectedIndex < 0) {
this.selectedIndex = this.relatedTags.length - 1; // Wrap around to bottom this.selectedIndex = this.relatedTags.length - 1; // Wrap around to bottom
@@ -368,10 +407,27 @@ class RelatedTagsUI {
this.#highlightItem(); this.#highlightItem();
} }
/** Selects the currently highlighted item */ /**
getSelectedTag() { * Get TagData of the current tag
* @returns {TagData|null}
*/
getCurrentTagData() {
for (const source of getEnabledTagSourceInPriorityOrder()) {
if (source in autoCompleteData && autoCompleteData[source].tagMap.has(this.currentTag)) {
return autoCompleteData[source].tagMap.get(this.currentTag);
}
}
return null;
}
/**
* Selects the currently highlighted item
* @return {TagData|null}
*/
getSelectedTagData() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.relatedTags.length) { if (this.selectedIndex >= 0 && this.selectedIndex < this.relatedTags.length) {
return this.relatedTags[this.selectedIndex].tag; return this.relatedTags[this.selectedIndex];
} }
return null; // No valid selection return null; // No valid selection
@@ -390,30 +446,51 @@ class RelatedTagsUI {
* Updates header content * Updates header content
*/ */
#updateHeader() { #updateHeader() {
// Find the tag data for the current tag let tagData = this.getCurrentTagData();
const tagData = Object.values(TagSource)
.map((source) => { if (!tagData) {
if (source in autoCompleteData && autoCompleteData[source].tagMap.has(this.currentTag)) { // Create a dummy TagData if not found
return autoCompleteData[source].tagMap.get(this.currentTag); tagData = new TagData(this.currentTag, null, 0, [], TagSource.Danbooru);
} }
})
.find((tagData) => tagData !== undefined); const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
const aliasText = tagData.alias.join(', ');
// Update header text with current tag // Update header text with current tag
this.headerText.innerHTML = ''; // Clear previous content this.headerText.innerHTML = ''; // Clear previous content
this.headerText.textContent = 'Tags related to: '; this.headerText.textContent = 'Tags related to: ';
const tagName = document.createElement('span'); const tagName = document.createElement('span');
tagName.className = 'related-tags-header-tag-name'; tagName.classList.add('related-tags-header-tag-name', tagData.source);
tagName.textContent = this.currentTag; tagName.title = `Count: ${tagData.count}\nCategory: ${categoryText}\nAlias: ${aliasText}`;
if (tagData && ['left', 'right'].includes(settingValues.tagSourceIconPosition)) { tagName.dataset.tagCategory = categoryText;
tagName.dataset.tagSource = tagData.source;
tagName.dataset.tagName = tagData.tag;
if (tagData.source && ['left', 'right'].includes(settingValues.tagSourceIconPosition)) {
const tagSourceIconHtml = `<svg class="autocomplete-plus-tag-icon-svg"><use xlink:href="#autocomplete-plus-icon-${tagData.source}"></use></svg>`; const tagSourceIconHtml = `<svg class="autocomplete-plus-tag-icon-svg"><use xlink:href="#autocomplete-plus-icon-${tagData.source}"></use></svg>`;
tagName.innerHTML = settingValues.tagSourceIconPosition == 'left' tagName.innerHTML = settingValues.tagSourceIconPosition == 'left'
? `${tagSourceIconHtml} ${tagData.tag}` ? `${tagSourceIconHtml} ${tagData.tag}`
: `${tagData.tag} ${tagSourceIconHtml}`; : `${tagData.tag} ${tagSourceIconHtml}`;
} else {
tagName.textContent += tagData.tag;
}
if (!tagData.hasWikiPage) {
tagName.classList.add('disabled');
} }
this.headerText.appendChild(tagName); this.headerText.appendChild(tagName);
// Clear previous alias
this.headerAlias.style.display = 'none';
this.headerAlias.innerHTML = '';
// Add alias if available
if (aliasText.length > 0 && !settingValues.hideAlias) {
this.headerAlias.textContent = aliasText;
this.headerAlias.style.display = 'block';
}
// Update pin button // Update pin button
this.pinBtn.textContent = this.isPinned ? '🎯' : '📌'; this.pinBtn.textContent = this.isPinned ? '🎯' : '📌';
@@ -432,7 +509,7 @@ class RelatedTagsUI {
if (!autoCompleteData[TagSource.Danbooru].initialized) { if (!autoCompleteData[TagSource.Danbooru].initialized) {
// Show loading message // Show loading message
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
messageDiv.className = 'related-tags-loading-message'; messageDiv.className = 'related-tags-message';
messageDiv.textContent = `Initializing cooccurrence data... [${autoCompleteData[TagSource.Danbooru].baseLoadingProgress.cooccurrence}%]`; messageDiv.textContent = `Initializing cooccurrence data... [${autoCompleteData[TagSource.Danbooru].baseLoadingProgress.cooccurrence}%]`;
this.tagsContainer.appendChild(messageDiv); this.tagsContainer.appendChild(messageDiv);
return; return;
@@ -441,11 +518,15 @@ class RelatedTagsUI {
if (!this.relatedTags || this.relatedTags.length === 0) { if (!this.relatedTags || this.relatedTags.length === 0) {
// Show no related tags message // Show no related tags message
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
messageDiv.className = 'related-tags-message';
messageDiv.textContent = 'No related tags found'; messageDiv.textContent = 'No related tags found';
this.tagsContainer.appendChild(messageDiv); this.tagsContainer.appendChild(messageDiv);
return; return;
} }
// Toggle column class based on settings
this.tagsContainer.classList.toggle('no-alias', settingValues.hideAlias);
const existingTags = extractTagsFromTextArea(this.target); const existingTags = extractTagsFromTextArea(this.target);
// Create tag rows // Create tag rows
@@ -463,11 +544,12 @@ class RelatedTagsUI {
* @returns {HTMLTableRowElement} The tag row element * @returns {HTMLTableRowElement} The tag row element
*/ */
#createTagElement(tagData, isExisting) { #createTagElement(tagData, isExisting) {
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown"; const categoryText = tagData.categoryText;
const aliasText = tagData.alias.join(', ');
const tagRow = document.createElement('div'); const tagRow = document.createElement('div');
tagRow.classList.add('related-tag-item', tagData.source); tagRow.classList.add('related-tag-item', tagData.source);
tagRow.dataset.tag = tagData.tag; tagRow.dataset.tagName = tagData.tag;
tagRow.dataset.tagCategory = categoryText; tagRow.dataset.tagCategory = categoryText;
// Tag name // Tag name
@@ -480,38 +562,48 @@ class RelatedTagsUI {
tagName.classList.add('related-tag-already-exists'); tagName.classList.add('related-tag-already-exists');
} }
// Wiki icon
const wikiIcon = document.createElement('span');
wikiIcon.className = 'related-tag-wiki-icon';
if (tagData.hasWikiPage) {
wikiIcon.dataset.tagName = tagData.tag;
wikiIcon.dataset.tagSource = tagData.source;
wikiIcon.textContent = '📖'
wikiIcon.title = 'Open wiki page';
} else {
wikiIcon.classList.add('disabled');
}
// Alias // Alias
const alias = document.createElement('span'); const alias = document.createElement('span');
alias.className = 'related-tag-alias'; alias.className = 'related-tag-alias';
// Display alias if available // Display alias if available
if (tagData.alias && tagData.alias.length > 0) { if (aliasText.length > 0) {
let aliasText = tagData.alias.join(', ');
alias.textContent = `${aliasText}`; alias.textContent = `${aliasText}`;
alias.title = tagData.alias.join(', '); // Full alias on hover alias.title = aliasText; // Full alias on hover
} }
// Category
const category = document.createElement('span');
category.className = `related-tag-category`;
category.textContent = `${categoryText.substring(0, 2)}`;
// Similarity // Similarity
const similarity = document.createElement('span'); const similarity = document.createElement('span');
similarity.className = 'related-tag-similarity'; similarity.className = 'related-tag-similarity';
similarity.textContent = `${(tagData.similarity * 100).toFixed(2)}%`; similarity.textContent = `${(tagData.similarity * 100).toFixed(2)}%`;
// Create tooltip with more info // Create tooltip with more info
let tooltipText = `Tag: ${tagData.tag}\nSimilarity: ${(tagData.similarity * 100).toFixed(2)}%\nCount: ${tagData.count}`; let tooltipText = `Similarity: ${(tagData.similarity * 100).toFixed(2)}%\nCount: ${tagData.count}\nCategory: ${categoryText}`;
if (tagData.alias && tagData.alias.length > 0) { if (aliasText.length > 0) {
tooltipText += `\nAlias: ${tagData.alias.join(', ')}`; tooltipText += `\nAlias: ${aliasText}`;
} }
tagRow.title = tooltipText; tagRow.title = tooltipText;
// Add cells to row // Add cells to row
tagRow.appendChild(tagName); tagRow.appendChild(tagName);
tagRow.appendChild(alias); tagRow.appendChild(wikiIcon);
tagRow.appendChild(category);
if (!settingValues.hideAlias) {
tagRow.appendChild(alias);
}
tagRow.appendChild(similarity); tagRow.appendChild(similarity);
return tagRow; return tagRow;
@@ -530,25 +622,29 @@ class RelatedTagsUI {
this.root.style.maxWidth = ''; this.root.style.maxWidth = '';
this.tagsContainer.style.maxHeight = ''; this.tagsContainer.style.maxHeight = '';
const rootRect = this.root.getBoundingClientRect(); const rootRect = this.root.getBoundingClientRect();
const headerRect = this.header.getBoundingClientRect();
// Hide it again after measurement
this.root.style.display = 'none';
this.root.style.visibility = 'visible';
// Get the optimal placement area // Get the optimal placement area
const placementArea = this.#getOptimalPlacementArea(rootRect.width, rootRect.height); const placementArea = this.#getOptimalPlacementArea(rootRect.width, rootRect.height);
// Apply Styles // Apply position and size
this.root.style.left = `${placementArea.x}px`; this.root.style.left = `${placementArea.x}px`;
this.root.style.top = `${placementArea.y}px`; this.root.style.top = `${placementArea.y}px`;
this.root.style.maxWidth = `${placementArea.width}px`; this.root.style.maxWidth = `${placementArea.width}px`;
this.tagsContainer.style.maxHeight = `${placementArea.height - headerRect.height}px`; const newHeaderRect = this.header.getBoundingClientRect();
if (this.relatedTags.length > 0) {
this.tagsContainer.style.maxHeight = `${placementArea.height - newHeaderRect.height}px`;
}
// Hide it again after measurement
this.root.style.display = 'none';
this.root.style.visibility = 'visible';
} }
/** Highlights the item (row) at the given index */ /** Highlights the item (row) at the given index */
#highlightItem() { #highlightItem() {
if (this.getSelectedTag() === null) return; // No valid selection if (this.getSelectedTagData() === null) return; // No valid selection
const items = this.tagsContainer.children; // Get rows const items = this.tagsContainer.children; // Get rows
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
@@ -581,9 +677,9 @@ class RelatedTagsUI {
} }
insertSelectedTag() { insertSelectedTag() {
const selectedTag = this.getSelectedTag(); const selectedTag = this.getSelectedTagData();
if (selectedTag) { if (selectedTag) {
this.#insertTag(selectedTag); this.#insertTag(selectedTag.tag);
} }
} }
@@ -626,6 +722,10 @@ class RelatedTagsUI {
area.y = targetRect.bottom; area.y = targetRect.bottom;
} }
// Calculate width considering scrollbar width if vertical scrolling is needed
const scrollbarWidth = area.height < elemHeight ? getScrollbarWidth() : 0;
area.width = Math.min(elemWidth + scrollbarWidth, maxWidth);
// Adjust x position to avoid overflow // Adjust x position to avoid overflow
area.x = Math.min(area.x, viewportWidth - area.width - margin.right); area.x = Math.min(area.x, viewportWidth - area.width - margin.right);
} else { } else {
@@ -642,6 +742,10 @@ class RelatedTagsUI {
area.x = targetRect.right; area.x = targetRect.right;
} }
// Calculate width considering scrollbar width if vertical scrolling is needed
const scrollbarWidth = area.height < elemHeight ? getScrollbarWidth() : 0;
area.width = Math.min(area.width + scrollbarWidth, viewportWidth - margin.left - margin.right);
// Adjust y position to avoid overflow // Adjust y position to avoid overflow
area.y = Math.min(area.y, viewportHeight - area.height - margin.bottom); area.y = Math.min(area.y, viewportHeight - area.height - margin.bottom);
} }
@@ -721,13 +825,20 @@ export class RelatedTagsEventHandler {
break; break;
case 'Enter': case 'Enter':
case 'Tab': case 'Tab':
if (this.relatedTagsUI.getSelectedTag() !== null) { if (this.relatedTagsUI.getSelectedTagData() !== null) {
event.preventDefault(); // Prevent Tab from changing focus event.preventDefault(); // Prevent Tab from changing focus
this.relatedTagsUI.insertSelectedTag(); this.relatedTagsUI.insertSelectedTag();
} else if (!this.relatedTagsUI.isPinned) { // If nothing selected and not pinned, hide the panel } else if (!this.relatedTagsUI.isPinned) { // If nothing selected and not pinned, hide the panel
this.relatedTagsUI.hide(); this.relatedTagsUI.hide();
} }
break; break;
case 'F1':
event.preventDefault();
const tagData = this.relatedTagsUI.getSelectedTagData() || this.relatedTagsUI.getCurrentTagData();
if (tagData && tagData.hasWikiPage) {
openTagWikiUrl(tagData.source, tagData.tag);
}
break;
case 'Escape': case 'Escape':
event.preventDefault(); event.preventDefault();
this.relatedTagsUI.hide(); this.relatedTagsUI.hide();
+133
View File
@@ -0,0 +1,133 @@
import { Charset, Encoder, Document } from './thirdparty/flexsearch.bundle.module.min.js'
import { kataToHira } from './utils.js';
/**
* Creates an encoder optimized for processing English tag names.
* Handles tag formatting like underscores and parentheses commonly used in Danbooru tags.
* @returns {Encoder} FlexSearch encoder for English tags
*/
function createTagEncoder() {
return new Encoder({
normalize: true,
dedupe: false,
numeric: false,
cache: true,
// filter: new Set(['and', 'to', 'be', 'on']),
replacer: [/(?<=[a-zA-Z\)])_$/, ''], // Remove trailing underscores after letters/parentheses
split: /(?<=[a-zA-Z\)])_(?=[a-zA-Z\(])|\((?=[a-zA-Z])|(?<=[a-zA-Z\)])\)|[ \-\n]/ // Split on underscores between words, parentheses, spaces, hyphens, and newlines
});
}
/**
* Creates an encoder optimized for processing CJK (Chinese, Japanese, Korean) characters.
* Uses exact character matching and converts katakana to hiragana for better Japanese search.
* @returns {Encoder} FlexSearch encoder for CJK text
*/
function createCJKEncoder() {
return new Encoder(Charset.Exact, {
dedupe: true,
numeric: true,
cache: true,
filter: new Set(['(', ')']), // Filter out parentheses characters
finalize: (term) => { // Convert katakana to hiragana for better Japanese matching
return term.map(str => kataToHira(str));
}
});
}
/**
* Creates an encoder optimized for processing Embedding or Lora notation.
* @returns {Encoder} FlexSearch encoder
*/
function createModelEncoder() {
return new Encoder({
normalize: true,
dedupe: false,
numeric: true,
cache: true,
prepare: function (str) {
return str.replace(/^<|>$/g, '').split(/(lora:|embedding:|[^\u0000-\u007f]+)/g).filter(Boolean).join(" ").trim();
},
split: /(?<=lora:.*|embedding:.*)[_./\(\)\-\s\\]+/
});
}
/**
* Creates a FlexSearch Document instance optimized for tag searching.
* Configures separate encoders for English tags and CJK aliases with appropriate tokenization.
* @returns {Document} Configured FlexSearch document for tag indexing
*/
export function createFlexSearchDocument() {
const tagEncoder = createTagEncoder();
const cjkEncoder = createCJKEncoder();
// Custom encoding function for alias field that handles mixed language content
const encodeAlias = function (word) {
return word.split(",")
.flatMap(str => {
if (/[^\u0000-\u007f]/.test(str)) {
// Contains non-ASCII characters (CJK text)
return cjkEncoder.encode(str);
} else {
// ASCII characters only (English text)
return tagEncoder.encode(str);
}
})
.filter(Boolean);
}
// Configure the FlexSearch document with optimized indexing settings
const document = new Document({
document: {
id: "id",
index: [
{
field: "tag",
tokenize: "bidirectional", // Allow partial matching from both ends
encoder: tagEncoder, // Use tag-optimized encoder
},
{
field: "alias", // Index the alias field for multi-language support
tokenize: "full", // Full tokenization for complete alias matching
encode: encodeAlias, // Use custom multi-language encoding function
}
]
}
});
return document;
}
/**
* Creates a FlexSearch Document instance optimized for lora or embedding searching.
* @returns {Document} Configured FlexSearch document
*/
export function createFlexSearchDocumentForModel() {
const modelEncoder = createModelEncoder();
// Configure the FlexSearch document with optimized indexing settings
// Note: alias field is not indexed for lora or embedding search
const document = new Document({
tokenize: "full", // Allow partial matching from both ends
encoder: modelEncoder,
document: {
id: "id",
index: [
{
field: "tag",
},
{
field: "alias",
}
]
}
});
return document;
}
// Export functions for testing when in test environment
const isTestEnvironment = typeof process !== 'undefined' && process.env.NODE_ENV === 'test';
export const __test__ = isTestEnvironment ? { createTagEncoder, createCJKEncoder, createModelEncoder } : undefined;
+26
View File
@@ -7,6 +7,12 @@ export const settingValues = {
// Autocomplete feature settings // Autocomplete feature settings
enabled: true, enabled: true,
maxSuggestions: 10, maxSuggestions: 10,
enableModels: true, // Enable Lora and Embedding suggestions
useFastSearch: false,
replaceUnderscoreWithSpace: true, // Replace underscores with spaces in tag insertion
prefixArtist: '', // Prefix to be attached before artist tags
autoInsertComma: true,
searchDebounceTime: 100, // Sequential search debounce time in milliseconds
// Related tags feature settings // Related tags feature settings
enableRelatedTags: true, enableRelatedTags: true,
@@ -14,11 +20,31 @@ export const settingValues = {
relatedTagsDisplayPosition: 'horizontal', // 'horizontal' or 'vertical' relatedTagsDisplayPosition: 'horizontal', // 'horizontal' or 'vertical'
relatedTagsTriggerMode: 'click', // Options: 'click', 'ctrl+Click' relatedTagsTriggerMode: 'click', // Options: 'click', 'ctrl+Click'
// Display settings
hideAlias: false, // Hide alias in the autocomplete and related tags display
// Auto format settings
enableAutoFormat: true,
autoFormatTrigger: 'auto', // Options: 'auto' (format on blur + shortcut), 'manual' (shortcut only)
useTrailingComma: false, // Whether to add comma at the end of each line
trimSurroundingSpaces: false, // Trim spaces around each tag
// Internal logic settings // Internal logic settings
_useFallbackAttachmentForEventListener: false, // Fallback to attach event listener when somthing goes wrong _useFallbackAttachmentForEventListener: false, // Fallback to attach event listener when somthing goes wrong
_maxTagLength: 100, // Maximum tag length to prevent performance issues with long text input
// Debugging settings (use internally) // Debugging settings (use internally)
_hideWhenOutofFocus: true, // Hide UI when the input is out of focus _hideWhenOutofFocus: true, // Hide UI when the input is out of focus
_logprocessingTime: false, // Log processing time for debugging _logprocessingTime: false, // Log processing time for debugging
} }
/**
* Update the maximum tag length setting value.
* This function ensures that the maximum tag length is always at least as long as the new length provided.
* @param {number} newLength
*/
export function updateMaxTagLength(newLength) {
if(isNaN(newLength)) return;
settingValues._maxTagLength = Math.max(settingValues._maxTagLength, newLength);
}
+106
View File
@@ -0,0 +1,106 @@
/**!
* FlexSearch.js v0.8.200 (Bundle/Module)
* Author and Copyright: Thomas Wilkerling
* Licence: Apache-2.0
* Hosted by Nextapps GmbH
* https://github.com/nextapps-de/flexsearch
*/
var w;function H(a,c,b){const e=typeof b,d=typeof a;if("undefined"!==e){if("undefined"!==d){if(b){if("function"===d&&e===d)return function(h){return a(b(h))};c=a.constructor;if(c===b.constructor){if(c===Array)return b.concat(a);if(c===Map){var f=new Map(b);for(var g of a)f.set(g[0],g[1]);return f}if(c===Set){g=new Set(b);for(f of a.values())g.add(f);return g}}}return a}return b}return"undefined"===d?c:a}function aa(a,c){return"undefined"===typeof a?c:a}function I(){return Object.create(null)}
function M(a){return"string"===typeof a}function ba(a){return"object"===typeof a}function ca(a){const c=[];for(const b of a.keys())c.push(b);return c}function ea(a,c){if(M(c))a=a[c];else for(let b=0;a&&b<c.length;b++)a=a[c[b]];return a};const fa=/[^\p{L}\p{N}]+/u,ha=/(\d{3})/g,ia=/(\D)(\d{3})/g,ja=/(\d{3})(\D)/g,ka=/[\u0300-\u036f]/g;function la(a={}){if(!this||this.constructor!==la)return new la(...arguments);if(arguments.length)for(a=0;a<arguments.length;a++)this.assign(arguments[a]);else this.assign(a)}w=la.prototype;
w.assign=function(a){this.normalize=H(a.normalize,!0,this.normalize);let c=a.include,b=c||a.exclude||a.split,e;if(b||""===b){if("object"===typeof b&&b.constructor!==RegExp){let d="";e=!c;c||(d+="\\p{Z}");b.letter&&(d+="\\p{L}");b.number&&(d+="\\p{N}",e=!!c);b.symbol&&(d+="\\p{S}");b.punctuation&&(d+="\\p{P}");b.control&&(d+="\\p{C}");if(b=b.char)d+="object"===typeof b?b.join(""):b;try{this.split=new RegExp("["+(c?"^":"")+d+"]+","u")}catch(f){this.split=/\s+/}}else this.split=b,e=!1===b||2>"a1a".split(b).length;
this.numeric=H(a.numeric,e)}else{try{this.split=H(this.split,fa)}catch(d){this.split=/\s+/}this.numeric=H(a.numeric,H(this.numeric,!0))}this.prepare=H(a.prepare,null,this.prepare);this.finalize=H(a.finalize,null,this.finalize);b=a.filter;this.filter="function"===typeof b?b:H(b&&new Set(b),null,this.filter);this.dedupe=H(a.dedupe,!0,this.dedupe);this.matcher=H((b=a.matcher)&&new Map(b),null,this.matcher);this.mapper=H((b=a.mapper)&&new Map(b),null,this.mapper);this.stemmer=H((b=a.stemmer)&&new Map(b),
null,this.stemmer);this.replacer=H(a.replacer,null,this.replacer);this.minlength=H(a.minlength,1,this.minlength);this.maxlength=H(a.maxlength,1024,this.maxlength);this.rtl=H(a.rtl,!1,this.rtl);if(this.cache=b=H(a.cache,!0,this.cache))this.I=null,this.R="number"===typeof b?b:2E5,this.B=new Map,this.H=new Map,this.M=this.L=128;this.h="";this.N=null;this.A="";this.O=null;if(this.matcher)for(const d of this.matcher.keys())this.h+=(this.h?"|":"")+d;if(this.stemmer)for(const d of this.stemmer.keys())this.A+=
(this.A?"|":"")+d;return this};w.addStemmer=function(a,c){this.stemmer||(this.stemmer=new Map);this.stemmer.set(a,c);this.A+=(this.A?"|":"")+a;this.O=null;this.cache&&Q(this);return this};w.addFilter=function(a){"function"===typeof a?this.filter=a:(this.filter||(this.filter=new Set),this.filter.add(a));this.cache&&Q(this);return this};
w.addMapper=function(a,c){if("object"===typeof a)return this.addReplacer(a,c);if(1<a.length)return this.addMatcher(a,c);this.mapper||(this.mapper=new Map);this.mapper.set(a,c);this.cache&&Q(this);return this};w.addMatcher=function(a,c){if("object"===typeof a)return this.addReplacer(a,c);if(2>a.length&&(this.dedupe||this.mapper))return this.addMapper(a,c);this.matcher||(this.matcher=new Map);this.matcher.set(a,c);this.h+=(this.h?"|":"")+a;this.N=null;this.cache&&Q(this);return this};
w.addReplacer=function(a,c){if("string"===typeof a)return this.addMatcher(a,c);this.replacer||(this.replacer=[]);this.replacer.push(a,c);this.cache&&Q(this);return this};
w.encode=function(a,c){if(this.cache&&a.length<=this.L)if(this.I){if(this.B.has(a))return this.B.get(a)}else this.I=setTimeout(Q,50,this);this.normalize&&("function"===typeof this.normalize?a=this.normalize(a):a=ka?a.normalize("NFKD").replace(ka,"").toLowerCase():a.toLowerCase());this.prepare&&(a=this.prepare(a));this.numeric&&3<a.length&&(a=a.replace(ia,"$1 $2").replace(ja,"$1 $2").replace(ha,"$1 "));const b=!(this.dedupe||this.mapper||this.filter||this.matcher||this.stemmer||this.replacer);let e=
[],d=I(),f,g,h=this.split||""===this.split?a.split(this.split):[a];for(let m=0,l,n;m<h.length;m++)if((l=n=h[m])&&!(l.length<this.minlength||l.length>this.maxlength)){if(c){if(d[l])continue;d[l]=1}else{if(f===l)continue;f=l}if(b)e.push(l);else if(!this.filter||("function"===typeof this.filter?this.filter(l):!this.filter.has(l))){if(this.cache&&l.length<=this.M)if(this.I){var k=this.H.get(l);if(k||""===k){k&&e.push(k);continue}}else this.I=setTimeout(Q,50,this);if(this.stemmer){this.O||(this.O=new RegExp("(?!^)("+
this.A+")$"));let u;for(;u!==l&&2<l.length;)u=l,l=l.replace(this.O,p=>this.stemmer.get(p))}if(l&&(this.mapper||this.dedupe&&1<l.length)){k="";for(let u=0,p="",t,r;u<l.length;u++)t=l.charAt(u),t===p&&this.dedupe||((r=this.mapper&&this.mapper.get(t))||""===r?r===p&&this.dedupe||!(p=r)||(k+=r):k+=p=t);l=k}this.matcher&&1<l.length&&(this.N||(this.N=new RegExp("("+this.h+")","g")),l=l.replace(this.N,u=>this.matcher.get(u)));if(l&&this.replacer)for(k=0;l&&k<this.replacer.length;k+=2)l=l.replace(this.replacer[k],
this.replacer[k+1]);this.cache&&n.length<=this.M&&(this.H.set(n,l),this.H.size>this.R&&(this.H.clear(),this.M=this.M/1.1|0));if(l){if(l!==n)if(c){if(d[l])continue;d[l]=1}else{if(g===l)continue;g=l}e.push(l)}}}this.finalize&&(e=this.finalize(e)||e);this.cache&&a.length<=this.L&&(this.B.set(a,e),this.B.size>this.R&&(this.B.clear(),this.L=this.L/1.1|0));return e};function Q(a){a.I=null;a.B.clear();a.H.clear()};function ma(a,c,b){b||(c||"object"!==typeof a?"object"===typeof c&&(b=c,c=0):b=a);b&&(a=b.query||a,c=b.limit||c);let e=""+(c||0);b&&(e+=(b.offset||0)+!!b.context+!!b.suggest+(!1!==b.resolve)+(b.resolution||this.resolution)+(b.boost||0));a=(""+a).toLowerCase();this.cache||(this.cache=new R);let d=this.cache.get(a+e);if(!d){const f=b&&b.cache;f&&(b.cache=!1);d=this.search(a,c,b);f&&(b.cache=f);this.cache.set(a+e,d)}return d}function R(a){this.limit=a&&!0!==a?a:1E3;this.cache=new Map;this.h=""}
R.prototype.set=function(a,c){this.cache.set(this.h=a,c);this.cache.size>this.limit&&this.cache.delete(this.cache.keys().next().value)};R.prototype.get=function(a){const c=this.cache.get(a);c&&this.h!==a&&(this.cache.delete(a),this.cache.set(this.h=a,c));return c};R.prototype.remove=function(a){for(const c of this.cache){const b=c[0];c[1].includes(a)&&this.cache.delete(b)}};R.prototype.clear=function(){this.cache.clear();this.h=""};const na={normalize:!1,numeric:!1,dedupe:!1};const qa={};const ra=new Map([["b","p"],["v","f"],["w","f"],["z","s"],["x","s"],["d","t"],["n","m"],["c","k"],["g","k"],["j","k"],["q","k"],["i","e"],["y","e"],["u","o"]]);const sa=new Map([["ae","a"],["oe","o"],["sh","s"],["kh","k"],["th","t"],["ph","f"],["pf","f"]]),ta=[/([^aeo])h(.)/g,"$1$2",/([aeo])h([^aeo]|$)/g,"$1$2",/(.)\1+/g,"$1"];const ua={a:"",e:"",i:"",o:"",u:"",y:"",b:1,f:1,p:1,v:1,c:2,g:2,j:2,k:2,q:2,s:2,x:2,z:2,"\u00df":2,d:3,t:3,l:4,m:5,n:5,r:6};var va={Exact:na,Default:qa,Normalize:qa,LatinBalance:{mapper:ra},LatinAdvanced:{mapper:ra,matcher:sa,replacer:ta},LatinExtra:{mapper:ra,replacer:ta.concat([/(?!^)[aeo]/g,""]),matcher:sa},LatinSoundex:{dedupe:!1,include:{letter:!0},finalize:function(a){for(let b=0;b<a.length;b++){var c=a[b];let e=c.charAt(0),d=ua[e];for(let f=1,g;f<c.length&&(g=c.charAt(f),"h"===g||"w"===g||!(g=ua[g])||g===d||(e+=g,d=g,4!==e.length));f++);a[b]=e}}},CJK:{split:""},LatinExact:na,LatinDefault:qa,LatinSimple:qa};function wa(a,c,b,e){let d=[];for(let f=0,g;f<a.index.length;f++)if(g=a.index[f],c>=g.length)c-=g.length;else{c=g[e?"splice":"slice"](c,b);const h=c.length;if(h&&(d=d.length?d.concat(c):c,b-=h,e&&(a.length-=h),!b))break;c=0}return d}
function za(a){if(!this||this.constructor!==za)return new za(a);this.index=a?[a]:[];this.length=a?a.length:0;const c=this;return new Proxy([],{get(b,e){if("length"===e)return c.length;if("push"===e)return function(d){c.index[c.index.length-1].push(d);c.length++};if("pop"===e)return function(){if(c.length)return c.length--,c.index[c.index.length-1].pop()};if("indexOf"===e)return function(d){let f=0;for(let g=0,h,k;g<c.index.length;g++){h=c.index[g];k=h.indexOf(d);if(0<=k)return f+k;f+=h.length}return-1};
if("includes"===e)return function(d){for(let f=0;f<c.index.length;f++)if(c.index[f].includes(d))return!0;return!1};if("slice"===e)return function(d,f){return wa(c,d||0,f||c.length,!1)};if("splice"===e)return function(d,f){return wa(c,d||0,f||c.length,!0)};if("constructor"===e)return Array;if("symbol"!==typeof e)return(b=c.index[e/2**31|0])&&b[e]},set(b,e,d){b=e/2**31|0;(c.index[b]||(c.index[b]=[]))[e]=d;c.length++;return!0}})}za.prototype.clear=function(){this.index.length=0};
za.prototype.destroy=function(){this.proxy=this.index=null};za.prototype.push=function(){};function S(a=8){if(!this||this.constructor!==S)return new S(a);this.index=I();this.h=[];this.size=0;32<a?(this.B=Aa,this.A=BigInt(a)):(this.B=Ba,this.A=a)}S.prototype.get=function(a){const c=this.index[this.B(a)];return c&&c.get(a)};S.prototype.set=function(a,c){var b=this.B(a);let e=this.index[b];e?(b=e.size,e.set(a,c),(b-=e.size)&&this.size++):(this.index[b]=e=new Map([[a,c]]),this.h.push(e),this.size++)};
function T(a=8){if(!this||this.constructor!==T)return new T(a);this.index=I();this.h=[];this.size=0;32<a?(this.B=Aa,this.A=BigInt(a)):(this.B=Ba,this.A=a)}T.prototype.add=function(a){var c=this.B(a);let b=this.index[c];b?(c=b.size,b.add(a),(c-=b.size)&&this.size++):(this.index[c]=b=new Set([a]),this.h.push(b),this.size++)};w=S.prototype;w.has=T.prototype.has=function(a){const c=this.index[this.B(a)];return c&&c.has(a)};
w.delete=T.prototype.delete=function(a){const c=this.index[this.B(a)];c&&c.delete(a)&&this.size--};w.clear=T.prototype.clear=function(){this.index=I();this.h=[];this.size=0};w.values=T.prototype.values=function*(){for(let a=0;a<this.h.length;a++)for(let c of this.h[a].values())yield c};w.keys=T.prototype.keys=function*(){for(let a=0;a<this.h.length;a++)for(let c of this.h[a].keys())yield c};w.entries=T.prototype.entries=function*(){for(let a=0;a<this.h.length;a++)for(let c of this.h[a].entries())yield c};
function Ba(a){let c=2**this.A-1;if("number"==typeof a)return a&c;let b=0,e=this.A+1;for(let d=0;d<a.length;d++)b=(b*e^a.charCodeAt(d))&c;return 32===this.A?b+2**31:b}function Aa(a){let c=BigInt(2)**this.A-BigInt(1);var b=typeof a;if("bigint"===b)return a&c;if("number"===b)return BigInt(a)&c;b=BigInt(0);let e=this.A+BigInt(1);for(let d=0;d<a.length;d++)b=(b*e^BigInt(a.charCodeAt(d)))&c;return b};let Ca,Da;
async function Ea(a){a=a.data;var c=a.task;const b=a.id;let e=a.args;switch(c){case "init":Da=a.options||{};(c=a.factory)?(Function("return "+c)()(self),Ca=new self.FlexSearch.Index(Da),delete self.FlexSearch):Ca=new V(Da);postMessage({id:b});break;default:let d;"export"===c&&(e[1]?(e[0]=Da.export,e[2]=0,e[3]=1):e=null);"import"===c?e[0]&&(a=await Da.import.call(Ca,e[0]),Ca.import(e[0],a)):((d=e&&Ca[c].apply(Ca,e))&&d.then&&(d=await d),d&&d.await&&(d=await d.await),"search"===c&&d.result&&(d=d.result));
postMessage("search"===c?{id:b,msg:d}:{id:b})}};function Fa(a){Ga.call(a,"add");Ga.call(a,"append");Ga.call(a,"search");Ga.call(a,"update");Ga.call(a,"remove");Ga.call(a,"searchCache")}let Ha,Ia,Ja;function Ka(){Ha=Ja=0}
function Ga(a){this[a+"Async"]=function(){const c=arguments;var b=c[c.length-1];let e;"function"===typeof b&&(e=b,delete c[c.length-1]);Ha?Ja||(Ja=Date.now()-Ia>=this.priority*this.priority*3):(Ha=setTimeout(Ka,0),Ia=Date.now());if(Ja){const f=this;return new Promise(g=>{setTimeout(function(){g(f[a+"Async"].apply(f,c))},0)})}const d=this[a].apply(this,c);b=d.then?d:new Promise(f=>f(d));e&&b.then(e);return b}};let W=0;
function La(a={},c){function b(h){function k(m){m=m.data||m;const l=m.id,n=l&&f.h[l];n&&(n(m.msg),delete f.h[l])}this.worker=h;this.h=I();if(this.worker){d?this.worker.on("message",k):this.worker.onmessage=k;if(a.config)return new Promise(function(m){1E9<W&&(W=0);f.h[++W]=function(){m(f)};f.worker.postMessage({id:W,task:"init",factory:e,options:a})});this.priority=a.priority||4;this.encoder=c||null;this.worker.postMessage({task:"init",factory:e,options:a});return this}}if(!this||this.constructor!==La)return new La(a);
let e="undefined"!==typeof self?self._factory:"undefined"!==typeof window?window._factory:null;e&&(e=e.toString());const d="undefined"===typeof window,f=this,g=Ma(e,d,a.worker);return g.then?g.then(function(h){return b.call(f,h)}):b.call(this,g)}X("add");X("append");X("search");X("update");X("remove");X("clear");X("export");X("import");La.prototype.searchCache=ma;Fa(La.prototype);
function X(a){La.prototype[a]=function(){const c=this,b=[].slice.call(arguments);var e=b[b.length-1];let d;"function"===typeof e&&(d=e,b.pop());e=new Promise(function(f){"export"===a&&"function"===typeof b[0]&&(b[0]=null);1E9<W&&(W=0);c.h[++W]=f;c.worker.postMessage({task:a,id:W,args:b})});return d?(e.then(d),this):e}}
function Ma(a,c,b){return c?"undefined"!==typeof module?new(require("worker_threads")["Worker"])(__dirname+"/worker/node.js"):import("worker_threads").then(function(worker){return new worker["Worker"](import.meta.dirname+"/node/node.mjs")}):a?new window.Worker(URL.createObjectURL(new Blob(["onmessage="+Ea.toString()],{type:"text/javascript"}))):new window.Worker("string"===typeof b?b:import.meta.url.replace("/worker.js","/worker/worker.js").replace("flexsearch.bundle.module.min.js",
"module/worker/worker.js"),{type:"module"})};Na.prototype.add=function(a,c,b){ba(a)&&(c=a,a=ea(c,this.key));if(c&&(a||0===a)){if(!b&&this.reg.has(a))return this.update(a,c);for(let h=0,k;h<this.field.length;h++){k=this.G[h];var e=this.index.get(this.field[h]);if("function"===typeof k){var d=k(c);d&&e.add(a,d,!1,!0)}else if(d=k.J,!d||d(c))k.constructor===String?k=[""+k]:M(k)&&(k=[k]),Qa(c,k,this.K,0,e,a,k[0],b)}if(this.tag)for(e=0;e<this.F.length;e++){var f=this.F[e];d=this.tag.get(this.P[e]);let h=I();if("function"===typeof f){if(f=f(c),!f)continue}else{var g=
f.J;if(g&&!g(c))continue;f.constructor===String&&(f=""+f);f=ea(c,f)}if(d&&f){M(f)&&(f=[f]);for(let k=0,m,l;k<f.length;k++)if(m=f[k],!h[m]&&(h[m]=1,(g=d.get(m))?l=g:d.set(m,l=[]),!b||!l.includes(a))){if(l.length===2**31-1){g=new za(l);if(this.fastupdate)for(let n of this.reg.values())n.includes(l)&&(n[n.indexOf(l)]=g);d.set(m,l=g)}l.push(a);this.fastupdate&&((g=this.reg.get(a))?g.push(l):this.reg.set(a,[l]))}}}if(this.store&&(!b||!this.store.has(a))){let h;if(this.D){h=I();for(let k=0,m;k<this.D.length;k++){m=
this.D[k];if((b=m.J)&&!b(c))continue;let l;if("function"===typeof m){l=m(c);if(!l)continue;m=[m.U]}else if(M(m)||m.constructor===String){h[m]=c[m];continue}Ra(c,h,m,0,m[0],l)}}this.store.set(a,h||c)}this.worker&&(this.fastupdate||this.reg.add(a))}return this};function Ra(a,c,b,e,d,f){a=a[d];if(e===b.length-1)c[d]=f||a;else if(a)if(a.constructor===Array)for(c=c[d]=Array(a.length),d=0;d<a.length;d++)Ra(a,c,b,e,d);else c=c[d]||(c[d]=I()),d=b[++e],Ra(a,c,b,e,d)}
function Qa(a,c,b,e,d,f,g,h){if(a=a[g])if(e===c.length-1){if(a.constructor===Array){if(b[e]){for(c=0;c<a.length;c++)d.add(f,a[c],!0,!0);return}a=a.join(" ")}d.add(f,a,h,!0)}else if(a.constructor===Array)for(g=0;g<a.length;g++)Qa(a,c,b,e,d,f,g,h);else g=c[++e],Qa(a,c,b,e,d,f,g,h);else d.db&&d.remove(f)};function Sa(a,c,b,e){if(!a.length)return a;if(1===a.length)return a=a[0],a=b||a.length>c?a.slice(b,b+c):a,e?Ta.call(this,a):a;let d=[];for(let f=0,g,h;f<a.length;f++)if((g=a[f])&&(h=g.length)){if(b){if(b>=h){b-=h;continue}g=g.slice(b,b+c);h=g.length;b=0}h>c&&(g=g.slice(0,c),h=c);if(!d.length&&h>=c)return e?Ta.call(this,g):g;d.push(g);c-=h;if(!c)break}d=1<d.length?[].concat.apply([],d):d[0];return e?Ta.call(this,d):d};function Ua(a,c,b,e){var d=e[0];if(d[0]&&d[0].query)return a[c].apply(a,d);if(!("and"!==c&&"not"!==c||a.result.length||a.await||d.suggest))return 1<e.length&&(d=e[e.length-1]),(e=d.resolve)?a.await||a.result:a;let f=[],g=0,h=0,k,m,l;var n;let u;for(c=0;c<e.length;c++)if(d=e[c]){var p=void 0;if(d.constructor===Y)p=d.await||d.result;else if(d.then||d.constructor===Array)p=d;else{g=d.limit||0;h=d.offset||0;l=d.suggest;m=d.resolve;k=(n=d.highlight&&m)||d.enrich&&m;p=d.queue;let t=d.async||p;(n=d.index)?
a.index||(a.index=n):n=a.index;if(d.query||d.tag){const r=d.field||d.pluck;r&&(n=a.index.index.get(r));if(p&&(u||a.await)){u=1;let q;const y=a.C.length,v=new Promise(function(A){q=A});(function(A,B){v.h=function(){B.index=null;B.resolve=!1;let E=t?A.searchAsync(B):A.search(B);if(E.then)return E.then(function(C){a.C[y]=C=C.result||C;q(C);return C});E=E.result||E;q(E);return E}})(n,Object.assign({},d));a.C.push(v);f[c]=v;continue}else d.resolve=!1,d.index=null,p=t?n.searchAsync(d):n.search(d),d.resolve=
m,d.index=n}else if(d.and)p=Va(d,"and",n);else if(d.or)p=Va(d,"or",n);else if(d.not)p=Va(d,"not",n);else if(d.xor)p=Va(d,"xor",n);else continue}p.await?(u=1,p=p.await):p.then?(u=1,p=p.then(function(t){return t.result||t})):p=p.result||p;f[c]=p}u&&!a.await&&(a.await=new Promise(function(t){a.return=t}));if(u){const t=Promise.all(f).then(function(r){for(let q=0;q<a.C.length;q++)if(a.C[q]===t){a.C[q]=function(){return b.call(a,r,g,h,k,m,l)};break}Wa(a)});a.C.push(t)}else if(a.await)a.C.push(function(){return b.call(a,
f,g,h,k,m,l)});else return b.call(a,f,g,h,k,m,l);return m?a.await||a.result:a}function Va(a,c,b){a=a[c];const e=a[0]||a;e.index||(e.index=b);b=new Y(e);1<a.length&&(b=b[c].apply(b,a.slice(1)));return b};Y.prototype.or=function(){return Ua(this,"or",Xa,arguments)};function Xa(a,c,b,e,d){a.length&&(this.result.length&&a.push(this.result),2>a.length?this.result=a[0]:(this.result=Ya(a,c,b,!1,this.h),b=0));d&&(this.await=null);return d?this.resolve(c,b,e):this};Y.prototype.and=function(){return Ua(this,"and",Za,arguments)};function Za(a,c,b,e,d,f){if(!f&&!this.result.length)return d?this.result:this;let g;if(a.length)if(this.result.length&&a.unshift(this.result),2>a.length)this.result=a[0];else{let h=0;for(let k=0,m,l;k<a.length;k++)if((m=a[k])&&(l=m.length))h<l&&(h=l);else if(!f){h=0;break}h?(this.result=$a(a,h,c,b,f,this.h,d),g=!0):this.result=[]}else f||(this.result=a);d&&(this.await=null);return d?this.resolve(c,b,e,g):this};Y.prototype.xor=function(){return Ua(this,"xor",ab,arguments)};
function ab(a,c,b,e,d,f){if(a.length)if(this.result.length&&a.unshift(this.result),2>a.length)this.result=a[0];else{a:{f=b;var g=this.h;const h=[],k=I();let m=0;for(let l=0,n;l<a.length;l++)if(n=a[l]){m<n.length&&(m=n.length);for(let u=0,p;u<n.length;u++)if(p=n[u])for(let t=0,r;t<p.length;t++)r=p[t],k[r]=k[r]?2:1}for(let l=0,n,u=0;l<m;l++)for(let p=0,t;p<a.length;p++)if(t=a[p])if(n=t[l])for(let r=0,q;r<n.length;r++)if(q=n[r],1===k[q])if(f)f--;else if(d){if(h.push(q),h.length===c){a=h;break a}}else{const y=
l+(p?g:0);h[y]||(h[y]=[]);h[y].push(q);if(++u===c){a=h;break a}}a=h}this.result=a;g=!0}else f||(this.result=a);d&&(this.await=null);return d?this.resolve(c,b,e,g):this};Y.prototype.not=function(){return Ua(this,"not",bb,arguments)};
function bb(a,c,b,e,d,f){if(!f&&!this.result.length)return d?this.result:this;if(a.length&&this.result.length){a:{f=b;var g=[];a=new Set(a.flat().flat());for(let h=0,k,m=0;h<this.result.length;h++)if(k=this.result[h])for(let l=0,n;l<k.length;l++)if(n=k[l],!a.has(n))if(f)f--;else if(d){if(g.push(n),g.length===c){a=g;break a}}else if(g[h]||(g[h]=[]),g[h].push(n),++m===c){a=g;break a}a=g}this.result=a;g=!0}d&&(this.await=null);return d?this.resolve(c,b,e,g):this};function Y(a,c){if(!this||this.constructor!==Y)return new Y(a,c);let b=0,e,d,f;if(a&&a.index){const g=a;c=g.index;b=g.boost||0;if(g.query){const h=g.resolve;a=g.async||g.queue;g.resolve=!1;g.index=null;a=a?c.searchAsync(g):c.search(g);g.resolve=h;g.index=c;a=a.result||a}else a=[]}if(a&&a.then){const g=this;a=a.then(function(h){g.C[0]=g.result=h.result||h;Wa(g)});e=[a];a=[];d=new Promise(function(h){f=h})}this.index=c||null;this.result=a||[];this.h=b;this.C=e||[];this.await=d||null;this.return=f||
null}Y.prototype.limit=function(a){if(this.result.length){const c=[];for(let b=0,e;b<this.result.length;b++)if(e=this.result[b])if(e.length<=a){if(c[b]=e,a-=e.length,!a)break}else{c[b]=e.slice(0,a);break}this.result=c}return this};Y.prototype.offset=function(a){if(this.result.length){const c=[];for(let b=0,e;b<this.result.length;b++)if(e=this.result[b])e.length<=a?a-=e.length:(c[b]=e.slice(a),a=0);this.result=c}return this};Y.prototype.boost=function(a){this.h+=a;return this};
function Wa(a,c){let b=a.result;for(let d=0,f;d<a.C.length;d++)if(f=a.C[d])if("function"===typeof f)b=f(),a.C[d]=b=b.result||b,d--;else if(f.h)b=f.h(),a.C[d]=b=b.result||b,d--;else if(f.then)return a.await;const e=a.return;a.result=b;a.C=[];a.await=null;a.return=null;c||e(b);return b}
Y.prototype.resolve=function(a,c,b,e){let d=this.await?Wa(this,!0):this.result;if(d.then){const f=this;return d.then(function(){return f.resolve(a,c,b)})}d.length&&("object"===typeof a&&(b=a.enrich,c=a.offset,a=a.limit),d=e?b?Ta.call(this.index,d):d:Sa.call(this.index,d,a||100,c,b));e=this.return;this.return=this.await=this.C=this.result=this.index=null;e&&e(d);return d};function $a(a,c,b,e,d,f,g){const h=a.length;let k=[],m,l;m=I();for(let n=0,u,p,t,r;n<c;n++)for(let q=0;q<h;q++)if(t=a[q],n<t.length&&(u=t[n]))for(let y=0;y<u.length;y++){p=u[y];(l=m[p])?m[p]++:(l=0,m[p]=1);r=k[l]||(k[l]=[]);if(!g){let v=n+(q||!d?0:f||0);r=r[v]||(r[v]=[])}r.push(p);if(g&&b&&l===h-1&&r.length-e===b)return e?r.slice(e):r}if(a=k.length)if(d)k=1<k.length?Ya(k,b,e,g,f):(k=k[0])&&b&&k.length>b||e?k.slice(e,b+e):k;else{if(a<h)return[];k=k[a-1];if(b||e)if(g){if(k.length>b||e)k=k.slice(e,b+
e)}else{d=[];for(let n=0,u;n<k.length;n++)if(u=k[n])if(e&&u.length>e)e-=u.length;else{if(b&&u.length>b||e)u=u.slice(e,b+e),b-=u.length,e&&(e-=u.length);d.push(u);if(!b)break}k=d}}return k}
function Ya(a,c,b,e,d){const f=[],g=I();let h;var k=a.length;let m;if(e)for(d=k-1;0<=d;d--){if(m=(e=a[d])&&e.length)for(k=0;k<m;k++)if(h=e[k],!g[h])if(g[h]=1,b)b--;else if(f.push(h),f.length===c)return f}else for(let l=k-1,n,u=0;0<=l;l--){n=a[l];for(let p=0;p<n.length;p++)if(m=(e=n[p])&&e.length)for(let t=0;t<m;t++)if(h=e[t],!g[h])if(g[h]=1,b)b--;else{let r=(p+(l<k-1?d||0:0))/(l+1)|0;(f[r]||(f[r]=[])).push(h);if(++u===c)return f}}return f}
function cb(a,c,b){const e=I(),d=[];for(let f=0,g;f<c.length;f++){g=c[f];for(let h=0;h<g.length;h++)e[g[h]]=1}if(b)for(let f=0,g;f<a.length;f++)g=a[f],e[g]&&(d.push(g),e[g]=0);else for(let f=0,g,h;f<a.result.length;f++)for(g=a.result[f],c=0;c<g.length;c++)h=g[c],e[h]&&((d[f]||(d[f]=[])).push(h),e[h]=0);return d};I();function eb(a,c,b,e,d){let f,g,h;"string"===typeof d?(f=d,d=""):f=d.template;g=f.indexOf("$1");h=f.substring(g+2);g=f.substring(0,g);let k=d&&d.boundary,m=!d||!1!==d.clip,l=d&&d.merge&&h&&g&&new RegExp(h+" "+g,"g");d=d&&d.ellipsis;var n=0;if("object"===typeof d){var u=d.template;n=u.length-2;d=d.pattern}"string"!==typeof d&&(d=!1===d?"":"...");n&&(d=u.replace("$1",d));u=d.length-n;let p,t;"object"===typeof k&&(p=k.before,0===p&&(p=-1),t=k.after,0===t&&(t=-1),k=k.total||9E5);n=new Map;for(let Oa=0,
da,db,oa;Oa<c.length;Oa++){let pa;if(e)pa=c,oa=e;else{var r=c[Oa];oa=r.field;if(!oa)continue;pa=r.result}db=b.get(oa);da=db.encoder;r=n.get(da);"string"!==typeof r&&(r=da.encode(a),n.set(da,r));for(let xa=0;xa<pa.length;xa++){var q=pa[xa].doc;if(!q)continue;q=ea(q,oa);if(!q)continue;var y=q.trim().split(/\s+/);if(!y.length)continue;q="";var v=[];let ya=[];var A=-1,B=-1,E=0;for(var C=0;C<y.length;C++){var F=y[C],z=da.encode(F);z=1<z.length?z.join(" "):z[0];let x;if(z&&F){var D=F.length,K=(da.split?
F.replace(da.split,""):F).length-z.length,G="",N=0;for(var O=0;O<r.length;O++){var P=r[O];if(P){var L=P.length;L+=K;N&&L<=N||(P=z.indexOf(P),-1<P&&(G=(P?F.substring(0,P):"")+g+F.substring(P,P+L)+h+(P+L<D?F.substring(P+L):""),N=L,x=!0))}}G&&(k&&(0>A&&(A=q.length+(q?1:0)),B=q.length+(q?1:0)+G.length,E+=D,ya.push(v.length),v.push({match:G})),q+=(q?" ":"")+G)}if(!x)F=y[C],q+=(q?" ":"")+F,k&&v.push({text:F});else if(k&&E>=k)break}E=ya.length*(f.length-2);if(p||t||k&&q.length-E>k)if(E=k+E-2*u,C=B-A,0<p&&
(C+=p),0<t&&(C+=t),C<=E)y=p?A-(0<p?p:0):A-((E-C)/2|0),v=t?B+(0<t?t:0):y+E,m||(0<y&&" "!==q.charAt(y)&&" "!==q.charAt(y-1)&&(y=q.indexOf(" ",y),0>y&&(y=0)),v<q.length&&" "!==q.charAt(v-1)&&" "!==q.charAt(v)&&(v=q.lastIndexOf(" ",v),v<B?v=B:++v)),q=(y?d:"")+q.substring(y,v)+(v<q.length?d:"");else{B=[];A={};E={};C={};F={};z={};G=K=D=0;for(O=N=1;;){var U=void 0;for(let x=0,J;x<ya.length;x++){J=ya[x];if(G)if(K!==G){if(C[x+1])continue;J+=G;if(A[J]){D-=u;E[x+1]=1;C[x+1]=1;continue}if(J>=v.length-1){if(J>=
v.length){C[x+1]=1;J>=y.length&&(E[x+1]=1);continue}D-=u}q=v[J].text;if(L=t&&z[x])if(0<L){if(q.length>L)if(C[x+1]=1,m)q=q.substring(0,L);else continue;(L-=q.length)||(L=-1);z[x]=L}else{C[x+1]=1;continue}if(D+q.length+1<=k)q=" "+q,B[x]+=q;else if(m)U=k-D-1,0<U&&(q=" "+q.substring(0,U),B[x]+=q),C[x+1]=1;else{C[x+1]=1;continue}}else{if(C[x])continue;J-=K;if(A[J]){D-=u;C[x]=1;E[x]=1;continue}if(0>=J){if(0>J){C[x]=1;E[x]=1;continue}D-=u}q=v[J].text;if(L=p&&F[x])if(0<L){if(q.length>L)if(C[x]=1,m)q=q.substring(q.length-
L);else continue;(L-=q.length)||(L=-1);F[x]=L}else{C[x]=1;continue}if(D+q.length+1<=k)q+=" ",B[x]=q+B[x];else if(m)U=q.length+1-(k-D),0<=U&&U<q.length&&(q=q.substring(U)+" ",B[x]=q+B[x]),C[x]=1;else{C[x]=1;continue}}else{q=v[J].match;p&&(F[x]=p);t&&(z[x]=t);x&&D++;let Pa;J?!x&&u&&(D+=u):(E[x]=1,C[x]=1);J>=y.length-1?Pa=1:J<v.length-1&&v[J+1].match?Pa=1:u&&(D+=u);D-=f.length-2;if(!x||D+q.length<=k)B[x]=q;else{U=N=O=E[x]=0;break}Pa&&(E[x+1]=1,C[x+1]=1)}D+=q.length;U=A[J]=1}if(U)K===G?G++:K++;else{K===
G?N=0:O=0;if(!N&&!O)break;N?(K++,G=K):G++}}q="";for(let x=0,J;x<B.length;x++)J=(x&&E[x]?" ":(x&&!d?" ":"")+d)+B[x],q+=J;d&&!E[B.length]&&(q+=d)}l&&(q=q.replace(l," "));pa[xa].highlight=q}if(e)break}return c};Na.prototype.search=function(a,c,b,e){b||(!c&&ba(a)?(b=a,a=""):ba(c)&&(b=c,c=0));let d=[];var f=[];let g;let h,k,m,l,n;let u=0,p=!0,t;if(b){b.constructor===Array&&(b={index:b});a=b.query||a;g=b.pluck;h=b.merge;m=b.boost;n=g||b.field||(n=b.index)&&(n.index?null:n);var r=this.tag&&b.tag;k=b.suggest;p=!1!==b.resolve;l=b.cache;t=p&&this.store&&b.highlight;var q=!!t||p&&this.store&&b.enrich;c=b.limit||c;var y=b.offset||0;c||(c=p?100:0);if(r&&(!this.db||!e)){r.constructor!==Array&&(r=[r]);var v=[];for(let F=
0,z;F<r.length;F++)if(z=r[F],z.field&&z.tag){var A=z.tag;if(A.constructor===Array)for(var B=0;B<A.length;B++)v.push(z.field,A[B]);else v.push(z.field,A)}else{A=Object.keys(z);for(let D=0,K,G;D<A.length;D++)if(K=A[D],G=z[K],G.constructor===Array)for(B=0;B<G.length;B++)v.push(K,G[B]);else v.push(K,G)}r=v;if(!a){f=[];if(v.length)for(r=0;r<v.length;r+=2){if(this.db){e=this.index.get(v[r]);if(!e)continue;f.push(e=e.db.tag(v[r+1],c,y,q))}else e=fb.call(this,v[r],v[r+1],c,y,q);d.push(p?{field:v[r],tag:v[r+
1],result:e}:[e])}if(f.length){const F=this;return Promise.all(f).then(function(z){for(let D=0;D<z.length;D++)p?d[D].result=z[D]:d[D]=z[D];return p?d:new Y(1<d.length?$a(d,1,0,0,k,m):d[0],F)})}return p?d:new Y(1<d.length?$a(d,1,0,0,k,m):d[0],this)}}p||g||!(n=n||this.field)||(M(n)?g=n:(n.constructor===Array&&1===n.length&&(n=n[0]),g=n.field||n.index));n&&n.constructor!==Array&&(n=[n])}n||(n=this.field);let E;v=(this.worker||this.db)&&!e&&[];for(let F=0,z,D,K;F<n.length;F++){D=n[F];if(this.db&&this.tag&&
!this.G[F])continue;let G;M(D)||(G=D,D=G.field,a=G.query||a,c=aa(G.limit,c),y=aa(G.offset,y),k=aa(G.suggest,k),t=p&&this.store&&aa(G.highlight,t),q=!!t||p&&this.store&&aa(G.enrich,q),l=aa(G.cache,l));if(e)z=e[F];else{A=G||b||{};B=A.enrich;var C=this.index.get(D);r&&(this.db&&(A.tag=r,E=C.db.support_tag_search,A.field=n),!E&&B&&(A.enrich=!1));z=l?C.searchCache(a,c,A):C.search(a,c,A);B&&(A.enrich=B);if(v){v[F]=z;continue}}K=(z=z.result||z)&&z.length;if(r&&K){A=[];B=0;if(this.db&&e){if(!E)for(C=n.length;C<
e.length;C++){let N=e[C];if(N&&N.length)B++,A.push(N);else if(!k)return p?d:new Y(d,this)}}else for(let N=0,O,P;N<r.length;N+=2){O=this.tag.get(r[N]);if(!O)if(k)continue;else return p?d:new Y(d,this);if(P=(O=O&&O.get(r[N+1]))&&O.length)B++,A.push(O);else if(!k)return p?d:new Y(d,this)}if(B){z=cb(z,A,p);K=z.length;if(!K&&!k)return p?z:new Y(z,this);B--}}if(K)f[u]=D,d.push(z),u++;else if(1===n.length)return p?d:new Y(d,this)}if(v){if(this.db&&r&&r.length&&!E)for(q=0;q<r.length;q+=2){f=this.index.get(r[q]);
if(!f)if(k)continue;else return p?d:new Y(d,this);v.push(f.db.tag(r[q+1],c,y,!1))}const F=this;return Promise.all(v).then(function(z){b&&(b.resolve=p);z.length&&(z=F.search(a,c,b,z));return z})}if(!u)return p?d:new Y(d,this);if(g&&(!q||!this.store))return d=d[0],p?d:new Y(d,this);v=[];for(y=0;y<f.length;y++){r=d[y];q&&r.length&&"undefined"===typeof r[0].doc&&(this.db?v.push(r=this.index.get(this.field[0]).db.enrich(r)):r=Ta.call(this,r));if(g)return p?t?eb(a,r,this.index,g,t):r:new Y(r,this);d[y]=
{field:f[y],result:r}}if(q&&this.db&&v.length){const F=this;return Promise.all(v).then(function(z){for(let D=0;D<z.length;D++)d[D].result=z[D];t&&(d=eb(a,d,F.index,g,t));return h?gb(d):d})}t&&(d=eb(a,d,this.index,g,t));return h?gb(d):d};
function gb(a){const c=[],b=I(),e=I();for(let d=0,f,g,h,k,m,l,n;d<a.length;d++){f=a[d];g=f.field;h=f.result;for(let u=0;u<h.length;u++)if(m=h[u],"object"!==typeof m?m={id:k=m}:k=m.id,(l=b[k])?l.push(g):(m.field=b[k]=[g],c.push(m)),n=m.highlight)l=e[k],l||(e[k]=l={},m.highlight=l),l[g]=n}return c}function fb(a,c,b,e,d){a=this.tag.get(a);if(!a)return[];a=a.get(c);if(!a)return[];c=a.length-e;if(0<c){if(b&&c>b||e)a=a.slice(e,e+b);d&&(a=Ta.call(this,a))}return a}
function Ta(a){if(!this||!this.store)return a;if(this.db)return this.index.get(this.field[0]).db.enrich(a);const c=Array(a.length);for(let b=0,e;b<a.length;b++)e=a[b],c[b]={id:e,doc:this.store.get(e)};return c};function Na(a){if(!this||this.constructor!==Na)return new Na(a);const c=a.document||a.doc||a;let b,e;this.G=[];this.field=[];this.K=[];this.key=(b=c.key||c.id)&&hb(b,this.K)||"id";(e=a.keystore||0)&&(this.keystore=e);this.fastupdate=!!a.fastupdate;this.reg=!this.fastupdate||a.worker||a.db?e?new T(e):new Set:e?new S(e):new Map;this.D=(b=c.store||null)&&b&&!0!==b&&[];this.store=b&&(e?new S(e):new Map);this.cache=(b=a.cache||null)&&new R(b);a.cache=!1;this.worker=a.worker||!1;this.priority=a.priority||
4;this.index=ib.call(this,a,c);this.tag=null;if(b=c.tag)if("string"===typeof b&&(b=[b]),b.length){this.tag=new Map;this.F=[];this.P=[];for(let d=0,f,g;d<b.length;d++){f=b[d];g=f.field||f;if(!g)throw Error("The tag field from the document descriptor is undefined.");f.custom?this.F[d]=f.custom:(this.F[d]=hb(g,this.K),f.filter&&("string"===typeof this.F[d]&&(this.F[d]=new String(this.F[d])),this.F[d].J=f.filter));this.P[d]=g;this.tag.set(g,new Map)}}if(this.worker){this.fastupdate=!1;a=[];for(const d of this.index.values())d.then&&
a.push(d);if(a.length){const d=this;return Promise.all(a).then(function(f){let g=0;for(const h of d.index.entries()){const k=h[0];let m=h[1];m.then&&(m=f[g],d.index.set(k,m),g++)}return d})}}else a.db&&(this.fastupdate=!1,this.mount(a.db))}w=Na.prototype;
w.mount=function(a){let c=this.field;if(this.tag)for(let f=0,g;f<this.P.length;f++){g=this.P[f];var b=void 0;this.index.set(g,b=new V({},this.reg));c===this.field&&(c=c.slice(0));c.push(g);b.tag=this.tag.get(g)}b=[];const e={db:a.db,type:a.type,fastupdate:a.fastupdate};for(let f=0,g,h;f<c.length;f++){e.field=h=c[f];g=this.index.get(h);const k=new a.constructor(a.id,e);k.id=a.id;b[f]=k.mount(g);g.document=!0;f?g.bypass=!0:g.store=this.store}const d=this;return this.db=Promise.all(b).then(function(){d.db=
!0})};w.commit=async function(a,c){const b=[];for(const e of this.index.values())b.push(e.commit(a,c));await Promise.all(b);this.reg.clear()};w.destroy=function(){const a=[];for(const c of this.index.values())a.push(c.destroy());return Promise.all(a)};
function ib(a,c){const b=new Map;let e=c.index||c.field||c;M(e)&&(e=[e]);for(let f=0,g,h;f<e.length;f++){g=e[f];M(g)||(h=g,g=g.field);h=ba(h)?Object.assign({},a,h):a;if(this.worker){var d=void 0;d=(d=h.encoder)&&d.encode?d:new la("string"===typeof d?va[d]:d||{});d=new La(h,d);b.set(g,d)}this.worker||b.set(g,new V(h,this.reg));h.custom?this.G[f]=h.custom:(this.G[f]=hb(g,this.K),h.filter&&("string"===typeof this.G[f]&&(this.G[f]=new String(this.G[f])),this.G[f].J=h.filter));this.field[f]=g}if(this.D){a=
c.store;M(a)&&(a=[a]);for(let f=0,g,h;f<a.length;f++)g=a[f],h=g.field||g,g.custom?(this.D[f]=g.custom,g.custom.U=h):(this.D[f]=hb(h,this.K),g.filter&&("string"===typeof this.D[f]&&(this.D[f]=new String(this.D[f])),this.D[f].J=g.filter))}return b}function hb(a,c){const b=a.split(":");let e=0;for(let d=0;d<b.length;d++)a=b[d],"]"===a[a.length-1]&&(a=a.substring(0,a.length-2))&&(c[e]=!0),a&&(b[e++]=a);e<b.length&&(b.length=e);return 1<e?b:b[0]}w.append=function(a,c){return this.add(a,c,!0)};
w.update=function(a,c){return this.remove(a).add(a,c)};w.remove=function(a){ba(a)&&(a=ea(a,this.key));for(var c of this.index.values())c.remove(a,!0);if(this.reg.has(a)){if(this.tag&&!this.fastupdate)for(let b of this.tag.values())for(let e of b){c=e[0];const d=e[1],f=d.indexOf(a);-1<f&&(1<d.length?d.splice(f,1):b.delete(c))}this.store&&this.store.delete(a);this.reg.delete(a)}this.cache&&this.cache.remove(a);return this};
w.clear=function(){const a=[];for(const c of this.index.values()){const b=c.clear();b.then&&a.push(b)}if(this.tag)for(const c of this.tag.values())c.clear();this.store&&this.store.clear();this.cache&&this.cache.clear();return a.length?Promise.all(a):this};w.contain=function(a){return this.db?this.index.get(this.field[0]).db.has(a):this.reg.has(a)};w.cleanup=function(){for(const a of this.index.values())a.cleanup();return this};
w.get=function(a){return this.db?this.index.get(this.field[0]).db.enrich(a).then(function(c){return c[0]&&c[0].doc||null}):this.store.get(a)||null};w.set=function(a,c){"object"===typeof a&&(c=a,a=ea(c,this.key));this.store.set(a,c);return this};w.searchCache=ma;w.export=jb;w.import=kb;Fa(Na.prototype);function lb(a,c=0){let b=[],e=[];c&&(c=25E4/c*5E3|0);for(const d of a.entries())e.push(d),e.length===c&&(b.push(e),e=[]);e.length&&b.push(e);return b}function mb(a,c){c||(c=new Map);for(let b=0,e;b<a.length;b++)e=a[b],c.set(e[0],e[1]);return c}function nb(a,c=0){let b=[],e=[];c&&(c=25E4/c*1E3|0);for(const d of a.entries())e.push([d[0],lb(d[1])[0]]),e.length===c&&(b.push(e),e=[]);e.length&&b.push(e);return b}
function ob(a,c){c||(c=new Map);for(let b=0,e,d;b<a.length;b++)e=a[b],d=c.get(e[0]),c.set(e[0],mb(e[1],d));return c}function pb(a){let c=[],b=[];for(const e of a.keys())b.push(e),25E4===b.length&&(c.push(b),b=[]);b.length&&c.push(b);return c}function qb(a,c){c||(c=new Set);for(let b=0;b<a.length;b++)c.add(a[b]);return c}
function rb(a,c,b,e,d,f,g=0){const h=e&&e.constructor===Array;var k=h?e.shift():e;if(!k)return this.export(a,c,d,f+1);if((k=a((c?c+".":"")+(g+1)+"."+b,JSON.stringify(k)))&&k.then){const m=this;return k.then(function(){return rb.call(m,a,c,b,h?e:null,d,f,g+1)})}return rb.call(this,a,c,b,h?e:null,d,f,g+1)}
function jb(a,c,b=0,e=0){if(b<this.field.length){const g=this.field[b];if((c=this.index.get(g).export(a,g,b,e=1))&&c.then){const h=this;return c.then(function(){return h.export(a,g,b+1)})}return this.export(a,g,b+1)}let d,f;switch(e){case 0:d="reg";f=pb(this.reg);c=null;break;case 1:d="tag";f=this.tag&&nb(this.tag,this.reg.size);c=null;break;case 2:d="doc";f=this.store&&lb(this.store);c=null;break;default:return}return rb.call(this,a,c,d,f,b,e)}
function kb(a,c){var b=a.split(".");"json"===b[b.length-1]&&b.pop();const e=2<b.length?b[0]:"";b=2<b.length?b[2]:b[1];if(this.worker&&e)return this.index.get(e).import(a);if(c){"string"===typeof c&&(c=JSON.parse(c));if(e)return this.index.get(e).import(b,c);switch(b){case "reg":this.fastupdate=!1;this.reg=qb(c,this.reg);for(let d=0,f;d<this.field.length;d++)f=this.index.get(this.field[d]),f.fastupdate=!1,f.reg=this.reg;if(this.worker){c=[];for(const d of this.index.values())c.push(d.import(a));return Promise.all(c)}break;
case "tag":this.tag=ob(c,this.tag);break;case "doc":this.store=mb(c,this.store)}}}function sb(a,c){let b="";for(const e of a.entries()){a=e[0];const d=e[1];let f="";for(let g=0,h;g<d.length;g++){h=d[g]||[""];let k="";for(let m=0;m<h.length;m++)k+=(k?",":"")+("string"===c?'"'+h[m]+'"':h[m]);k="["+k+"]";f+=(f?",":"")+k}f='["'+a+'",['+f+"]]";b+=(b?",":"")+f}return b};V.prototype.remove=function(a,c){const b=this.reg.size&&(this.fastupdate?this.reg.get(a):this.reg.has(a));if(b){if(this.fastupdate)for(let e=0,d,f;e<b.length;e++){if((d=b[e])&&(f=d.length))if(d[f-1]===a)d.pop();else{const g=d.indexOf(a);0<=g&&d.splice(g,1)}}else tb(this.map,a),this.depth&&tb(this.ctx,a);c||this.reg.delete(a)}this.db&&(this.commit_task.push({del:a}),this.S&&ub(this));this.cache&&this.cache.remove(a);return this};
function tb(a,c){let b=0;var e="undefined"===typeof c;if(a.constructor===Array)for(let d=0,f,g,h;d<a.length;d++){if((f=a[d])&&f.length){if(e)return 1;g=f.indexOf(c);if(0<=g){if(1<f.length)return f.splice(g,1),1;delete a[d];if(b)return 1;h=1}else{if(h)return 1;b++}}}else for(let d of a.entries())e=d[0],tb(d[1],c)?b++:a.delete(e);return b};const vb={memory:{resolution:1},performance:{resolution:3,fastupdate:!0,context:{depth:1,resolution:1}},match:{tokenize:"forward"},score:{resolution:9,context:{depth:2,resolution:3}}};V.prototype.add=function(a,c,b,e){if(c&&(a||0===a)){if(!e&&!b&&this.reg.has(a))return this.update(a,c);e=this.depth;c=this.encoder.encode(c,!e);const m=c.length;if(m){const l=I(),n=I(),u=this.resolution;for(let p=0;p<m;p++){let t=c[this.rtl?m-1-p:p];var d=t.length;if(d&&(e||!n[t])){var f=this.score?this.score(c,t,p,null,0):wb(u,m,p),g="";switch(this.tokenize){case "full":if(2<d){for(let r=0,q;r<d;r++)for(f=d;f>r;f--){g=t.substring(r,f);q=this.rtl?d-1-r:r;var h=this.score?this.score(c,t,p,g,q):wb(u,
m,p,d,q);xb(this,n,g,h,a,b)}break}case "bidirectional":case "reverse":if(1<d){for(h=d-1;0<h;h--){g=t[this.rtl?d-1-h:h]+g;var k=this.score?this.score(c,t,p,g,h):wb(u,m,p,d,h);xb(this,n,g,k,a,b)}g=""}case "forward":if(1<d){for(h=0;h<d;h++)g+=t[this.rtl?d-1-h:h],xb(this,n,g,f,a,b);break}default:if(xb(this,n,t,f,a,b),e&&1<m&&p<m-1)for(d=I(),g=this.T,f=t,h=Math.min(e+1,this.rtl?p+1:m-p),d[f]=1,k=1;k<h;k++)if((t=c[this.rtl?m-1-p-k:p+k])&&!d[t]){d[t]=1;const r=this.score?this.score(c,f,p,t,k-1):wb(g+(m/
2>g?0:1),m,p,h-1,k-1),q=this.bidirectional&&t>f;xb(this,l,q?f:t,r,a,b,q?t:f)}}}}this.fastupdate||this.reg.add(a)}else c=""}this.db&&(c||this.commit_task.push({del:a}),this.S&&ub(this));return this};
function xb(a,c,b,e,d,f,g){let h=g?a.ctx:a.map,k;if(!c[b]||g&&!(k=c[b])[g])if(g?(c=k||(c[b]=I()),c[g]=1,(k=h.get(g))?h=k:h.set(g,h=new Map)):c[b]=1,(k=h.get(b))?h=k:h.set(b,h=k=[]),h=h[e]||(h[e]=[]),!f||!h.includes(d)){if(h.length===2**31-1){c=new za(h);if(a.fastupdate)for(let m of a.reg.values())m.includes(h)&&(m[m.indexOf(h)]=c);k[e]=h=c}h.push(d);a.fastupdate&&((e=a.reg.get(d))?e.push(h):a.reg.set(d,[h]))}}
function wb(a,c,b,e,d){return b&&1<a?c+(e||0)<=a?b+(d||0):(a-1)/(c+(e||0))*(b+(d||0))+1|0:0};V.prototype.search=function(a,c,b){b||(c||"object"!==typeof a?"object"===typeof c&&(b=c,c=0):(b=a,a=""));if(b&&b.cache)return b.cache=!1,a=this.searchCache(a,c,b),b.cache=!0,a;let e=[],d,f,g,h=0,k,m,l,n,u;b&&(a=b.query||a,c=b.limit||c,h=b.offset||0,f=b.context,g=b.suggest,u=(k=b.resolve)&&b.enrich,l=b.boost,n=b.resolution,m=this.db&&b.tag);"undefined"===typeof k&&(k=this.resolve);f=this.depth&&!1!==f;let p=this.encoder.encode(a,!f);d=p.length;c=c||(k?100:0);if(1===d)return yb.call(this,p[0],"",c,
h,k,u,m);if(2===d&&f&&!g)return yb.call(this,p[1],p[0],c,h,k,u,m);let t=I(),r=0,q;f&&(q=p[0],r=1);n||0===n||(n=q?this.T:this.resolution);if(this.db){if(this.db.search&&(b=this.db.search(this,p,c,h,g,k,u,m),!1!==b))return b;const y=this;return async function(){for(let v,A;r<d;r++){if((A=p[r])&&!t[A]){t[A]=1;v=await zb(y,A,q,0,0,!1,!1);if(v=Ab(v,e,g,n)){e=v;break}q&&(g&&v&&e.length||(q=A))}g&&q&&r===d-1&&!e.length&&(n=y.resolution,q="",r=-1,t=I())}return Bb(e,n,c,h,g,l,k)}()}for(let y,v;r<d;r++){if((v=
p[r])&&!t[v]){t[v]=1;y=zb(this,v,q,0,0,!1,!1);if(y=Ab(y,e,g,n)){e=y;break}q&&(g&&y&&e.length||(q=v))}g&&q&&r===d-1&&!e.length&&(n=this.resolution,q="",r=-1,t=I())}return Bb(e,n,c,h,g,l,k)};function Bb(a,c,b,e,d,f,g){let h=a.length,k=a;if(1<h)k=$a(a,c,b,e,d,f,g);else if(1===h)return g?Sa.call(null,a[0],b,e):new Y(a[0],this);return g?k:new Y(k,this)}
function yb(a,c,b,e,d,f,g){a=zb(this,a,c,b,e,d,f,g);return this.db?a.then(function(h){return d?h||[]:new Y(h,this)}):a&&a.length?d?Sa.call(this,a,b,e):new Y(a,this):d?[]:new Y([],this)}function Ab(a,c,b,e){let d=[];if(a&&a.length){if(a.length<=e){c.push(a);return}for(let f=0,g;f<e;f++)if(g=a[f])d[f]=g;if(d.length){c.push(d);return}}if(!b)return d}
function zb(a,c,b,e,d,f,g,h){let k;b&&(k=a.bidirectional&&c>b)&&(k=b,b=c,c=k);if(a.db)return a.db.get(c,b,e,d,f,g,h);a=b?(a=a.ctx.get(b))&&a.get(c):a.map.get(c);return a};function V(a,c){if(!this||this.constructor!==V)return new V(a);if(a){var b=M(a)?a:a.preset;b&&(a=Object.assign({},vb[b],a))}else a={};b=a.context;const e=!0===b?{depth:1}:b||{},d=M(a.encoder)?va[a.encoder]:a.encode||a.encoder||{};this.encoder=d.encode?d:"object"===typeof d?new la(d):{encode:d};this.resolution=a.resolution||9;this.tokenize=b=(b=a.tokenize)&&"default"!==b&&"exact"!==b&&b||"strict";this.depth="strict"===b&&e.depth||0;this.bidirectional=!1!==e.bidirectional;this.fastupdate=!!a.fastupdate;
this.score=a.score||null;(b=a.keystore||0)&&(this.keystore=b);this.map=b?new S(b):new Map;this.ctx=b?new S(b):new Map;this.reg=c||(this.fastupdate?b?new S(b):new Map:b?new T(b):new Set);this.T=e.resolution||3;this.rtl=d.rtl||a.rtl||!1;this.cache=(b=a.cache||null)&&new R(b);this.resolve=!1!==a.resolve;if(b=a.db)this.db=this.mount(b);this.S=!1!==a.commit;this.commit_task=[];this.commit_timer=null;this.priority=a.priority||4}w=V.prototype;
w.mount=function(a){this.commit_timer&&(clearTimeout(this.commit_timer),this.commit_timer=null);return a.mount(this)};w.commit=function(a,c){this.commit_timer&&(clearTimeout(this.commit_timer),this.commit_timer=null);return this.db.commit(this,a,c)};w.destroy=function(){this.commit_timer&&(clearTimeout(this.commit_timer),this.commit_timer=null);return this.db.destroy()};function ub(a){a.commit_timer||(a.commit_timer=setTimeout(function(){a.commit_timer=null;a.db.commit(a,void 0,void 0)},1))}
w.clear=function(){this.map.clear();this.ctx.clear();this.reg.clear();this.cache&&this.cache.clear();this.db&&(this.commit_timer&&clearTimeout(this.commit_timer),this.commit_timer=null,this.commit_task=[{clear:!0}]);return this};w.append=function(a,c){return this.add(a,c,!0)};w.contain=function(a){return this.db?this.db.has(a):this.reg.has(a)};w.update=function(a,c){const b=this,e=this.remove(a);return e&&e.then?e.then(()=>b.add(a,c)):this.add(a,c)};
w.cleanup=function(){if(!this.fastupdate)return this;tb(this.map);this.depth&&tb(this.ctx);return this};w.searchCache=ma;w.export=function(a,c,b=0,e=0){let d,f;switch(e){case 0:d="reg";f=pb(this.reg);break;case 1:d="cfg";f=null;break;case 2:d="map";f=lb(this.map,this.reg.size);break;case 3:d="ctx";f=nb(this.ctx,this.reg.size);break;default:return}return rb.call(this,a,c,d,f,b,e)};
w.import=function(a,c){if(c)switch("string"===typeof c&&(c=JSON.parse(c)),a=a.split("."),"json"===a[a.length-1]&&a.pop(),3===a.length&&a.shift(),a=1<a.length?a[1]:a[0],a){case "reg":this.fastupdate=!1;this.reg=qb(c,this.reg);break;case "map":this.map=mb(c,this.map);break;case "ctx":this.ctx=ob(c,this.ctx)}};
w.serialize=function(a=!0){let c="",b="",e="";if(this.reg.size){let f;for(var d of this.reg.keys())f||(f=typeof d),c+=(c?",":"")+("string"===f?'"'+d+'"':d);c="index.reg=new Set(["+c+"]);";b=sb(this.map,f);b="index.map=new Map(["+b+"]);";for(const g of this.ctx.entries()){d=g[0];let h=sb(g[1],f);h="new Map(["+h+"])";h='["'+d+'",'+h+"]";e+=(e?",":"")+h}e="index.ctx=new Map(["+e+"]);"}return a?"function inject(index){"+c+b+e+"}":c+b+e};Fa(V.prototype);const Cb="undefined"!==typeof window&&(window.indexedDB||window.mozIndexedDB||window.webkitIndexedDB||window.msIndexedDB),Db=["map","ctx","tag","reg","cfg"],Eb=I();
function Fb(a,c={}){if(!this||this.constructor!==Fb)return new Fb(a,c);"object"===typeof a&&(c=a,a=a.name);a||console.info("Default storage space was used, because a name was not passed.");this.id="flexsearch"+(a?":"+a.toLowerCase().replace(/[^a-z0-9_\-]/g,""):"");this.field=c.field?c.field.toLowerCase().replace(/[^a-z0-9_\-]/g,""):"";this.type=c.type;this.fastupdate=this.support_tag_search=!1;this.db=null;this.h={}}w=Fb.prototype;w.mount=function(a){if(a.index)return a.mount(this);a.db=this;return this.open()};
w.open=function(){if(this.db)return this.db;let a=this;navigator.storage&&navigator.storage.persist();Eb[a.id]||(Eb[a.id]=[]);Eb[a.id].push(a.field);const c=Cb.open(a.id,1);c.onupgradeneeded=function(){const b=a.db=this.result;for(let e=0,d;e<Db.length;e++){d=Db[e];for(let f=0,g;f<Eb[a.id].length;f++)g=Eb[a.id][f],b.objectStoreNames.contains(d+("reg"!==d?g?":"+g:"":""))||b.createObjectStore(d+("reg"!==d?g?":"+g:"":""))}};return a.db=Z(c,function(b){a.db=b;a.db.onversionchange=function(){a.close()}})};
w.close=function(){this.db&&this.db.close();this.db=null};w.destroy=function(){const a=Cb.deleteDatabase(this.id);return Z(a)};w.clear=function(){const a=[];for(let b=0,e;b<Db.length;b++){e=Db[b];for(let d=0,f;d<Eb[this.id].length;d++)f=Eb[this.id][d],a.push(e+("reg"!==e?f?":"+f:"":""))}const c=this.db.transaction(a,"readwrite");for(let b=0;b<a.length;b++)c.objectStore(a[b]).clear();return Z(c)};
w.get=function(a,c,b=0,e=0,d=!0,f=!1){a=this.db.transaction((c?"ctx":"map")+(this.field?":"+this.field:""),"readonly").objectStore((c?"ctx":"map")+(this.field?":"+this.field:"")).get(c?c+":"+a:a);const g=this;return Z(a).then(function(h){let k=[];if(!h||!h.length)return k;if(d){if(!b&&!e&&1===h.length)return h[0];for(let m=0,l;m<h.length;m++)if((l=h[m])&&l.length){if(e>=l.length){e-=l.length;continue}const n=b?e+Math.min(l.length-e,b):l.length;for(let u=e;u<n;u++)k.push(l[u]);e=0;if(k.length===b)break}return f?
g.enrich(k):k}return h})};w.tag=function(a,c=0,b=0,e=!1){a=this.db.transaction("tag"+(this.field?":"+this.field:""),"readonly").objectStore("tag"+(this.field?":"+this.field:"")).get(a);const d=this;return Z(a).then(function(f){if(!f||!f.length||b>=f.length)return[];if(!c&&!b)return f;f=f.slice(b,b+c);return e?d.enrich(f):f})};
w.enrich=function(a){"object"!==typeof a&&(a=[a]);const c=this.db.transaction("reg","readonly").objectStore("reg"),b=[];for(let e=0;e<a.length;e++)b[e]=Z(c.get(a[e]));return Promise.all(b).then(function(e){for(let d=0;d<e.length;d++)e[d]={id:a[d],doc:e[d]?JSON.parse(e[d]):null};return e})};w.has=function(a){a=this.db.transaction("reg","readonly").objectStore("reg").getKey(a);return Z(a).then(function(c){return!!c})};w.search=null;w.info=function(){};
w.transaction=function(a,c,b){a+="reg"!==a?this.field?":"+this.field:"":"";let e=this.h[a+":"+c];if(e)return b.call(this,e);let d=this.db.transaction(a,c);this.h[a+":"+c]=e=d.objectStore(a);const f=b.call(this,e);this.h[a+":"+c]=null;return Z(d).finally(function(){d=e=null;return f})};
w.commit=async function(a,c,b){if(c)await this.clear(),a.commit_task=[];else{let e=a.commit_task;a.commit_task=[];for(let d=0,f;d<e.length;d++)if(f=e[d],f.clear){await this.clear();c=!0;break}else e[d]=f.del;c||(b||(e=e.concat(ca(a.reg))),e.length&&await this.remove(e))}a.reg.size&&(await this.transaction("map","readwrite",function(e){for(const d of a.map){const f=d[0],g=d[1];g.length&&(c?e.put(g,f):e.get(f).onsuccess=function(){let h=this.result;var k;if(h&&h.length){const m=Math.max(h.length,g.length);
for(let l=0,n,u;l<m;l++)if((u=g[l])&&u.length){if((n=h[l])&&n.length)for(k=0;k<u.length;k++)n.push(u[k]);else h[l]=u;k=1}}else h=g,k=1;k&&e.put(h,f)})}}),await this.transaction("ctx","readwrite",function(e){for(const d of a.ctx){const f=d[0],g=d[1];for(const h of g){const k=h[0],m=h[1];m.length&&(c?e.put(m,f+":"+k):e.get(f+":"+k).onsuccess=function(){let l=this.result;var n;if(l&&l.length){const u=Math.max(l.length,m.length);for(let p=0,t,r;p<u;p++)if((r=m[p])&&r.length){if((t=l[p])&&t.length)for(n=
0;n<r.length;n++)t.push(r[n]);else l[p]=r;n=1}}else l=m,n=1;n&&e.put(l,f+":"+k)})}}}),a.store?await this.transaction("reg","readwrite",function(e){for(const d of a.store){const f=d[0],g=d[1];e.put("object"===typeof g?JSON.stringify(g):1,f)}}):a.bypass||await this.transaction("reg","readwrite",function(e){for(const d of a.reg.keys())e.put(1,d)}),a.tag&&await this.transaction("tag","readwrite",function(e){for(const d of a.tag){const f=d[0],g=d[1];g.length&&(e.get(f).onsuccess=function(){let h=this.result;
h=h&&h.length?h.concat(g):g;e.put(h,f)})}}),a.map.clear(),a.ctx.clear(),a.tag&&a.tag.clear(),a.store&&a.store.clear(),a.document||a.reg.clear())};function Gb(a,c,b){const e=a.value;let d,f=0;for(let g=0,h;g<e.length;g++){if(h=b?e:e[g]){for(let k=0,m,l;k<c.length;k++)if(l=c[k],m=h.indexOf(l),0<=m)if(d=1,1<h.length)h.splice(m,1);else{e[g]=[];break}f+=h.length}if(b)break}f?d&&a.update(e):a.delete();a.continue()}
w.remove=function(a){"object"!==typeof a&&(a=[a]);return Promise.all([this.transaction("map","readwrite",function(c){c.openCursor().onsuccess=function(){const b=this.result;b&&Gb(b,a)}}),this.transaction("ctx","readwrite",function(c){c.openCursor().onsuccess=function(){const b=this.result;b&&Gb(b,a)}}),this.transaction("tag","readwrite",function(c){c.openCursor().onsuccess=function(){const b=this.result;b&&Gb(b,a,!0)}}),this.transaction("reg","readwrite",function(c){for(let b=0;b<a.length;b++)c.delete(a[b])})])};
function Z(a,c){return new Promise((b,e)=>{a.onsuccess=a.oncomplete=function(){c&&c(this.result);c=null;b(this.result)};a.onerror=a.onblocked=e;a=null})};export default {Index:V,Charset:va,Encoder:la,Document:Na,Worker:La,Resolver:Y,IndexedDB:Fb,Language:{}};
export const Index=V;export const Charset=va;export const Encoder=la;export const Document=Na;export const Worker=La;export const Resolver=Y;export const IndexedDB=Fb;export const Language={};
+153 -4
View File
@@ -1,3 +1,5 @@
import { settingValues } from './settings.js';
// --- Html String constants --- // --- Html String constants ---
/** /**
@@ -44,6 +46,15 @@ const REG_STRIP_TRAILING_PAREN = /(.*)(?<!\\)\)$/s;
const REG_WILDCARD_WEIGHTED_TAG = /(\d+)[_\s]*::(.*?)(?=\||$)/g; const REG_WILDCARD_WEIGHTED_TAG = /(\d+)[_\s]*::(.*?)(?=\||$)/g;
const REG_WILDCARD_SIMPLE_WORD = /[^{}_|]+/g; const REG_WILDCARD_SIMPLE_WORD = /[^{}_|]+/g;
/**
* Checks if the input text is longer than the maximum allowed tag length.
* @param {string} text The text to check.
* @return {boolean} True if the text is longer than the maximum tag length, false otherwise.
*/
export function isLongText(text) {
return text?.length > settingValues._maxTagLength;
}
/** /**
* Converts Hiragana to Katakana. * Converts Hiragana to Katakana.
* @param {string} str Input string. * @param {string} str Input string.
@@ -98,6 +109,30 @@ export function formatCountHumanReadable(num) {
return (num / si[i].value).toFixed(1).replace(rx, "$1") + si[i].symbol; return (num / si[i].value).toFixed(1).replace(rx, "$1") + si[i].symbol;
} }
/**
* Escapes HTML special characters in a string.
* @param {string} str The input string.
* @returns {string} The escaped string.
*/
export function escapeHtml(str) {
if (typeof str !== 'string') {
return str;
}
const escapeMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'`': '&#x60;',
'/': '&#x2F;'
};
return str.replace(/[&<>"'`/]/g, match =>
escapeMap[match]);
}
/** /**
* Escapes parentheses in a string for use in prompts. * Escapes parentheses in a string for use in prompts.
* Replaces '(' with '\(' and ')' with '\)'. * Replaces '(' with '\(' and ')' with '\)'.
@@ -188,23 +223,31 @@ export function normalizeTagToSearch(str) {
* Converts underscores to spaces only if the tag contains at least one letter or number, * Converts underscores to spaces only if the tag contains at least one letter or number,
* and is not a wildcard call (e.g., "__wildcard__"). * and is not a wildcard call (e.g., "__wildcard__").
* Keeps underscores for tags that are only symbols (e.g. "^_^") or wildcard calls. * Keeps underscores for tags that are only symbols (e.g. "^_^") or wildcard calls.
* For multi-tags (multiple tags with commas), parentheses are not escaped to preserve prompt weight syntax.
* @param {string} str * @param {string} str
* @returns {string} * @returns {string}
*/ */
export function normalizeTagToInsert(str) { export function normalizeTagToInsert(str) {
if (!str) return str; if (!str) return str;
// Check if multi-tag string
const isMultiTag = str.includes(',');
// Check if the string contains at least one letter or number
if (isContainsLetterOrNumber(str)) { if (isContainsLetterOrNumber(str)) {
const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4; const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4;
if (!isWildcardCall) { if (!isWildcardCall && settingValues.replaceUnderscoreWithSpace) {
// If doesn't wildcard call, replace underscores with spaces // If doesn't wildcard call and setting is enabled, replace underscores with spaces
return escapeParentheses(str.replace(/_/g, " ")); const result = str.replace(/_/g, " ");
// Only escape parentheses for single tags, not multi-tags
return isMultiTag ? result : escapeParentheses(result);
} }
} }
// Otherwise, keep it as is (e.g., ""^_^", "__wildcard__") // Otherwise, keep it as is (e.g., ""^_^", "__wildcard__")
return escapeParentheses(str); // Only escape parentheses for single tags, not multi-tags
return isMultiTag ? str : escapeParentheses(str);
} }
/** /**
@@ -515,6 +558,33 @@ export function getCurrentTagRange(text, cursorPos) {
return { start: adjustedStart, end: adjustedEnd, tag: adjustedTag }; return { start: adjustedStart, end: adjustedEnd, tag: adjustedTag };
} }
/**
* Adds weight to a lora tag if it doesn't already have one.
* @param {string} loraTag The model tag string (e.g., "<lora:my_style01>"
* @param {number} defaultWeight The default weight to add if not present
* @returns {string} The model tag with weight (e.g., "<lora:my_style01:1.0>")
*/
export function addWeightToLora(loraTag, defaultWeight = 1.0) {
if (!loraTag) return loraTag;
// Match LoRA format: <lora:name> or <lora:name:weight>
const loraMatch = loraTag.match(/^(<lora:[^>:]+)(:[0-9.]+)?>$/i);
if (loraMatch) {
const existingWeight = loraMatch[2];
// If weight already exists, return as-is
if (existingWeight) {
return loraTag;
}
// Add default weight, preserving original case
return `${loraMatch[1]}:${defaultWeight.toFixed(1)}>`;
}
// If it's not a recognized model format, return as-is
return loraTag;
}
// --- End String Helper Functions --- // --- End String Helper Functions ---
/** /**
@@ -549,3 +619,82 @@ export function getViewportMargin() {
right: rightBarRect.width, right: rightBarRect.width,
}; };
} }
// Cache for scrollbar width measurement
let _cachedScrollbarWidth;
/**
* Measures and returns the scrollbar width for the current browser.
* Creates a temporary element to measure the scrollbar width accurately.
* @returns {number} The scrollbar width in pixels
*/
export function getScrollbarWidth() {
// Return cached value if available
if (_cachedScrollbarWidth !== undefined) {
return _cachedScrollbarWidth;
}
// Create a temporary div with scrollbar
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.position = 'absolute';
outer.style.overflow = 'scroll';
outer.style.top = '-9999px';
outer.style.width = '100px';
document.body.appendChild(outer);
const inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
// Cache the result
_cachedScrollbarWidth = outer.offsetWidth - inner.offsetWidth;
document.body.removeChild(outer);
return _cachedScrollbarWidth;
}
/**
* Opens a wiki URL in a new browser tab for the given tag.
* @param {string} tagSource - The source of the tag ('danbooru', 'e621', 'embeddings', 'lora')
* @param {string} tagName - The name of the tag
* @returns {boolean} - True if URL was opened, false if not applicable
*/
export function openTagWikiUrl(tagSource, tagName) {
if (!tagSource || !tagName) {
console.warn('[Autocomplete-Plus] Cannot open wiki: missing tagSource or tagName');
return false;
}
let wikiUrl = null;
switch (tagSource) {
case 'danbooru':
const danbooruTag = encodeURIComponent(tagName.replace(/ /g, '_'));
wikiUrl = `https://danbooru.donmai.us/wiki_pages/${danbooruTag}`;
break;
case 'e621':
const e621Tag = encodeURIComponent(tagName.replace(/ /g, '_'));
wikiUrl = `https://e621.net/wiki_pages/${e621Tag}`;
break;
case 'embeddings':
case 'lora':
// console.debug('[Autocomplete-Plus] Model tags do not have wiki pages');
return false;
default:
console.warn(`[Autocomplete-Plus] Unknown tag source: ${tagSource}`);
return false;
}
if (wikiUrl) {
// console.debug(`[Autocomplete-Plus] Opening wiki URL: ${wikiUrl}`);
window.open(wikiUrl, '_blank', 'noopener,noreferrer');
return true;
}
return false;
}