mirror of
https://github.com/vladmandic/automatic
synced 2026-09-20 01:31:13 +02:00
en details
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
- **Extra networks**:
|
||||
- new details interface to view and save data about extra networks
|
||||
main ui now has a single button on each en to trigger details view
|
||||
details view includes model/lora metadata parser!
|
||||
- faster search, ability to show/hide/sort networks
|
||||
- refactored subfolder handling
|
||||
*note*: this will trigger model hash recaclulation on first model use
|
||||
@@ -29,6 +30,8 @@
|
||||
- better pipeline auto-detect when loading from safetensors
|
||||
- **Logging**
|
||||
- get browser session info in server log
|
||||
- when running with `--debug` flag, log is force-rotated
|
||||
so each `sdnext.log.*` represents exactly one server run
|
||||
|
||||
## Update for 2023-09-13
|
||||
|
||||
|
||||
+7
-1
@@ -20,6 +20,7 @@ class Dot(dict): # dot notation access to dictionary attributes
|
||||
|
||||
log = logging.getLogger("sd")
|
||||
log_file = os.path.join(os.path.dirname(__file__), 'sdnext.log')
|
||||
log_rolled = False
|
||||
quick_allowed = True
|
||||
errors = 0
|
||||
opts = {}
|
||||
@@ -90,7 +91,12 @@ def setup_logging():
|
||||
rh.setLevel(level)
|
||||
log.addHandler(rh)
|
||||
|
||||
fh = RotatingFileHandler(log_file, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8', delay=True) # 10MB default for log rotation
|
||||
fh = RotatingFileHandler(log_file, maxBytes=32*1024*1024, backupCount=9, encoding='utf-8', delay=True) # 10MB default for log rotation
|
||||
global log_rolled # pylint: disable=global-statement
|
||||
if not log_rolled and args.debug:
|
||||
fh.doRollover()
|
||||
log.debug(f'Logging: {log_file}')
|
||||
log_rolled = True
|
||||
fh.formatter = logging.Formatter('%(asctime)s | %(name)s | %(levelname)s | %(module)s | %(message)s')
|
||||
fh.setLevel(logging.DEBUG)
|
||||
log.addHandler(fh)
|
||||
|
||||
@@ -33,37 +33,12 @@ const setENState = (state) => {
|
||||
updateInput(el);
|
||||
};
|
||||
|
||||
// popup
|
||||
|
||||
let globalPopup = null;
|
||||
let globalPopupInner = null;
|
||||
|
||||
function popup(contents) {
|
||||
if (!globalPopup) {
|
||||
globalPopup = document.createElement('div');
|
||||
globalPopup.onclick = () => { globalPopup.style.display = 'none'; };
|
||||
globalPopup.classList.add('global-popup');
|
||||
const close = document.createElement('div');
|
||||
close.classList.add('global-popup-close');
|
||||
close.onclick = () => { globalPopup.style.display = 'none'; };
|
||||
close.title = 'Close';
|
||||
globalPopup.appendChild(close);
|
||||
globalPopupInner = document.createElement('div');
|
||||
globalPopupInner.onclick = (event) => { event.stopPropagation(); return false; };
|
||||
globalPopupInner.classList.add('global-popup-inner');
|
||||
globalPopup.appendChild(globalPopupInner);
|
||||
gradioApp().appendChild(globalPopup);
|
||||
}
|
||||
globalPopupInner.innerHTML = '';
|
||||
globalPopupInner.appendChild(contents);
|
||||
globalPopup.style.display = 'flex';
|
||||
}
|
||||
|
||||
// methods
|
||||
|
||||
function showCardDetails(event) {
|
||||
console.log('HERE1', event);
|
||||
const tabname = getENActiveTab();
|
||||
setENState({ op: 'showCardDetails' });
|
||||
// setENState({ op: 'showCardDetails' });
|
||||
const btn = gradioApp().getElementById(`${tabname}_extra_details_btn`);
|
||||
btn.click();
|
||||
event.stopPropagation();
|
||||
@@ -71,47 +46,14 @@ function showCardDetails(event) {
|
||||
}
|
||||
|
||||
function getCardDetails(...args) {
|
||||
const el = event?.target?.parentElement?.parentElement?.parentElement;
|
||||
console.log('HERE2', event);
|
||||
const el = event?.target?.parentElement?.parentElement;
|
||||
if (!el?.classList?.contains('card')) return [...args];
|
||||
const tabname = getENActiveTab();
|
||||
setENState({ op: 'getCardDetails', item: el.dataset.name });
|
||||
return [...args];
|
||||
}
|
||||
|
||||
function saveCardDescription(event) {
|
||||
const el = event?.target?.parentElement?.parentElement?.parentElement;
|
||||
if (!el?.classList?.contains('card')) return;
|
||||
const tabname = getENActiveTab();
|
||||
const description = gradioApp().getElementById(`${tabname}_description`)?.children[0].children[1].value;
|
||||
el.dataset.description = description;
|
||||
setENState({ op: 'saveCardDescription', item: el.dataset.name, description });
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function saveCardPreview(event) {
|
||||
const el = event?.target?.parentElement?.parentElement?.parentElement;
|
||||
if (!el?.classList?.contains('card')) return;
|
||||
const index = selected_gallery_index();
|
||||
setENState({ op: 'saveCardPreview', item: el.dataset.name, index });
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function readCardMetadata(event, extraPage, cardName) {
|
||||
requestGet('./sd_extra_networks/metadata', { page: extraPage, item: cardName }, (data) => {
|
||||
if (data?.metadata) {
|
||||
if (typeof (data?.metadata) !== 'string') data.metadata = JSON.stringify(data.metadata, null, 2);
|
||||
const elem = document.createElement('pre');
|
||||
elem.classList.add('popup-metadata');
|
||||
elem.textContent = data.metadata;
|
||||
popup(elem);
|
||||
}
|
||||
});
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function readCardTags(el, tags) {
|
||||
const clickTag = (e, tag) => {
|
||||
e.preventDefault();
|
||||
|
||||
+16
-8
@@ -252,14 +252,22 @@ table.settings-value-table td { padding: 0.4em; border: 1px solid #ccc; max-widt
|
||||
.extra-network-cards .card:hover .overlay { background: rgba(0, 0, 0, 0.40); }
|
||||
.extra-network-cards .card .overlay .tags { margin: 4px; display: none; overflow-wrap: break-word; }
|
||||
.extra-network-cards .card .overlay .tag { padding: 2px; margin: 2px; background: var(--neutral-700); cursor: pointer; display: inline-block; }
|
||||
.extra-network-cards .card .overlay .actions { font-size: 2.2em; display: none; text-align-last: center; cursor: pointer; font-variant: unicase; height: 0.8em }
|
||||
.extra-network-cards .card .overlay .actions > span { padding: 4px; }
|
||||
.extra-network-cards .card .overlay .description { line-break: anywhere; display: none; color: white; }
|
||||
.extra-network-cards .card:hover .overlay .actions { display: block; }
|
||||
.extra-network-cards .card .actions > span { padding: 4px; }
|
||||
.extra-network-cards .card:hover .actions { display: block; }
|
||||
.extra-network-cards .card:hover .overlay .tags { display: block; }
|
||||
.extra-network-cards .card:hover .overlay .description { display: block; }
|
||||
|
||||
|
||||
.extra-network-cards .card .actions {
|
||||
font-size: 3em;
|
||||
display: none;
|
||||
text-align-last: right;
|
||||
cursor: pointer;
|
||||
font-variant: unicase;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
right: 0;
|
||||
height: 0.7em;
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.40);
|
||||
}
|
||||
#txt2img_description, #img2img_description { max-height: 63px; overflow-y: auto !important; }
|
||||
#txt2img_description > label > textarea, #img2img_description > label > textarea { font-size: 0.9em }
|
||||
#txt2img_extra_details, #img2img_extra_details {
|
||||
@@ -273,7 +281,7 @@ table.settings-value-table td { padding: 0.4em; border: 1px solid #ccc; max-widt
|
||||
box-shadow: var(--button-shadow);
|
||||
}
|
||||
#txt2img_extra_details > div, #img2img_extra_details > div { overflow-y: auto; min-height: 40vh; max-height: 80vh; align-self: flex-start; }
|
||||
#txt2img_extra_details td:first-child, #img2img_extra_details td:first-child { font-weight: bold; }
|
||||
#txt2img_extra_details td:first-child, #img2img_extra_details td:first-child { font-weight: bold; vertical-align: top; }
|
||||
|
||||
/* controlnet
|
||||
.controlnet_control_type .controlnet_control_type_filter_group .wrap:last-of-type { display: grid; grid-auto-flow: row; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
|
||||
+122
-57
@@ -94,17 +94,15 @@ class ExtraNetworksPage:
|
||||
self.refresh_time = None
|
||||
# class additional is to keep old extensions happy
|
||||
self.card = '''
|
||||
<div class='card' onclick={card_click} title='{name}' data-name='{name}' data-filename='{local_preview}' data-description='{description}' data-tags='{tags}'>
|
||||
<div class='card' onclick={card_click} title='{name}' data-name='{name}' data-description='{description}' data-tags='{tags}'>
|
||||
<div class='overlay'>
|
||||
<span style="display:none" class='search_term'>{search_term}</span>
|
||||
<div class='name'>{title}</div>
|
||||
<div class='tags'></div>
|
||||
<div class='actions'>
|
||||
<div class='additional'><ul></ul></div>
|
||||
<span title="Save current image as preview image" onclick="saveCardPreview(event)">⏺️</span>
|
||||
<span title="Save current description" onclick="saveCardDescription(event)">🛅</span>
|
||||
<span title="Get details" onclick="showCardDetails(event)">🛈</span>
|
||||
</div>
|
||||
<div class='name'>{title}</div>
|
||||
</div>
|
||||
<div class='actions'>
|
||||
<span title="Get details" onclick="showCardDetails(event)">🛈</span>
|
||||
<div class='additional'><ul></ul></div>
|
||||
</div>
|
||||
<img class='preview' src='{preview}' style='width: {width}px; height: {height}px; object-fit: {fit}' loading='lazy'></img>
|
||||
</div>
|
||||
@@ -232,7 +230,6 @@ class ExtraNetworksPage:
|
||||
"prompt": item.get("prompt", None),
|
||||
"search_term": item.get("search_term", ""),
|
||||
"description": item.get("description") or "",
|
||||
"local_preview": item.get("local_preview"),
|
||||
"card_click": item.get("onclick", '"' + html.escape(f'return cardClicked({item.get("prompt", None)}, {"true" if self.allow_negative_prompt else "false"})') + '"'),
|
||||
}
|
||||
alias = item.get("alias", None)
|
||||
@@ -333,6 +330,7 @@ class ExtraNetworksUi:
|
||||
self.search: gr.Textbox = None
|
||||
self.button_details: gr.Button = None
|
||||
self.details_components: list = []
|
||||
self.last_item: dict = None
|
||||
|
||||
|
||||
def create_ui(container, button_parent, tabname, skip_indexing = False):
|
||||
@@ -347,7 +345,7 @@ def create_ui(container, button_parent, tabname, skip_indexing = False):
|
||||
state = {}
|
||||
|
||||
def get_item(state):
|
||||
if state is None or state.page is None or state.item is None:
|
||||
if state is None or not hasattr(state, 'page') or not hasattr(state, 'item'):
|
||||
return None, None
|
||||
page = next(iter([x for x in get_pages() if x.title == state.page]), None)
|
||||
if page is None:
|
||||
@@ -368,43 +366,11 @@ def create_ui(container, button_parent, tabname, skip_indexing = False):
|
||||
return
|
||||
page, item = get_item(state)
|
||||
shared.log.debug(f'Extra network: op={state.op} page={page.title if page is not None else None} item={item.filename if item is not None else None}')
|
||||
ui.last_item = item
|
||||
|
||||
if state.op == 'getCardDetails':
|
||||
pass
|
||||
|
||||
if state.op == 'saveCardDescription':
|
||||
if item is None or state.description is None or len(state.description) == 0:
|
||||
return
|
||||
try:
|
||||
filename = os.path.splitext(item.filename)[1] + '.txt'
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
f.write(state.description)
|
||||
shared.log.info(f'Extra network save: file="{filename}" description={state.description}')
|
||||
except Exception as e:
|
||||
shared.log.error(f'Extra network save: file="{filename}" {e}')
|
||||
|
||||
if state.op == 'saveCardPreview':
|
||||
if item is None or item.local_preview is None or len(item.local_preview) == 0:
|
||||
return
|
||||
try:
|
||||
images = list(ui.gallery.temp_files)
|
||||
if len(images) < 1:
|
||||
shared.log.error(f'Extra network save: preview="{item.local_preview}" no images')
|
||||
return
|
||||
image = Image.open(images[state.index])
|
||||
except Exception as e:
|
||||
shared.log.error(f'Extra network save: preview="{item.local_preview}" {e}')
|
||||
return
|
||||
if image.width > 512 or image.height > 512:
|
||||
image = image.convert('RGB')
|
||||
image.thumbnail((512, 512), Image.HAMMING)
|
||||
image.save(item.local_preview, quality=50)
|
||||
thumb = os.path.splitext(item.local_preview)[0] + '.thumb.jpg'
|
||||
if os.path.exists(thumb):
|
||||
shared.log.debug(f'Extra network delete: thumbnail="{thumb}"')
|
||||
os.remove(thumb)
|
||||
shared.log.info(f'Extra network save: preview="{item.local_preview}"')
|
||||
|
||||
def toggle_visibility(is_visible):
|
||||
is_visible = not is_visible
|
||||
return is_visible, gr.update(visible=is_visible), gr.update(variant=("secondary-down" if is_visible else "secondary"))
|
||||
@@ -420,21 +386,21 @@ def create_ui(container, button_parent, tabname, skip_indexing = False):
|
||||
img = gr.Image(value=None, show_label=False, interactive=False, container=True)
|
||||
ui.details_components.append(img)
|
||||
with gr.Row():
|
||||
gr.Button('Replace')
|
||||
gr.Button('Delete')
|
||||
btn_save_img = gr.Button('Replace', elem_classes=['small-button'])
|
||||
btn_delete_img = gr.Button('Delete', elem_classes=['small-button'])
|
||||
with gr.Tabs():
|
||||
with gr.Tab('Description'):
|
||||
desc = gr.Textbox('', show_label=False, lines=8)
|
||||
desc = gr.Textbox('', show_label=False, lines=8, placeholder="Extra network description...")
|
||||
ui.details_components.append(desc)
|
||||
with gr.Row():
|
||||
gr.Button('Save')
|
||||
gr.Button('Delete')
|
||||
btn_save_desc = gr.Button('Save', elem_classes=['small-button'])
|
||||
btn_delete_desc = gr.Button('Delete', elem_classes=['small-button'])
|
||||
with gr.Tab('Info'):
|
||||
info = gr.Textbox('', show_label=False, lines=8)
|
||||
info = gr.Textbox('', show_label=False, lines=8, placeholder="Extra network info...")
|
||||
ui.details_components.append(info)
|
||||
with gr.Row():
|
||||
gr.Button('Save')
|
||||
gr.Button('Delete')
|
||||
btn_save_info = gr.Button('Save', elem_classes=['small-button'])
|
||||
btn_delete_info = gr.Button('Delete', elem_classes=['small-button'])
|
||||
with gr.Tab('Metadata'):
|
||||
meta = gr.JSON({}, show_label=False, lines=8)
|
||||
ui.details_components.append(meta)
|
||||
@@ -443,7 +409,7 @@ def create_ui(container, button_parent, tabname, skip_indexing = False):
|
||||
button_refresh = ToolButton(symbols.refresh, elem_id=tabname+"_extra_refresh")
|
||||
button_close = ToolButton(symbols.close, elem_id=tabname+"_extra_close")
|
||||
ui.search = gr.Textbox('', show_label=False, elem_id=tabname+"_extra_search", placeholder="Search...", elem_classes="textbox", lines=2)
|
||||
ui.description = gr.Textbox('', show_label=False, elem_id=tabname+"_description", placeholder="Save/Replace Extra Network Description...", elem_classes="textbox", lines=2)
|
||||
ui.description = gr.Textbox('', show_label=False, elem_id=tabname+"_description", elem_classes="textbox", lines=2, interactive=False)
|
||||
|
||||
if ui.tabname == 'txt2img': # refresh only once
|
||||
global refresh_time # pylint: disable=global-statement
|
||||
@@ -454,26 +420,125 @@ def create_ui(container, button_parent, tabname, skip_indexing = False):
|
||||
hmtl = gr.HTML(page.html, elem_id=f'{tabname}{page.name}_extra_page', elem_classes="extra-networks-page")
|
||||
ui.pages.append(hmtl)
|
||||
|
||||
def fn_save_img(index):
|
||||
if index is None or index < 0 or ui.last_item is None or ui.last_item.local_preview is None:
|
||||
return 'html/card-no-preview.png'
|
||||
images = list(ui.gallery.temp_files)
|
||||
if len(images) < index + 1:
|
||||
shared.log.warning(f'Extra network no image: item={ui.last_item.name}')
|
||||
return 'html/card-no-preview.png'
|
||||
try:
|
||||
image = Image.open(images[index])
|
||||
except Exception as e:
|
||||
shared.log.error(f'Extra network error opening image: item={ui.last_item.name} {e}')
|
||||
return 'html/card-no-preview.png'
|
||||
fn_delete_img()
|
||||
if image.width > 512 or image.height > 512:
|
||||
image = image.convert('RGB')
|
||||
image.thumbnail((512, 512), Image.HAMMING)
|
||||
image.save(ui.last_item.local_preview, quality=50)
|
||||
shared.log.debug(f'Extra network save image: item={ui.last_item.name} filename={ui.last_item.local_preview}')
|
||||
return image
|
||||
|
||||
def fn_delete_img():
|
||||
preview_extensions = ["jpg", "jpeg", "png", "webp", "tiff", "jp2"]
|
||||
fn = os.path.splitext(ui.last_item.filename)[0]
|
||||
for file in [f'{fn}{mid}{ext}' for ext in preview_extensions for mid in ['.thumb.', '.preview.', '.']]:
|
||||
if os.path.exists(file):
|
||||
os.remove(file)
|
||||
shared.log.debug(f'Extra network delete image: item={ui.last_item.name} filename={file}')
|
||||
return 'html/card-no-preview.png'
|
||||
|
||||
def fn_save_desc(desc):
|
||||
fn = os.path.splitext(ui.last_item.filename)[0] + '.txt'
|
||||
with open(fn, 'w', encoding='utf-8') as f:
|
||||
f.write(desc)
|
||||
shared.log.debug(f'Extra network save desc: item={ui.last_item.name} filename={fn}')
|
||||
return desc
|
||||
|
||||
def fn_delete_desc(desc):
|
||||
if ui.last_item is None:
|
||||
return desc
|
||||
fn = os.path.splitext(ui.last_item.filename)[0] + '.txt'
|
||||
if os.path.exists(fn):
|
||||
shared.log.debug(f'Extra network delete desc: item={ui.last_item.name} filename={fn}')
|
||||
os.remove(fn)
|
||||
return ''
|
||||
return desc
|
||||
|
||||
def fn_save_info(info):
|
||||
fn = os.path.splitext(ui.last_item.filename)[0] + '.info'
|
||||
with open(fn, 'w', encoding='utf-8') as f:
|
||||
f.write(info)
|
||||
shared.log.debug(f'Extra network save desc: item={ui.last_item.name} filename={fn}')
|
||||
return info
|
||||
|
||||
def fn_delete_info(info):
|
||||
if ui.last_item is None:
|
||||
return info
|
||||
fn = os.path.splitext(ui.last_item.filename)[0] + '.info'
|
||||
if os.path.exists(fn):
|
||||
shared.log.debug(f'Extra network delete info: item={ui.last_item.name} filename={fn}')
|
||||
os.remove(fn)
|
||||
return ''
|
||||
return info
|
||||
|
||||
btn_save_img.click(fn=fn_save_img, _js='(img) => { return selected_gallery_index() }', inputs=[img], outputs=[img])
|
||||
btn_delete_img.click(fn=fn_delete_img, inputs=[], outputs=[img])
|
||||
btn_save_desc.click(fn=fn_save_desc, inputs=[desc], outputs=[desc])
|
||||
btn_delete_desc.click(fn=fn_delete_desc, inputs=[desc], outputs=[desc])
|
||||
btn_save_info.click(fn=fn_save_info, inputs=[info], outputs=[info])
|
||||
btn_delete_info.click(fn=fn_delete_info, inputs=[info], outputs=[info])
|
||||
|
||||
def show_details(text, img, desc, info, meta):
|
||||
page, item = get_item(state)
|
||||
if item is not None and os.path.exists(item.filename):
|
||||
stat = os.stat(item.filename)
|
||||
desc = item.description
|
||||
info = page.info.get(item.name, 'N/A')
|
||||
meta = page.metadata.get(item.name, {}) or {}
|
||||
if type(meta) is str:
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except:
|
||||
meta = {}
|
||||
img = page.find_preview_file(item.filename)
|
||||
lora = ''
|
||||
model = ''
|
||||
if page.title == 'Model':
|
||||
merge = len(list(meta.get('sd_merge_models', {})))
|
||||
if merge > 0:
|
||||
model += f'<tr><td>Merge models</td><td>{merge} recipes</td></tr>'
|
||||
if meta.get('modelspec.architecture', None) is not None:
|
||||
model += f'''
|
||||
<tr><td>Architecture</td><td>{meta.get('modelspec.architecture', 'N/A')}</td></tr>
|
||||
<tr><td>Title</td><td>{meta.get('modelspec.title', 'N/A')}</td></tr>
|
||||
<tr><td>Resolution</td><td>{meta.get('modelspec.resolution', 'N/A')}</td></tr>
|
||||
'''
|
||||
if page.title == 'Lora':
|
||||
tags = getattr(item, 'tags', {})
|
||||
tags = [f'{name}:{tags[name]}' for i, name in enumerate(tags)]
|
||||
tags = ' '.join(tags)
|
||||
lora = f'''
|
||||
<tr><td>Tags</td><td>{tags}</td></tr>
|
||||
<tr><td>Base model</td><td>{meta.get('ss_sd_model_name', 'N/A')}</td></tr>
|
||||
<tr><td>Resolution</td><td>{meta.get('ss_resolution', 'N/A')}</td></tr>
|
||||
<tr><td>Training images</td><td>{meta.get('ss_num_train_images', 'N/A')}</td></tr>
|
||||
<tr><td>Comment</td><td>{meta.get('ss_training_comment', 'N/A')}</td></tr>
|
||||
'''
|
||||
text = f'''
|
||||
<h2 style="border-bottom: 1px solid var(--button-primary-border-color); margin-bottom: 1em; margin-top: -1.3em !important;">{item.name}</h2>
|
||||
<table style="width: 100%; line-height: 1.3em;"><tbody>
|
||||
<tr><td>Type</td><td>{page.title}</td></tr>
|
||||
<tr><td>Alias</td><td>{getattr(item, 'alias', 'N/A')}</td></tr>
|
||||
<tr><td>Tags</td><td>{getattr(item, 'tags', 'N/A')}</td></tr>
|
||||
<tr><td>Filename</td><td>{item.filename}</td></tr>
|
||||
<tr><td>Hash</td><td>{getattr(item, 'hash', 'N/A')}</td></tr>
|
||||
<tr><td>Size</td><td>{round(stat.st_size/1024/1024, 2)} MB</td></tr>
|
||||
<tr><td>Last modified</td><td>{datetime.fromtimestamp(stat.st_mtime)}</td></tr>
|
||||
{lora}
|
||||
{model}
|
||||
</tbody></table>
|
||||
'''
|
||||
desc = item.description
|
||||
info = page.info.get(item.name, 'N/A')
|
||||
meta = page.metadata.get(item.name, 'N/A')
|
||||
img = page.find_preview_file(item.filename)
|
||||
return [text, img, desc, info, meta, gr.update(visible=True)]
|
||||
|
||||
def en_refresh(title):
|
||||
|
||||
+1
-1
Submodule wiki updated: 521f222bae...9dc03276db
Reference in New Issue
Block a user