I18N
I18N
This commit is contained in:
parent
e4afe5819b
commit
9183cc0c71
@ -6,6 +6,10 @@ import fcbh.cli_args as fcbh_cli
|
|||||||
fcbh_cli.parser.add_argument("--share", action='store_true', help="Set whether to share on Gradio.")
|
fcbh_cli.parser.add_argument("--share", action='store_true', help="Set whether to share on Gradio.")
|
||||||
fcbh_cli.parser.add_argument("--preset", type=str, default=None, help="Apply specified UI preset.")
|
fcbh_cli.parser.add_argument("--preset", type=str, default=None, help="Apply specified UI preset.")
|
||||||
|
|
||||||
|
fcbh_cli.parser.add_argument("--language", type=str, default=None,
|
||||||
|
help="Translate UI using json files in [language] folder. "
|
||||||
|
"For example, [--language example] will use [language/example.json] for translation.")
|
||||||
|
|
||||||
fcbh_cli.args = fcbh_cli.parser.parse_args()
|
fcbh_cli.args = fcbh_cli.parser.parse_args()
|
||||||
fcbh_cli.args.disable_cuda_malloc = True
|
fcbh_cli.args.disable_cuda_malloc = True
|
||||||
fcbh_cli.args.auto_launch = True
|
fcbh_cli.args.auto_launch = True
|
||||||
|
@ -1 +1 @@
|
|||||||
version = '2.1.718'
|
version = '2.1.719'
|
||||||
|
205
javascript/localization.js
Normal file
205
javascript/localization.js
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
|
||||||
|
// localization = {} -- the dict with translations is created by the backend
|
||||||
|
|
||||||
|
var ignore_ids_for_localization = {
|
||||||
|
setting_sd_hypernetwork: 'OPTION',
|
||||||
|
setting_sd_model_checkpoint: 'OPTION',
|
||||||
|
modelmerger_primary_model_name: 'OPTION',
|
||||||
|
modelmerger_secondary_model_name: 'OPTION',
|
||||||
|
modelmerger_tertiary_model_name: 'OPTION',
|
||||||
|
train_embedding: 'OPTION',
|
||||||
|
train_hypernetwork: 'OPTION',
|
||||||
|
txt2img_styles: 'OPTION',
|
||||||
|
img2img_styles: 'OPTION',
|
||||||
|
setting_random_artist_categories: 'OPTION',
|
||||||
|
setting_face_restoration_model: 'OPTION',
|
||||||
|
setting_realesrgan_enabled_models: 'OPTION',
|
||||||
|
extras_upscaler_1: 'OPTION',
|
||||||
|
extras_upscaler_2: 'OPTION',
|
||||||
|
};
|
||||||
|
|
||||||
|
var re_num = /^[.\d]+$/;
|
||||||
|
var re_emoji = /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u;
|
||||||
|
|
||||||
|
var original_lines = {};
|
||||||
|
var translated_lines = {};
|
||||||
|
|
||||||
|
function hasLocalization() {
|
||||||
|
return window.localization && Object.keys(window.localization).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textNodesUnder(el) {
|
||||||
|
var n, a = [], walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
while ((n = walk.nextNode())) a.push(n);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canBeTranslated(node, text) {
|
||||||
|
if (!text) return false;
|
||||||
|
if (!node.parentElement) return false;
|
||||||
|
|
||||||
|
var parentType = node.parentElement.nodeName;
|
||||||
|
if (parentType == 'SCRIPT' || parentType == 'STYLE' || parentType == 'TEXTAREA') return false;
|
||||||
|
|
||||||
|
if (parentType == 'OPTION' || parentType == 'SPAN') {
|
||||||
|
var pnode = node;
|
||||||
|
for (var level = 0; level < 4; level++) {
|
||||||
|
pnode = pnode.parentElement;
|
||||||
|
if (!pnode) break;
|
||||||
|
|
||||||
|
if (ignore_ids_for_localization[pnode.id] == parentType) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (re_num.test(text)) return false;
|
||||||
|
if (re_emoji.test(text)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTranslation(text) {
|
||||||
|
if (!text) return undefined;
|
||||||
|
|
||||||
|
if (translated_lines[text] === undefined) {
|
||||||
|
original_lines[text] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tl = localization[text];
|
||||||
|
if (tl !== undefined) {
|
||||||
|
translated_lines[tl] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processTextNode(node) {
|
||||||
|
var text = node.textContent.trim();
|
||||||
|
|
||||||
|
if (!canBeTranslated(node, text)) return;
|
||||||
|
|
||||||
|
var tl = getTranslation(text);
|
||||||
|
if (tl !== undefined) {
|
||||||
|
node.textContent = tl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processNode(node) {
|
||||||
|
if (node.nodeType == 3) {
|
||||||
|
processTextNode(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.title) {
|
||||||
|
let tl = getTranslation(node.title);
|
||||||
|
if (tl !== undefined) {
|
||||||
|
node.title = tl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.placeholder) {
|
||||||
|
let tl = getTranslation(node.placeholder);
|
||||||
|
if (tl !== undefined) {
|
||||||
|
node.placeholder = tl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textNodesUnder(node).forEach(function(node) {
|
||||||
|
processTextNode(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizeWholePage() {
|
||||||
|
processNode(gradioApp());
|
||||||
|
|
||||||
|
function elem(comp) {
|
||||||
|
var elem_id = comp.props.elem_id ? comp.props.elem_id : "component-" + comp.id;
|
||||||
|
return gradioApp().getElementById(elem_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var comp of window.gradio_config.components) {
|
||||||
|
if (comp.props.webui_tooltip) {
|
||||||
|
let e = elem(comp);
|
||||||
|
|
||||||
|
let tl = e ? getTranslation(e.title) : undefined;
|
||||||
|
if (tl !== undefined) {
|
||||||
|
e.title = tl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (comp.props.placeholder) {
|
||||||
|
let e = elem(comp);
|
||||||
|
let textbox = e ? e.querySelector('[placeholder]') : null;
|
||||||
|
|
||||||
|
let tl = textbox ? getTranslation(textbox.placeholder) : undefined;
|
||||||
|
if (tl !== undefined) {
|
||||||
|
textbox.placeholder = tl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpTranslations() {
|
||||||
|
if (!hasLocalization()) {
|
||||||
|
// If we don't have any localization,
|
||||||
|
// we will not have traversed the app to find
|
||||||
|
// original_lines, so do that now.
|
||||||
|
localizeWholePage();
|
||||||
|
}
|
||||||
|
var dumped = {};
|
||||||
|
if (localization.rtl) {
|
||||||
|
dumped.rtl = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const text in original_lines) {
|
||||||
|
if (dumped[text] !== undefined) continue;
|
||||||
|
dumped[text] = localization[text] || text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dumped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function download_localization() {
|
||||||
|
var text = JSON.stringify(dumpTranslations(), null, 4);
|
||||||
|
|
||||||
|
var element = document.createElement('a');
|
||||||
|
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
|
||||||
|
element.setAttribute('download', "localization.json");
|
||||||
|
element.style.display = 'none';
|
||||||
|
document.body.appendChild(element);
|
||||||
|
|
||||||
|
element.click();
|
||||||
|
|
||||||
|
document.body.removeChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
if (!hasLocalization()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUiUpdate(function(m) {
|
||||||
|
m.forEach(function(mutation) {
|
||||||
|
mutation.addedNodes.forEach(function(node) {
|
||||||
|
processNode(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
localizeWholePage();
|
||||||
|
|
||||||
|
if (localization.rtl) { // if the language is from right to left,
|
||||||
|
(new MutationObserver((mutations, observer) => { // wait for the style to load
|
||||||
|
mutations.forEach(mutation => {
|
||||||
|
mutation.addedNodes.forEach(node => {
|
||||||
|
if (node.tagName === 'STYLE') {
|
||||||
|
observer.disconnect();
|
||||||
|
|
||||||
|
for (const x of node.sheet.rules) { // find all rtl media rules
|
||||||
|
if (Array.from(x.media || []).includes('rtl')) {
|
||||||
|
x.media.appendMedium('all'); // enable them
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})).observe(gradioApp(), {childList: true});
|
||||||
|
}
|
||||||
|
});
|
@ -1,5 +1,4 @@
|
|||||||
// based on https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.6.0/script.js
|
// based on https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.6.0/script.js
|
||||||
|
|
||||||
function gradioApp() {
|
function gradioApp() {
|
||||||
const elems = document.getElementsByTagName('gradio-app');
|
const elems = document.getElementsByTagName('gradio-app');
|
||||||
const elem = elems.length == 0 ? document : elems[0];
|
const elem = elems.length == 0 ? document : elems[0];
|
||||||
@ -12,22 +11,162 @@ function gradioApp() {
|
|||||||
return elem.shadowRoot ? elem.shadowRoot : elem;
|
return elem.shadowRoot ? elem.shadowRoot : elem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function playNotification() {
|
/**
|
||||||
gradioApp().querySelector('#audio_notification audio')?.play();
|
* Get the currently selected top-level UI tab button (e.g. the button that says "Extras").
|
||||||
|
*/
|
||||||
|
function get_uiCurrentTab() {
|
||||||
|
return gradioApp().querySelector('#tabs > .tab-nav > button.selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
/**
|
||||||
var handled = false;
|
* Get the first currently visible top-level UI tab content (e.g. the div hosting the "txt2img" UI).
|
||||||
if (e.key !== undefined) {
|
*/
|
||||||
if ((e.key == "Enter" && (e.metaKey || e.ctrlKey || e.altKey))) handled = true;
|
function get_uiCurrentTabContent() {
|
||||||
} else if (e.keyCode !== undefined) {
|
return gradioApp().querySelector('#tabs > .tabitem[id^=tab_]:not([style*="display: none"])');
|
||||||
if ((e.keyCode == 13 && (e.metaKey || e.ctrlKey || e.altKey))) handled = true;
|
}
|
||||||
|
|
||||||
|
var uiUpdateCallbacks = [];
|
||||||
|
var uiAfterUpdateCallbacks = [];
|
||||||
|
var uiLoadedCallbacks = [];
|
||||||
|
var uiTabChangeCallbacks = [];
|
||||||
|
var optionsChangedCallbacks = [];
|
||||||
|
var uiAfterUpdateTimeout = null;
|
||||||
|
var uiCurrentTab = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback to be called at each UI update.
|
||||||
|
* The callback receives an array of MutationRecords as an argument.
|
||||||
|
*/
|
||||||
|
function onUiUpdate(callback) {
|
||||||
|
uiUpdateCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback to be called soon after UI updates.
|
||||||
|
* The callback receives no arguments.
|
||||||
|
*
|
||||||
|
* This is preferred over `onUiUpdate` if you don't need
|
||||||
|
* access to the MutationRecords, as your function will
|
||||||
|
* not be called quite as often.
|
||||||
|
*/
|
||||||
|
function onAfterUiUpdate(callback) {
|
||||||
|
uiAfterUpdateCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback to be called when the UI is loaded.
|
||||||
|
* The callback receives no arguments.
|
||||||
|
*/
|
||||||
|
function onUiLoaded(callback) {
|
||||||
|
uiLoadedCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback to be called when the UI tab is changed.
|
||||||
|
* The callback receives no arguments.
|
||||||
|
*/
|
||||||
|
function onUiTabChange(callback) {
|
||||||
|
uiTabChangeCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback to be called when the options are changed.
|
||||||
|
* The callback receives no arguments.
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
function onOptionsChanged(callback) {
|
||||||
|
optionsChangedCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeCallbacks(queue, arg) {
|
||||||
|
for (const callback of queue) {
|
||||||
|
try {
|
||||||
|
callback(arg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("error running callback", callback, ":", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (handled) {
|
}
|
||||||
var button = gradioApp().querySelector('button[id=generate_button]');
|
|
||||||
if (button) {
|
/**
|
||||||
button.click();
|
* Schedule the execution of the callbacks registered with onAfterUiUpdate.
|
||||||
|
* The callbacks are executed after a short while, unless another call to this function
|
||||||
|
* is made before that time. IOW, the callbacks are executed only once, even
|
||||||
|
* when there are multiple mutations observed.
|
||||||
|
*/
|
||||||
|
function scheduleAfterUiUpdateCallbacks() {
|
||||||
|
clearTimeout(uiAfterUpdateTimeout);
|
||||||
|
uiAfterUpdateTimeout = setTimeout(function() {
|
||||||
|
executeCallbacks(uiAfterUpdateCallbacks);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
var executedOnLoaded = false;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
var mutationObserver = new MutationObserver(function(m) {
|
||||||
|
if (!executedOnLoaded && gradioApp().querySelector('#txt2img_prompt')) {
|
||||||
|
executedOnLoaded = true;
|
||||||
|
executeCallbacks(uiLoadedCallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
executeCallbacks(uiUpdateCallbacks, m);
|
||||||
|
scheduleAfterUiUpdateCallbacks();
|
||||||
|
const newTab = get_uiCurrentTab();
|
||||||
|
if (newTab && (newTab !== uiCurrentTab)) {
|
||||||
|
uiCurrentTab = newTab;
|
||||||
|
executeCallbacks(uiTabChangeCallbacks);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mutationObserver.observe(gradioApp(), {childList: true, subtree: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a ctrl+enter as a shortcut to start a generation
|
||||||
|
*/
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
const isEnter = e.key === 'Enter' || e.keyCode === 13;
|
||||||
|
const isModifierKey = e.metaKey || e.ctrlKey || e.altKey;
|
||||||
|
|
||||||
|
const interruptButton = get_uiCurrentTabContent().querySelector('button[id$=_interrupt]');
|
||||||
|
const generateButton = get_uiCurrentTabContent().querySelector('button[id$=_generate]');
|
||||||
|
|
||||||
|
if (isEnter && isModifierKey) {
|
||||||
|
if (interruptButton.style.display === 'block') {
|
||||||
|
interruptButton.click();
|
||||||
|
setTimeout(function() {
|
||||||
|
generateButton.click();
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
generateButton.click();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checks that a UI element is not in another hidden element or tab content
|
||||||
|
*/
|
||||||
|
function uiElementIsVisible(el) {
|
||||||
|
if (el === document) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedStyle = getComputedStyle(el);
|
||||||
|
const isVisible = computedStyle.display !== 'none';
|
||||||
|
|
||||||
|
if (!isVisible) return false;
|
||||||
|
return uiElementIsVisible(el.parentNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiElementInSight(el) {
|
||||||
|
const clRect = el.getBoundingClientRect();
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const isOnScreen = clRect.bottom > 0 && clRect.top < windowHeight;
|
||||||
|
|
||||||
|
return isOnScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNotification() {
|
||||||
|
gradioApp().querySelector('#audio_notification audio')?.play();
|
||||||
|
}
|
||||||
|
6
language/example.json
Normal file
6
language/example.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"Generate": "生成",
|
||||||
|
"Input Image": "入力画像",
|
||||||
|
"Advanced": "고급",
|
||||||
|
"SAI 3D Model": "SAI 3D Modèle"
|
||||||
|
}
|
25
modules/localization.py
Normal file
25
modules/localization.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
localization_root = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'language')
|
||||||
|
|
||||||
|
|
||||||
|
def localization_js(filename):
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if isinstance(filename, str):
|
||||||
|
full_name = os.path.abspath(os.path.join(localization_root, filename + '.json'))
|
||||||
|
if os.path.exists(full_name):
|
||||||
|
try:
|
||||||
|
with open(full_name, encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
for k, v in data.items():
|
||||||
|
assert isinstance(k, str)
|
||||||
|
assert isinstance(v, str)
|
||||||
|
except Exception as e:
|
||||||
|
print(str(e))
|
||||||
|
print(f'Failed to load localization file {full_name}')
|
||||||
|
|
||||||
|
return f"window.localization = {json.dumps(data)}"
|
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import gradio as gr
|
import gradio as gr
|
||||||
|
import args_manager
|
||||||
|
|
||||||
|
from modules.localization import localization_js
|
||||||
|
|
||||||
|
|
||||||
GradioTemplateResponseOriginal = gr.routes.templates.TemplateResponse
|
GradioTemplateResponseOriginal = gr.routes.templates.TemplateResponse
|
||||||
|
|
||||||
@ -21,8 +25,11 @@ def webpath(fn):
|
|||||||
def javascript_html():
|
def javascript_html():
|
||||||
script_js_path = webpath('javascript/script.js')
|
script_js_path = webpath('javascript/script.js')
|
||||||
context_menus_js_path = webpath('javascript/contextMenus.js')
|
context_menus_js_path = webpath('javascript/contextMenus.js')
|
||||||
head = f'<script type="text/javascript" src="{script_js_path}"></script>\n'
|
localization_js_path = webpath('javascript/localization.js')
|
||||||
|
head = f'<script type="text/javascript">{localization_js(args_manager.args.language)}</script>\n'
|
||||||
|
head += f'<script type="text/javascript" src="{script_js_path}"></script>\n'
|
||||||
head += f'<script type="text/javascript" src="{context_menus_js_path}"></script>\n'
|
head += f'<script type="text/javascript" src="{context_menus_js_path}"></script>\n'
|
||||||
|
head += f'<script type="text/javascript" src="{localization_js_path}"></script>\n'
|
||||||
return head
|
return head
|
||||||
|
|
||||||
|
|
||||||
|
33
readme.md
33
readme.md
@ -296,3 +296,36 @@ Special thanks to [twri](https://github.com/twri) and [3Diva](https://github.com
|
|||||||
## Update Log
|
## Update Log
|
||||||
|
|
||||||
The log is [here](update_log.md).
|
The log is [here](update_log.md).
|
||||||
|
|
||||||
|
# Localization/Translation/I18N
|
||||||
|
|
||||||
|
**We need your help!** Please help with translating Fooocus to international languages.
|
||||||
|
|
||||||
|
You can put json files in the `language` folder to translate the user interface.
|
||||||
|
|
||||||
|
For example, below is the content of `Fooocus/language/example.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Generate": "生成",
|
||||||
|
"Input Image": "入力画像",
|
||||||
|
"Advanced": "고급",
|
||||||
|
"SAI 3D Model": "SAI 3D Modèle"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you add `--language example` arg, Fooocus will read `Fooocus/language/example.json` to translate the UI.
|
||||||
|
|
||||||
|
For example, you can edit the ending line of Windows `run.bat` as
|
||||||
|
|
||||||
|
.\python_embeded\python.exe -s Fooocus\entry_with_update.py --language example
|
||||||
|
|
||||||
|
Or `run_anime.bat` as
|
||||||
|
|
||||||
|
.\python_embeded\python.exe -s Fooocus\entry_with_update.py --language example --preset anime
|
||||||
|
|
||||||
|
Or `run_realistic.bat` as
|
||||||
|
|
||||||
|
.\python_embeded\python.exe -s Fooocus\entry_with_update.py --language example --preset realistic
|
||||||
|
|
||||||
|
For practical translation, you may create your own file like `Fooocus/language/jp.json` or `Fooocus/language/cn.json` and then use flag `--language jp` or `--language cn`. Apparently, these files do not exist now. **We need your help to create these files!**
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
# 2.1.719
|
||||||
|
|
||||||
|
* I18N
|
||||||
|
|
||||||
# 2.1.718
|
# 2.1.718
|
||||||
|
|
||||||
* Corrected handling dash in wildcard names, more wildcards (extended-color).
|
* Corrected handling dash in wildcard names, more wildcards (extended-color).
|
||||||
|
Loading…
Reference in New Issue
Block a user