Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65960a2394 | ||
|
|
92f01b84a0 | ||
|
|
3f8eb3bc0c | ||
|
|
fe42e198d2 | ||
|
|
98d9fdfc50 | ||
|
|
504b681ab8 | ||
|
|
aca0ed6326 | ||
|
|
ab941ab329 | ||
|
|
c4e408eede | ||
|
|
f8c86203e4 | ||
|
|
5a4ded44b8 | ||
|
|
295d0c0cdc | ||
|
|
37ce793208 | ||
|
|
2386fc7aa0 | ||
|
|
937c5bb283 | ||
|
|
512dd88d7a |
@@ -12,9 +12,9 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -118,11 +118,28 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
|
||||
>[!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.
|
||||
|
||||
### 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
|
||||
|
||||
### Tag Source
|
||||
|
||||
> [!TIP]
|
||||
> [!NOTE]
|
||||
> 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.
|
||||
@@ -133,6 +150,7 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
|
||||
|
||||
- **Enable Autocomplete**: Enable/disable the autocomplete feature.
|
||||
- **Max suggestions**: Maximum number of autocomplete suggestions to display.
|
||||
- **Replace '_' with 'Space'**: Replaces underscores with spaces when inserting tags. This setting also affects related tag display.
|
||||
- **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).
|
||||
|
||||
@@ -143,9 +161,9 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
|
||||
- **Default Display Position**: Default display position when ComfyUI starts.
|
||||
- **Related Tags Trigger Mode**: Which action will trigger displaying related tags (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)
|
||||
|
||||
## Advanced Settings
|
||||
|
||||
|
||||
+21
-3
@@ -116,11 +116,28 @@ worst_quality,5,9999999,
|
||||
>[!NOTE]
|
||||
> ユーザー 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等のタグデータの提供元を「タグソース」と呼びます
|
||||
|
||||
- **Autocomplete Tag Source**: オートコンプリート候補に表示するタグソース。「all」を選択するとロード済みの全てのタグソースを表示します
|
||||
@@ -131,6 +148,7 @@ worst_quality,5,9999999,
|
||||
|
||||
- **Enable Autocomplete**: オートコンプリート機能の有効化/無効化
|
||||
- **Max Suggestions**: オートコンプリート候補の最大表示件数
|
||||
- **Replace '_' with 'Space'**: タグ挿入時にアンダースコアをスペースに置き換えます。この設定は関連タグ表示にも影響します
|
||||
- **Enable Loras and Embeddings**: LoraとEmbeddingを候補に表示する
|
||||
- **Use Fast Search**: オートコンプリート候補の検索を高速な処理に切り替える(詳細は [オートコンプリートの高速検索について](#オートコンプリートの高速検索について) を確認してください)
|
||||
|
||||
@@ -141,9 +159,9 @@ worst_quality,5,9999999,
|
||||
- **Default Display Position**: ComfyUI起動時のデフォルト表示位置
|
||||
- **Related Tags Trigger Mode** : 関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック)
|
||||
|
||||
### その他
|
||||
### 表示
|
||||
|
||||
- **Check CSV updates**: 「Check Now」ボタンを押すと新しい CSV ファイルがHuggingFaceにあるか確認し、必要に応じてダウンロードを行います
|
||||
- **Hide Alias**: オートコンプリートと関連タグで表示されるエイリアス列の非表示/表示を切り替え(デフォルトは表示です)
|
||||
|
||||
## 上級者向け設定
|
||||
|
||||
|
||||
Generated
+1501
-739
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -6,9 +6,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-jest": "^30.2.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest": "^30.2.0",
|
||||
"stylelint": "^16.19.1",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard": "^38.0.0"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-autocomplete-plus"
|
||||
description = "Autocomplete and Related Tag display for ComfyUI"
|
||||
version = "1.5.0"
|
||||
version = "1.6.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["",]
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
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>, ');
|
||||
});
|
||||
|
||||
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, ');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { settingValues } from '../../web/js/settings.js';
|
||||
import {
|
||||
extractTagsFromTextArea,
|
||||
normalizeTagToSearch,
|
||||
@@ -135,6 +136,11 @@ describe('normalizeTagToSearch', () => {
|
||||
});
|
||||
|
||||
describe('normalizeTagToInsert', () => {
|
||||
const originalValue = settingValues.replaceUnderscoreWithSpace;
|
||||
afterEach(() => {
|
||||
settingValues.replaceUnderscoreWithSpace = originalValue;
|
||||
});
|
||||
|
||||
test('should return null or empty string for invalid inputs', () => {
|
||||
expect(normalizeTagToInsert(null)).toBeNull();
|
||||
expect(normalizeTagToInsert(undefined)).toBeUndefined();
|
||||
@@ -167,6 +173,73 @@ describe('normalizeTagToInsert', () => {
|
||||
expect(normalizeTagToInsert('year:2000')).toBe('year:2000');
|
||||
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', () => {
|
||||
|
||||
+33
-1
@@ -259,6 +259,10 @@ function searchWithFlexSearch(partialTag, queryVariations) {
|
||||
* @returns {string} The current partial tag.
|
||||
*/
|
||||
function getCurrentPartialTag(inputElement) {
|
||||
if (!inputElement) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const text = inputElement.value;
|
||||
const cursorPos = inputElement.selectionStart;
|
||||
|
||||
@@ -304,10 +308,25 @@ function getCurrentPartialTag(inputElement) {
|
||||
* @param {TagData} tagDataToInsert The raw tag string to insert.
|
||||
*/
|
||||
function insertTagToTextArea(inputElement, tagDataToInsert) {
|
||||
if (!inputElement || !tagDataToInsert) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = inputElement.value;
|
||||
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);
|
||||
let replaceEnd = cursorPos;
|
||||
|
||||
@@ -1025,3 +1044,16 @@ export class AutocompleteEventHandler {
|
||||
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;
|
||||
|
||||
+8
-12
@@ -184,18 +184,8 @@ async function loadTags(csvUrl, siteName) {
|
||||
updateMaxTagLength(tag.length);
|
||||
|
||||
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
|
||||
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)) {
|
||||
// Set the tag and its alias in the maps
|
||||
autoCompleteData[siteName].tagMap.set(tagData.tag, tagData);
|
||||
if (tagData.alias && Array.isArray(tagData.alias)) {
|
||||
tagData.alias.forEach(alias => {
|
||||
@@ -204,8 +194,11 @@ 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) {
|
||||
console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${csvUrl}:`, error);
|
||||
@@ -450,6 +443,9 @@ async function initializeDataFromCSV(csvListData, source) {
|
||||
await Promise.all([
|
||||
Promise.all(tagsLoadPromiseFactories.map(factory => factory()))
|
||||
.then(() => {
|
||||
// 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);
|
||||
})
|
||||
|
||||
@@ -282,6 +282,7 @@ app.registerExtension({
|
||||
settingValues.tagSource = newVal;
|
||||
}
|
||||
},
|
||||
|
||||
// --- Autocomplete Settings ---
|
||||
{
|
||||
id: id + ".use_fast_search",
|
||||
@@ -305,6 +306,17 @@ app.registerExtension({
|
||||
settingValues.enableModels = newVal;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: id + ".replace_underscore_with_space",
|
||||
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 + ".max_suggestions",
|
||||
name: "Max suggestions",
|
||||
|
||||
@@ -411,13 +411,8 @@ class RelatedTagsUI {
|
||||
.find((tagData) => tagData !== undefined);
|
||||
|
||||
if (!tagData) {
|
||||
tagData = new TagData({
|
||||
tag: this.currentTag,
|
||||
source: TagSource.Danbooru,
|
||||
category: 'unknown',
|
||||
count: 0,
|
||||
alias: [],
|
||||
});
|
||||
// Create a dummy TagData if not found
|
||||
tagData = new TagData(this.currentTag, null, 0, [], TagSource.Danbooru);
|
||||
}
|
||||
|
||||
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
|
||||
|
||||
@@ -14,7 +14,7 @@ function createTagEncoder() {
|
||||
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, and newlines
|
||||
split: /(?<=[a-zA-Z\)])_(?=[a-zA-Z\(])|\((?=[a-zA-Z])|(?<=[a-zA-Z\)])\)|[ \-\n]/ // Split on underscores between words, parentheses, spaces, hyphens, and newlines
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const settingValues = {
|
||||
maxSuggestions: 10,
|
||||
enableModels: true, // Enable Lora and Embedding suggestions
|
||||
useFastSearch: false,
|
||||
replaceUnderscoreWithSpace: true, // Replace underscores with spaces in tag insertion
|
||||
|
||||
// Related tags feature settings
|
||||
enableRelatedTags: true,
|
||||
|
||||
+13
-5
@@ -223,23 +223,31 @@ export function normalizeTagToSearch(str) {
|
||||
* Converts underscores to spaces only if the tag contains at least one letter or number,
|
||||
* and is not a wildcard call (e.g., "__wildcard__").
|
||||
* Keeps underscores for tags that are only symbols (e.g. "^_^") or wildcard calls.
|
||||
* @param {string} str
|
||||
* For multi-tags (multiple tags with commas), parentheses are not escaped to preserve prompt weight syntax.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeTagToInsert(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)) {
|
||||
const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4;
|
||||
|
||||
if (!isWildcardCall) {
|
||||
// If doesn't wildcard call, replace underscores with spaces
|
||||
return escapeParentheses(str.replace(/_/g, " "));
|
||||
if (!isWildcardCall && settingValues.replaceUnderscoreWithSpace) {
|
||||
// If doesn't wildcard call and setting is enabled, replace underscores with spaces
|
||||
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__")
|
||||
return escapeParentheses(str);
|
||||
// Only escape parentheses for single tags, not multi-tags
|
||||
return isMultiTag ? str : escapeParentheses(str);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user