Dr.Lt.Data
10 months ago
committed by
GitHub
71 changed files with 2522 additions and 599 deletions
@ -1,9 +0,0 @@
|
||||
{ |
||||
"path-intellisense.mappings": { |
||||
"../": "${workspaceFolder}/web/extensions/core" |
||||
}, |
||||
"[python]": { |
||||
"editor.defaultFormatter": "ms-python.autopep8" |
||||
}, |
||||
"python.formatting.provider": "none" |
||||
} |
@ -0,0 +1,54 @@
|
||||
import os |
||||
import json |
||||
from aiohttp import web |
||||
|
||||
|
||||
class AppSettings(): |
||||
def __init__(self, user_manager): |
||||
self.user_manager = user_manager |
||||
|
||||
def get_settings(self, request): |
||||
file = self.user_manager.get_request_user_filepath( |
||||
request, "comfy.settings.json") |
||||
if os.path.isfile(file): |
||||
with open(file) as f: |
||||
return json.load(f) |
||||
else: |
||||
return {} |
||||
|
||||
def save_settings(self, request, settings): |
||||
file = self.user_manager.get_request_user_filepath( |
||||
request, "comfy.settings.json") |
||||
with open(file, "w") as f: |
||||
f.write(json.dumps(settings, indent=4)) |
||||
|
||||
def add_routes(self, routes): |
||||
@routes.get("/settings") |
||||
async def get_settings(request): |
||||
return web.json_response(self.get_settings(request)) |
||||
|
||||
@routes.get("/settings/{id}") |
||||
async def get_setting(request): |
||||
value = None |
||||
settings = self.get_settings(request) |
||||
setting_id = request.match_info.get("id", None) |
||||
if setting_id and setting_id in settings: |
||||
value = settings[setting_id] |
||||
return web.json_response(value) |
||||
|
||||
@routes.post("/settings") |
||||
async def post_settings(request): |
||||
settings = self.get_settings(request) |
||||
new_settings = await request.json() |
||||
self.save_settings(request, {**settings, **new_settings}) |
||||
return web.Response(status=200) |
||||
|
||||
@routes.post("/settings/{id}") |
||||
async def post_setting(request): |
||||
setting_id = request.match_info.get("id", None) |
||||
if not setting_id: |
||||
return web.Response(status=400) |
||||
settings = self.get_settings(request) |
||||
settings[setting_id] = await request.json() |
||||
self.save_settings(request, settings) |
||||
return web.Response(status=200) |
@ -0,0 +1,140 @@
|
||||
import json |
||||
import os |
||||
import re |
||||
import uuid |
||||
from aiohttp import web |
||||
from comfy.cli_args import args |
||||
from folder_paths import user_directory |
||||
from .app_settings import AppSettings |
||||
|
||||
default_user = "default" |
||||
users_file = os.path.join(user_directory, "users.json") |
||||
|
||||
|
||||
class UserManager(): |
||||
def __init__(self): |
||||
global user_directory |
||||
|
||||
self.settings = AppSettings(self) |
||||
if not os.path.exists(user_directory): |
||||
os.mkdir(user_directory) |
||||
if not args.multi_user: |
||||
print("****** User settings have been changed to be stored on the server instead of browser storage. ******") |
||||
print("****** For multi-user setups add the --multi-user CLI argument to enable multiple user profiles. ******") |
||||
|
||||
if args.multi_user: |
||||
if os.path.isfile(users_file): |
||||
with open(users_file) as f: |
||||
self.users = json.load(f) |
||||
else: |
||||
self.users = {} |
||||
else: |
||||
self.users = {"default": "default"} |
||||
|
||||
def get_request_user_id(self, request): |
||||
user = "default" |
||||
if args.multi_user and "comfy-user" in request.headers: |
||||
user = request.headers["comfy-user"] |
||||
|
||||
if user not in self.users: |
||||
raise KeyError("Unknown user: " + user) |
||||
|
||||
return user |
||||
|
||||
def get_request_user_filepath(self, request, file, type="userdata", create_dir=True): |
||||
global user_directory |
||||
|
||||
if type == "userdata": |
||||
root_dir = user_directory |
||||
else: |
||||
raise KeyError("Unknown filepath type:" + type) |
||||
|
||||
user = self.get_request_user_id(request) |
||||
path = user_root = os.path.abspath(os.path.join(root_dir, user)) |
||||
|
||||
# prevent leaving /{type} |
||||
if os.path.commonpath((root_dir, user_root)) != root_dir: |
||||
return None |
||||
|
||||
parent = user_root |
||||
|
||||
if file is not None: |
||||
# prevent leaving /{type}/{user} |
||||
path = os.path.abspath(os.path.join(user_root, file)) |
||||
if os.path.commonpath((user_root, path)) != user_root: |
||||
return None |
||||
|
||||
if create_dir and not os.path.exists(parent): |
||||
os.mkdir(parent) |
||||
|
||||
return path |
||||
|
||||
def add_user(self, name): |
||||
name = name.strip() |
||||
if not name: |
||||
raise ValueError("username not provided") |
||||
user_id = re.sub("[^a-zA-Z0-9-_]+", '-', name) |
||||
user_id = user_id + "_" + str(uuid.uuid4()) |
||||
|
||||
self.users[user_id] = name |
||||
|
||||
global users_file |
||||
with open(users_file, "w") as f: |
||||
json.dump(self.users, f) |
||||
|
||||
return user_id |
||||
|
||||
def add_routes(self, routes): |
||||
self.settings.add_routes(routes) |
||||
|
||||
@routes.get("/users") |
||||
async def get_users(request): |
||||
if args.multi_user: |
||||
return web.json_response({"storage": "server", "users": self.users}) |
||||
else: |
||||
user_dir = self.get_request_user_filepath(request, None, create_dir=False) |
||||
return web.json_response({ |
||||
"storage": "server", |
||||
"migrated": os.path.exists(user_dir) |
||||
}) |
||||
|
||||
@routes.post("/users") |
||||
async def post_users(request): |
||||
body = await request.json() |
||||
username = body["username"] |
||||
if username in self.users.values(): |
||||
return web.json_response({"error": "Duplicate username."}, status=400) |
||||
|
||||
user_id = self.add_user(username) |
||||
return web.json_response(user_id) |
||||
|
||||
@routes.get("/userdata/{file}") |
||||
async def getuserdata(request): |
||||
file = request.match_info.get("file", None) |
||||
if not file: |
||||
return web.Response(status=400) |
||||
|
||||
path = self.get_request_user_filepath(request, file) |
||||
if not path: |
||||
return web.Response(status=403) |
||||
|
||||
if not os.path.exists(path): |
||||
return web.Response(status=404) |
||||
|
||||
return web.FileResponse(path) |
||||
|
||||
@routes.post("/userdata/{file}") |
||||
async def post_userdata(request): |
||||
file = request.match_info.get("file", None) |
||||
if not file: |
||||
return web.Response(status=400) |
||||
|
||||
path = self.get_request_user_filepath(request, file) |
||||
if not path: |
||||
return web.Response(status=403) |
||||
|
||||
body = await request.read() |
||||
with open(path, "wb") as f: |
||||
f.write(body) |
||||
|
||||
return web.Response(status=200) |
@ -0,0 +1,55 @@
|
||||
import torch |
||||
import comfy.model_management |
||||
import comfy.sample |
||||
import comfy.samplers |
||||
import comfy.utils |
||||
|
||||
|
||||
class PerpNeg: |
||||
@classmethod |
||||
def INPUT_TYPES(s): |
||||
return {"required": {"model": ("MODEL", ), |
||||
"empty_conditioning": ("CONDITIONING", ), |
||||
"neg_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0}), |
||||
}} |
||||
RETURN_TYPES = ("MODEL",) |
||||
FUNCTION = "patch" |
||||
|
||||
CATEGORY = "_for_testing" |
||||
|
||||
def patch(self, model, empty_conditioning, neg_scale): |
||||
m = model.clone() |
||||
nocond = comfy.sample.convert_cond(empty_conditioning) |
||||
|
||||
def cfg_function(args): |
||||
model = args["model"] |
||||
noise_pred_pos = args["cond_denoised"] |
||||
noise_pred_neg = args["uncond_denoised"] |
||||
cond_scale = args["cond_scale"] |
||||
x = args["input"] |
||||
sigma = args["sigma"] |
||||
model_options = args["model_options"] |
||||
nocond_processed = comfy.samplers.encode_model_conds(model.extra_conds, nocond, x, x.device, "negative") |
||||
|
||||
(noise_pred_nocond, _) = comfy.samplers.calc_cond_uncond_batch(model, nocond_processed, None, x, sigma, model_options) |
||||
|
||||
pos = noise_pred_pos - noise_pred_nocond |
||||
neg = noise_pred_neg - noise_pred_nocond |
||||
perp = ((torch.mul(pos, neg).sum())/(torch.norm(neg)**2)) * neg |
||||
perp_neg = perp * neg_scale |
||||
cfg_result = noise_pred_nocond + cond_scale*(pos - perp_neg) |
||||
cfg_result = x - cfg_result |
||||
return cfg_result |
||||
|
||||
m.set_model_sampler_cfg_function(cfg_function) |
||||
|
||||
return (m, ) |
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = { |
||||
"PerpNeg": PerpNeg, |
||||
} |
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = { |
||||
"PerpNeg": "Perp-Neg", |
||||
} |
@ -0,0 +1,47 @@
|
||||
import torch |
||||
import nodes |
||||
import comfy.utils |
||||
|
||||
class SD_4XUpscale_Conditioning: |
||||
@classmethod |
||||
def INPUT_TYPES(s): |
||||
return {"required": { "images": ("IMAGE",), |
||||
"positive": ("CONDITIONING",), |
||||
"negative": ("CONDITIONING",), |
||||
"scale_ratio": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 10.0, "step": 0.01}), |
||||
"noise_augmentation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}), |
||||
}} |
||||
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT") |
||||
RETURN_NAMES = ("positive", "negative", "latent") |
||||
|
||||
FUNCTION = "encode" |
||||
|
||||
CATEGORY = "conditioning/upscale_diffusion" |
||||
|
||||
def encode(self, images, positive, negative, scale_ratio, noise_augmentation): |
||||
width = max(1, round(images.shape[-2] * scale_ratio)) |
||||
height = max(1, round(images.shape[-3] * scale_ratio)) |
||||
|
||||
pixels = comfy.utils.common_upscale((images.movedim(-1,1) * 2.0) - 1.0, width // 4, height // 4, "bilinear", "center") |
||||
|
||||
out_cp = [] |
||||
out_cn = [] |
||||
|
||||
for t in positive: |
||||
n = [t[0], t[1].copy()] |
||||
n[1]['concat_image'] = pixels |
||||
n[1]['noise_augmentation'] = noise_augmentation |
||||
out_cp.append(n) |
||||
|
||||
for t in negative: |
||||
n = [t[0], t[1].copy()] |
||||
n[1]['concat_image'] = pixels |
||||
n[1]['noise_augmentation'] = noise_augmentation |
||||
out_cn.append(n) |
||||
|
||||
latent = torch.zeros([images.shape[0], 4, height // 4, width // 4]) |
||||
return (out_cp, out_cn, {"samples":latent}) |
||||
|
||||
NODE_CLASS_MAPPINGS = { |
||||
"SD_4XUpscale_Conditioning": SD_4XUpscale_Conditioning, |
||||
} |
@ -0,0 +1,102 @@
|
||||
import torch |
||||
import nodes |
||||
import comfy.utils |
||||
|
||||
def camera_embeddings(elevation, azimuth): |
||||
elevation = torch.as_tensor([elevation]) |
||||
azimuth = torch.as_tensor([azimuth]) |
||||
embeddings = torch.stack( |
||||
[ |
||||
torch.deg2rad( |
||||
(90 - elevation) - (90) |
||||
), # Zero123 polar is 90-elevation |
||||
torch.sin(torch.deg2rad(azimuth)), |
||||
torch.cos(torch.deg2rad(azimuth)), |
||||
torch.deg2rad( |
||||
90 - torch.full_like(elevation, 0) |
||||
), |
||||
], dim=-1).unsqueeze(1) |
||||
|
||||
return embeddings |
||||
|
||||
|
||||
class StableZero123_Conditioning: |
||||
@classmethod |
||||
def INPUT_TYPES(s): |
||||
return {"required": { "clip_vision": ("CLIP_VISION",), |
||||
"init_image": ("IMAGE",), |
||||
"vae": ("VAE",), |
||||
"width": ("INT", {"default": 256, "min": 16, "max": nodes.MAX_RESOLUTION, "step": 8}), |
||||
"height": ("INT", {"default": 256, "min": 16, "max": nodes.MAX_RESOLUTION, "step": 8}), |
||||
"batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}), |
||||
"elevation": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0}), |
||||
"azimuth": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0}), |
||||
}} |
||||
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT") |
||||
RETURN_NAMES = ("positive", "negative", "latent") |
||||
|
||||
FUNCTION = "encode" |
||||
|
||||
CATEGORY = "conditioning/3d_models" |
||||
|
||||
def encode(self, clip_vision, init_image, vae, width, height, batch_size, elevation, azimuth): |
||||
output = clip_vision.encode_image(init_image) |
||||
pooled = output.image_embeds.unsqueeze(0) |
||||
pixels = comfy.utils.common_upscale(init_image.movedim(-1,1), width, height, "bilinear", "center").movedim(1,-1) |
||||
encode_pixels = pixels[:,:,:,:3] |
||||
t = vae.encode(encode_pixels) |
||||
cam_embeds = camera_embeddings(elevation, azimuth) |
||||
cond = torch.cat([pooled, cam_embeds.repeat((pooled.shape[0], 1, 1))], dim=-1) |
||||
|
||||
positive = [[cond, {"concat_latent_image": t}]] |
||||
negative = [[torch.zeros_like(pooled), {"concat_latent_image": torch.zeros_like(t)}]] |
||||
latent = torch.zeros([batch_size, 4, height // 8, width // 8]) |
||||
return (positive, negative, {"samples":latent}) |
||||
|
||||
class StableZero123_Conditioning_Batched: |
||||
@classmethod |
||||
def INPUT_TYPES(s): |
||||
return {"required": { "clip_vision": ("CLIP_VISION",), |
||||
"init_image": ("IMAGE",), |
||||
"vae": ("VAE",), |
||||
"width": ("INT", {"default": 256, "min": 16, "max": nodes.MAX_RESOLUTION, "step": 8}), |
||||
"height": ("INT", {"default": 256, "min": 16, "max": nodes.MAX_RESOLUTION, "step": 8}), |
||||
"batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}), |
||||
"elevation": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0}), |
||||
"azimuth": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0}), |
||||
"elevation_batch_increment": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0}), |
||||
"azimuth_batch_increment": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0}), |
||||
}} |
||||
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT") |
||||
RETURN_NAMES = ("positive", "negative", "latent") |
||||
|
||||
FUNCTION = "encode" |
||||
|
||||
CATEGORY = "conditioning/3d_models" |
||||
|
||||
def encode(self, clip_vision, init_image, vae, width, height, batch_size, elevation, azimuth, elevation_batch_increment, azimuth_batch_increment): |
||||
output = clip_vision.encode_image(init_image) |
||||
pooled = output.image_embeds.unsqueeze(0) |
||||
pixels = comfy.utils.common_upscale(init_image.movedim(-1,1), width, height, "bilinear", "center").movedim(1,-1) |
||||
encode_pixels = pixels[:,:,:,:3] |
||||
t = vae.encode(encode_pixels) |
||||
|
||||
cam_embeds = [] |
||||
for i in range(batch_size): |
||||
cam_embeds.append(camera_embeddings(elevation, azimuth)) |
||||
elevation += elevation_batch_increment |
||||
azimuth += azimuth_batch_increment |
||||
|
||||
cam_embeds = torch.cat(cam_embeds, dim=0) |
||||
cond = torch.cat([comfy.utils.repeat_to_batch_size(pooled, batch_size), cam_embeds], dim=-1) |
||||
|
||||
positive = [[cond, {"concat_latent_image": t}]] |
||||
negative = [[torch.zeros_like(pooled), {"concat_latent_image": torch.zeros_like(t)}]] |
||||
latent = torch.zeros([batch_size, 4, height // 8, width // 8]) |
||||
return (positive, negative, {"samples":latent, "batch_index": [0] * batch_size}) |
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = { |
||||
"StableZero123_Conditioning": StableZero123_Conditioning, |
||||
"StableZero123_Conditioning_Batched": StableZero123_Conditioning_Batched, |
||||
} |
@ -0,0 +1,9 @@
|
||||
const { start } = require("./utils"); |
||||
const lg = require("./utils/litegraph"); |
||||
|
||||
// Load things once per test file before to ensure its all warmed up for the tests
|
||||
beforeAll(async () => { |
||||
lg.setup(global); |
||||
await start({ resetEnv: true }); |
||||
lg.teardown(global); |
||||
}); |
@ -1,3 +1,4 @@
|
||||
{ |
||||
"presets": ["@babel/preset-env"] |
||||
"presets": ["@babel/preset-env"], |
||||
"plugins": ["babel-plugin-transform-import-meta"] |
||||
} |
||||
|
@ -0,0 +1,295 @@
|
||||
// @ts-check
|
||||
/// <reference path="../node_modules/@types/jest/index.d.ts" />
|
||||
const { start } = require("../utils"); |
||||
const lg = require("../utils/litegraph"); |
||||
|
||||
describe("users", () => { |
||||
beforeEach(() => { |
||||
lg.setup(global); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
lg.teardown(global); |
||||
}); |
||||
|
||||
function expectNoUserScreen() { |
||||
// Ensure login isnt visible
|
||||
const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; |
||||
expect(selection["style"].display).toBe("none"); |
||||
const menu = document.querySelectorAll(".comfy-menu")?.[0]; |
||||
expect(window.getComputedStyle(menu)?.display).not.toBe("none"); |
||||
} |
||||
|
||||
describe("multi-user", () => { |
||||
function mockAddStylesheet() { |
||||
const utils = require("../../web/scripts/utils"); |
||||
utils.addStylesheet = jest.fn().mockReturnValue(Promise.resolve()); |
||||
} |
||||
|
||||
async function waitForUserScreenShow() { |
||||
mockAddStylesheet(); |
||||
|
||||
// Wait for "show" to be called
|
||||
const { UserSelectionScreen } = require("../../web/scripts/ui/userSelection"); |
||||
let resolve, reject; |
||||
const fn = UserSelectionScreen.prototype.show; |
||||
const p = new Promise((res, rej) => { |
||||
resolve = res; |
||||
reject = rej; |
||||
}); |
||||
jest.spyOn(UserSelectionScreen.prototype, "show").mockImplementation(async (...args) => { |
||||
const res = fn(...args); |
||||
await new Promise(process.nextTick); // wait for promises to resolve
|
||||
resolve(); |
||||
return res; |
||||
}); |
||||
// @ts-ignore
|
||||
setTimeout(() => reject("timeout waiting for UserSelectionScreen to be shown."), 500); |
||||
await p; |
||||
await new Promise(process.nextTick); // wait for promises to resolve
|
||||
} |
||||
|
||||
async function testUserScreen(onShown, users) { |
||||
if (!users) { |
||||
users = {}; |
||||
} |
||||
const starting = start({ |
||||
resetEnv: true, |
||||
userConfig: { storage: "server", users }, |
||||
}); |
||||
|
||||
// Ensure no current user
|
||||
expect(localStorage["Comfy.userId"]).toBeFalsy(); |
||||
expect(localStorage["Comfy.userName"]).toBeFalsy(); |
||||
|
||||
await waitForUserScreenShow(); |
||||
|
||||
const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; |
||||
expect(selection).toBeTruthy(); |
||||
|
||||
// Ensure login is visible
|
||||
expect(window.getComputedStyle(selection)?.display).not.toBe("none"); |
||||
// Ensure menu is hidden
|
||||
const menu = document.querySelectorAll(".comfy-menu")?.[0]; |
||||
expect(window.getComputedStyle(menu)?.display).toBe("none"); |
||||
|
||||
const isCreate = await onShown(selection); |
||||
|
||||
// Submit form
|
||||
selection.querySelectorAll("form")[0].submit(); |
||||
await new Promise(process.nextTick); // wait for promises to resolve
|
||||
|
||||
// Wait for start
|
||||
const s = await starting; |
||||
|
||||
// Ensure login is removed
|
||||
expect(document.querySelectorAll("#comfy-user-selection")).toHaveLength(0); |
||||
expect(window.getComputedStyle(menu)?.display).not.toBe("none"); |
||||
|
||||
// Ensure settings + templates are saved
|
||||
const { api } = require("../../web/scripts/api"); |
||||
expect(api.createUser).toHaveBeenCalledTimes(+isCreate); |
||||
expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate); |
||||
expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate); |
||||
if (isCreate) { |
||||
expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); |
||||
expect(s.app.isNewUserSession).toBeTruthy(); |
||||
} else { |
||||
expect(s.app.isNewUserSession).toBeFalsy(); |
||||
} |
||||
|
||||
return { users, selection, ...s }; |
||||
} |
||||
|
||||
it("allows user creation if no users", async () => { |
||||
const { users } = await testUserScreen((selection) => { |
||||
// Ensure we have no users flag added
|
||||
expect(selection.classList.contains("no-users")).toBeTruthy(); |
||||
|
||||
// Enter a username
|
||||
const input = selection.getElementsByTagName("input")[0]; |
||||
input.focus(); |
||||
input.value = "Test User"; |
||||
|
||||
return true; |
||||
}); |
||||
|
||||
expect(users).toStrictEqual({ |
||||
"Test User!": "Test User", |
||||
}); |
||||
|
||||
expect(localStorage["Comfy.userId"]).toBe("Test User!"); |
||||
expect(localStorage["Comfy.userName"]).toBe("Test User"); |
||||
}); |
||||
it("allows user creation if no current user but other users", async () => { |
||||
const users = { |
||||
"Test User 2!": "Test User 2", |
||||
}; |
||||
|
||||
await testUserScreen((selection) => { |
||||
expect(selection.classList.contains("no-users")).toBeFalsy(); |
||||
|
||||
// Enter a username
|
||||
const input = selection.getElementsByTagName("input")[0]; |
||||
input.focus(); |
||||
input.value = "Test User 3"; |
||||
return true; |
||||
}, users); |
||||
|
||||
expect(users).toStrictEqual({ |
||||
"Test User 2!": "Test User 2", |
||||
"Test User 3!": "Test User 3", |
||||
}); |
||||
|
||||
expect(localStorage["Comfy.userId"]).toBe("Test User 3!"); |
||||
expect(localStorage["Comfy.userName"]).toBe("Test User 3"); |
||||
}); |
||||
it("allows user selection if no current user but other users", async () => { |
||||
const users = { |
||||
"A!": "A", |
||||
"B!": "B", |
||||
"C!": "C", |
||||
}; |
||||
|
||||
await testUserScreen((selection) => { |
||||
expect(selection.classList.contains("no-users")).toBeFalsy(); |
||||
|
||||
// Check user list
|
||||
const select = selection.getElementsByTagName("select")[0]; |
||||
const options = select.getElementsByTagName("option"); |
||||
expect( |
||||
[...options] |
||||
.filter((o) => !o.disabled) |
||||
.reduce((p, n) => { |
||||
p[n.getAttribute("value")] = n.textContent; |
||||
return p; |
||||
}, {}) |
||||
).toStrictEqual(users); |
||||
|
||||
// Select an option
|
||||
select.focus(); |
||||
select.value = options[2].value; |
||||
|
||||
return false; |
||||
}, users); |
||||
|
||||
expect(users).toStrictEqual(users); |
||||
|
||||
expect(localStorage["Comfy.userId"]).toBe("B!"); |
||||
expect(localStorage["Comfy.userName"]).toBe("B"); |
||||
}); |
||||
it("doesnt show user screen if current user", async () => { |
||||
const starting = start({ |
||||
resetEnv: true, |
||||
userConfig: { |
||||
storage: "server", |
||||
users: { |
||||
"User!": "User", |
||||
}, |
||||
}, |
||||
localStorage: { |
||||
"Comfy.userId": "User!", |
||||
"Comfy.userName": "User", |
||||
}, |
||||
}); |
||||
await new Promise(process.nextTick); // wait for promises to resolve
|
||||
|
||||
expectNoUserScreen(); |
||||
|
||||
await starting; |
||||
}); |
||||
it("allows user switching", async () => { |
||||
const { app } = await start({ |
||||
resetEnv: true, |
||||
userConfig: { |
||||
storage: "server", |
||||
users: { |
||||
"User!": "User", |
||||
}, |
||||
}, |
||||
localStorage: { |
||||
"Comfy.userId": "User!", |
||||
"Comfy.userName": "User", |
||||
}, |
||||
}); |
||||
|
||||
// cant actually test switching user easily but can check the setting is present
|
||||
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeTruthy(); |
||||
}); |
||||
}); |
||||
describe("single-user", () => { |
||||
it("doesnt show user creation if no default user", async () => { |
||||
const { app } = await start({ |
||||
resetEnv: true, |
||||
userConfig: { migrated: false, storage: "server" }, |
||||
}); |
||||
expectNoUserScreen(); |
||||
|
||||
// It should store the settings
|
||||
const { api } = require("../../web/scripts/api"); |
||||
expect(api.storeSettings).toHaveBeenCalledTimes(1); |
||||
expect(api.storeUserData).toHaveBeenCalledTimes(1); |
||||
expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); |
||||
expect(app.isNewUserSession).toBeTruthy(); |
||||
}); |
||||
it("doesnt show user creation if default user", async () => { |
||||
const { app } = await start({ |
||||
resetEnv: true, |
||||
userConfig: { migrated: true, storage: "server" }, |
||||
}); |
||||
expectNoUserScreen(); |
||||
|
||||
// It should store the settings
|
||||
const { api } = require("../../web/scripts/api"); |
||||
expect(api.storeSettings).toHaveBeenCalledTimes(0); |
||||
expect(api.storeUserData).toHaveBeenCalledTimes(0); |
||||
expect(app.isNewUserSession).toBeFalsy(); |
||||
}); |
||||
it("doesnt allow user switching", async () => { |
||||
const { app } = await start({ |
||||
resetEnv: true, |
||||
userConfig: { migrated: true, storage: "server" }, |
||||
}); |
||||
expectNoUserScreen(); |
||||
|
||||
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); |
||||
}); |
||||
}); |
||||
describe("browser-user", () => { |
||||
it("doesnt show user creation if no default user", async () => { |
||||
const { app } = await start({ |
||||
resetEnv: true, |
||||
userConfig: { migrated: false, storage: "browser" }, |
||||
}); |
||||
expectNoUserScreen(); |
||||
|
||||
// It should store the settings
|
||||
const { api } = require("../../web/scripts/api"); |
||||
expect(api.storeSettings).toHaveBeenCalledTimes(0); |
||||
expect(api.storeUserData).toHaveBeenCalledTimes(0); |
||||
expect(app.isNewUserSession).toBeFalsy(); |
||||
}); |
||||
it("doesnt show user creation if default user", async () => { |
||||
const { app } = await start({ |
||||
resetEnv: true, |
||||
userConfig: { migrated: true, storage: "server" }, |
||||
}); |
||||
expectNoUserScreen(); |
||||
|
||||
// It should store the settings
|
||||
const { api } = require("../../web/scripts/api"); |
||||
expect(api.storeSettings).toHaveBeenCalledTimes(0); |
||||
expect(api.storeUserData).toHaveBeenCalledTimes(0); |
||||
expect(app.isNewUserSession).toBeFalsy(); |
||||
}); |
||||
it("doesnt allow user switching", async () => { |
||||
const { app } = await start({ |
||||
resetEnv: true, |
||||
userConfig: { migrated: true, storage: "browser" }, |
||||
}); |
||||
expectNoUserScreen(); |
||||
|
||||
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,32 @@
|
||||
import { $el } from "../ui.js"; |
||||
|
||||
export class ComfyDialog { |
||||
constructor() { |
||||
this.element = $el("div.comfy-modal", { parent: document.body }, [ |
||||
$el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]), |
||||
]); |
||||
} |
||||
|
||||
createButtons() { |
||||
return [ |
||||
$el("button", { |
||||
type: "button", |
||||
textContent: "Close", |
||||
onclick: () => this.close(), |
||||
}), |
||||
]; |
||||
} |
||||
|
||||
close() { |
||||
this.element.style.display = "none"; |
||||
} |
||||
|
||||
show(html) { |
||||
if (typeof html === "string") { |
||||
this.textElement.innerHTML = html; |
||||
} else { |
||||
this.textElement.replaceChildren(html); |
||||
} |
||||
this.element.style.display = "flex"; |
||||
} |
||||
} |
@ -0,0 +1,307 @@
|
||||
import { $el } from "../ui.js"; |
||||
import { api } from "../api.js"; |
||||
import { ComfyDialog } from "./dialog.js"; |
||||
|
||||
export class ComfySettingsDialog extends ComfyDialog { |
||||
constructor(app) { |
||||
super(); |
||||
this.app = app; |
||||
this.settingsValues = {}; |
||||
this.settingsLookup = {}; |
||||
this.element = $el( |
||||
"dialog", |
||||
{ |
||||
id: "comfy-settings-dialog", |
||||
parent: document.body, |
||||
}, |
||||
[ |
||||
$el("table.comfy-modal-content.comfy-table", [ |
||||
$el("caption", { textContent: "Settings" }), |
||||
$el("tbody", { $: (tbody) => (this.textElement = tbody) }), |
||||
$el("button", { |
||||
type: "button", |
||||
textContent: "Close", |
||||
style: { |
||||
cursor: "pointer", |
||||
}, |
||||
onclick: () => { |
||||
this.element.close(); |
||||
}, |
||||
}), |
||||
]), |
||||
] |
||||
); |
||||
} |
||||
|
||||
get settings() { |
||||
return Object.values(this.settingsLookup); |
||||
} |
||||
|
||||
async load() { |
||||
if (this.app.storageLocation === "browser") { |
||||
this.settingsValues = localStorage; |
||||
} else { |
||||
this.settingsValues = await api.getSettings(); |
||||
} |
||||
|
||||
// Trigger onChange for any settings added before load
|
||||
for (const id in this.settingsLookup) { |
||||
this.settingsLookup[id].onChange?.(this.settingsValues[this.getId(id)]); |
||||
} |
||||
} |
||||
|
||||
getId(id) { |
||||
if (this.app.storageLocation === "browser") { |
||||
id = "Comfy.Settings." + id; |
||||
} |
||||
return id; |
||||
} |
||||
|
||||
getSettingValue(id, defaultValue) { |
||||
let value = this.settingsValues[this.getId(id)]; |
||||
if(value != null) { |
||||
if(this.app.storageLocation === "browser") { |
||||
try { |
||||
value = JSON.parse(value); |
||||
} catch (error) { |
||||
} |
||||
} |
||||
} |
||||
return value ?? defaultValue; |
||||
} |
||||
|
||||
async setSettingValueAsync(id, value) { |
||||
const json = JSON.stringify(value); |
||||
localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage
|
||||
|
||||
let oldValue = this.getSettingValue(id, undefined); |
||||
this.settingsValues[this.getId(id)] = value; |
||||
|
||||
if (id in this.settingsLookup) { |
||||
this.settingsLookup[id].onChange?.(value, oldValue); |
||||
} |
||||
|
||||
await api.storeSetting(id, value); |
||||
} |
||||
|
||||
setSettingValue(id, value) { |
||||
this.setSettingValueAsync(id, value).catch((err) => { |
||||
alert(`Error saving setting '${id}'`); |
||||
console.error(err); |
||||
}); |
||||
} |
||||
|
||||
addSetting({ id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", options = undefined }) { |
||||
if (!id) { |
||||
throw new Error("Settings must have an ID"); |
||||
} |
||||
|
||||
if (id in this.settingsLookup) { |
||||
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`); |
||||
} |
||||
|
||||
let skipOnChange = false; |
||||
let value = this.getSettingValue(id); |
||||
if (value == null) { |
||||
if (this.app.isNewUserSession) { |
||||
// Check if we have a localStorage value but not a setting value and we are a new user
|
||||
const localValue = localStorage["Comfy.Settings." + id]; |
||||
if (localValue) { |
||||
value = JSON.parse(localValue); |
||||
this.setSettingValue(id, value); // Store on the server
|
||||
} |
||||
} |
||||
if (value == null) { |
||||
value = defaultValue; |
||||
} |
||||
} |
||||
|
||||
// Trigger initial setting of value
|
||||
if (!skipOnChange) { |
||||
onChange?.(value, undefined); |
||||
} |
||||
|
||||
this.settingsLookup[id] = { |
||||
id, |
||||
onChange, |
||||
name, |
||||
render: () => { |
||||
const setter = (v) => { |
||||
if (onChange) { |
||||
onChange(v, value); |
||||
} |
||||
|
||||
this.setSettingValue(id, v); |
||||
value = v; |
||||
}; |
||||
value = this.getSettingValue(id, defaultValue); |
||||
|
||||
let element; |
||||
const htmlID = id.replaceAll(".", "-"); |
||||
|
||||
const labelCell = $el("td", [ |
||||
$el("label", { |
||||
for: htmlID, |
||||
classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""], |
||||
textContent: name, |
||||
}), |
||||
]); |
||||
|
||||
if (typeof type === "function") { |
||||
element = type(name, setter, value, attrs); |
||||
} else { |
||||
switch (type) { |
||||
case "boolean": |
||||
element = $el("tr", [ |
||||
labelCell, |
||||
$el("td", [ |
||||
$el("input", { |
||||
id: htmlID, |
||||
type: "checkbox", |
||||
checked: value, |
||||
onchange: (event) => { |
||||
const isChecked = event.target.checked; |
||||
if (onChange !== undefined) { |
||||
onChange(isChecked); |
||||
} |
||||
this.setSettingValue(id, isChecked); |
||||
}, |
||||
}), |
||||
]), |
||||
]); |
||||
break; |
||||
case "number": |
||||
element = $el("tr", [ |
||||
labelCell, |
||||
$el("td", [ |
||||
$el("input", { |
||||
type, |
||||
value, |
||||
id: htmlID, |
||||
oninput: (e) => { |
||||
setter(e.target.value); |
||||
}, |
||||
...attrs, |
||||
}), |
||||
]), |
||||
]); |
||||
break; |
||||
case "slider": |
||||
element = $el("tr", [ |
||||
labelCell, |
||||
$el("td", [ |
||||
$el( |
||||
"div", |
||||
{ |
||||
style: { |
||||
display: "grid", |
||||
gridAutoFlow: "column", |
||||
}, |
||||
}, |
||||
[ |
||||
$el("input", { |
||||
...attrs, |
||||
value, |
||||
type: "range", |
||||
oninput: (e) => { |
||||
setter(e.target.value); |
||||
e.target.nextElementSibling.value = e.target.value; |
||||
}, |
||||
}), |
||||
$el("input", { |
||||
...attrs, |
||||
value, |
||||
id: htmlID, |
||||
type: "number", |
||||
style: { maxWidth: "4rem" }, |
||||
oninput: (e) => { |
||||
setter(e.target.value); |
||||
e.target.previousElementSibling.value = e.target.value; |
||||
}, |
||||
}), |
||||
] |
||||
), |
||||
]), |
||||
]); |
||||
break; |
||||
case "combo": |
||||
element = $el("tr", [ |
||||
labelCell, |
||||
$el("td", [ |
||||
$el( |
||||
"select", |
||||
{ |
||||
oninput: (e) => { |
||||
setter(e.target.value); |
||||
}, |
||||
}, |
||||
(typeof options === "function" ? options(value) : options || []).map((opt) => { |
||||
if (typeof opt === "string") { |
||||
opt = { text: opt }; |
||||
} |
||||
const v = opt.value ?? opt.text; |
||||
return $el("option", { |
||||
value: v, |
||||
textContent: opt.text, |
||||
selected: value + "" === v + "", |
||||
}); |
||||
}) |
||||
), |
||||
]), |
||||
]); |
||||
break; |
||||
case "text": |
||||
default: |
||||
if (type !== "text") { |
||||
console.warn(`Unsupported setting type '${type}, defaulting to text`); |
||||
} |
||||
|
||||
element = $el("tr", [ |
||||
labelCell, |
||||
$el("td", [ |
||||
$el("input", { |
||||
value, |
||||
id: htmlID, |
||||
oninput: (e) => { |
||||
setter(e.target.value); |
||||
}, |
||||
...attrs, |
||||
}), |
||||
]), |
||||
]); |
||||
break; |
||||
} |
||||
} |
||||
if (tooltip) { |
||||
element.title = tooltip; |
||||
} |
||||
|
||||
return element; |
||||
}, |
||||
}; |
||||
|
||||
const self = this; |
||||
return { |
||||
get value() { |
||||
return self.getSettingValue(id, defaultValue); |
||||
}, |
||||
set value(v) { |
||||
self.setSettingValue(id, v); |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
show() { |
||||
this.textElement.replaceChildren( |
||||
$el( |
||||
"tr", |
||||
{ |
||||
style: { display: "none" }, |
||||
}, |
||||
[$el("th"), $el("th", { style: { width: "33%" } })] |
||||
), |
||||
...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render()) |
||||
); |
||||
this.element.showModal(); |
||||
} |
||||
} |
@ -0,0 +1,34 @@
|
||||
.lds-ring { |
||||
display: inline-block; |
||||
position: relative; |
||||
width: 1em; |
||||
height: 1em; |
||||
} |
||||
.lds-ring div { |
||||
box-sizing: border-box; |
||||
display: block; |
||||
position: absolute; |
||||
width: 100%; |
||||
height: 100%; |
||||
border: 0.15em solid #fff; |
||||
border-radius: 50%; |
||||
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; |
||||
border-color: #fff transparent transparent transparent; |
||||
} |
||||
.lds-ring div:nth-child(1) { |
||||
animation-delay: -0.45s; |
||||
} |
||||
.lds-ring div:nth-child(2) { |
||||
animation-delay: -0.3s; |
||||
} |
||||
.lds-ring div:nth-child(3) { |
||||
animation-delay: -0.15s; |
||||
} |
||||
@keyframes lds-ring { |
||||
0% { |
||||
transform: rotate(0deg); |
||||
} |
||||
100% { |
||||
transform: rotate(360deg); |
||||
} |
||||
} |
@ -0,0 +1,9 @@
|
||||
import { addStylesheet } from "../utils.js"; |
||||
|
||||
addStylesheet(import.meta.url); |
||||
|
||||
export function createSpinner() { |
||||
const div = document.createElement("div"); |
||||
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`; |
||||
return div.firstElementChild; |
||||
} |
@ -0,0 +1,135 @@
|
||||
.comfy-user-selection { |
||||
width: 100vw; |
||||
height: 100vh; |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
z-index: 999; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
font-family: sans-serif; |
||||
background: linear-gradient(var(--tr-even-bg-color), var(--tr-odd-bg-color)); |
||||
} |
||||
|
||||
.comfy-user-selection-inner { |
||||
background: var(--comfy-menu-bg); |
||||
margin-top: -30vh; |
||||
padding: 20px 40px; |
||||
border-radius: 10px; |
||||
min-width: 365px; |
||||
position: relative; |
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.comfy-user-selection-inner form { |
||||
width: 100%; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
} |
||||
|
||||
.comfy-user-selection-inner h1 { |
||||
margin: 10px 0 30px 0; |
||||
font-weight: normal; |
||||
} |
||||
|
||||
.comfy-user-selection-inner label { |
||||
display: flex; |
||||
flex-direction: column; |
||||
width: 100%; |
||||
} |
||||
|
||||
.comfy-user-selection input, |
||||
.comfy-user-selection select { |
||||
background-color: var(--comfy-input-bg); |
||||
color: var(--input-text); |
||||
border: 0; |
||||
border-radius: 5px; |
||||
padding: 5px; |
||||
margin-top: 10px; |
||||
} |
||||
|
||||
.comfy-user-selection input::placeholder { |
||||
color: var(--descrip-text); |
||||
opacity: 1; |
||||
} |
||||
|
||||
.comfy-user-existing { |
||||
width: 100%; |
||||
} |
||||
|
||||
.no-users .comfy-user-existing { |
||||
display: none; |
||||
} |
||||
|
||||
.comfy-user-selection-inner .or-separator { |
||||
margin: 10px 0; |
||||
padding: 10px; |
||||
display: block; |
||||
text-align: center; |
||||
width: 100%; |
||||
color: var(--descrip-text); |
||||
} |
||||
|
||||
.comfy-user-selection-inner .or-separator { |
||||
overflow: hidden; |
||||
text-align: center; |
||||
margin-left: -10px; |
||||
} |
||||
|
||||
.comfy-user-selection-inner .or-separator::before, |
||||
.comfy-user-selection-inner .or-separator::after { |
||||
content: ""; |
||||
background-color: var(--border-color); |
||||
position: relative; |
||||
height: 1px; |
||||
vertical-align: middle; |
||||
display: inline-block; |
||||
width: calc(50% - 20px); |
||||
top: -1px; |
||||
} |
||||
|
||||
.comfy-user-selection-inner .or-separator::before { |
||||
right: 10px; |
||||
margin-left: -50%; |
||||
} |
||||
|
||||
.comfy-user-selection-inner .or-separator::after { |
||||
left: 10px; |
||||
margin-right: -50%; |
||||
} |
||||
|
||||
.comfy-user-selection-inner section { |
||||
width: 100%; |
||||
padding: 10px; |
||||
margin: -10px; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.comfy-user-selection-inner section.selected { |
||||
background: var(--border-color); |
||||
border-radius: 5px; |
||||
} |
||||
|
||||
.comfy-user-selection-inner footer { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
margin-top: 20px; |
||||
} |
||||
|
||||
.comfy-user-selection-inner .comfy-user-error { |
||||
color: var(--error-text); |
||||
margin-bottom: 10px; |
||||
} |
||||
|
||||
.comfy-user-button-next { |
||||
font-size: 16px; |
||||
padding: 6px 10px; |
||||
width: 100px; |
||||
display: flex; |
||||
gap: 5px; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
@ -0,0 +1,114 @@
|
||||
import { api } from "../api.js"; |
||||
import { $el } from "../ui.js"; |
||||
import { addStylesheet } from "../utils.js"; |
||||
import { createSpinner } from "./spinner.js"; |
||||
|
||||
export class UserSelectionScreen { |
||||
async show(users, user) { |
||||
// This will rarely be hit so move the loading to on demand
|
||||
await addStylesheet(import.meta.url); |
||||
const userSelection = document.getElementById("comfy-user-selection"); |
||||
userSelection.style.display = ""; |
||||
return new Promise((resolve) => { |
||||
const input = userSelection.getElementsByTagName("input")[0]; |
||||
const select = userSelection.getElementsByTagName("select")[0]; |
||||
const inputSection = input.closest("section"); |
||||
const selectSection = select.closest("section"); |
||||
const form = userSelection.getElementsByTagName("form")[0]; |
||||
const error = userSelection.getElementsByClassName("comfy-user-error")[0]; |
||||
const button = userSelection.getElementsByClassName("comfy-user-button-next")[0]; |
||||
|
||||
let inputActive = null; |
||||
input.addEventListener("focus", () => { |
||||
inputSection.classList.add("selected"); |
||||
selectSection.classList.remove("selected"); |
||||
inputActive = true; |
||||
}); |
||||
select.addEventListener("focus", () => { |
||||
inputSection.classList.remove("selected"); |
||||
selectSection.classList.add("selected"); |
||||
inputActive = false; |
||||
select.style.color = ""; |
||||
}); |
||||
select.addEventListener("blur", () => { |
||||
if (!select.value) { |
||||
select.style.color = "var(--descrip-text)"; |
||||
} |
||||
}); |
||||
|
||||
form.addEventListener("submit", async (e) => { |
||||
e.preventDefault(); |
||||
if (inputActive == null) { |
||||
error.textContent = "Please enter a username or select an existing user."; |
||||
} else if (inputActive) { |
||||
const username = input.value.trim(); |
||||
if (!username) { |
||||
error.textContent = "Please enter a username."; |
||||
return; |
||||
} |
||||
|
||||
// Create new user
|
||||
input.disabled = select.disabled = input.readonly = select.readonly = true; |
||||
const spinner = createSpinner(); |
||||
button.prepend(spinner); |
||||
try { |
||||
const resp = await api.createUser(username); |
||||
if (resp.status >= 300) { |
||||
let message = "Error creating user: " + resp.status + " " + resp.statusText; |
||||
try { |
||||
const res = await resp.json();
|
||||
if(res.error) { |
||||
message = res.error; |
||||
} |
||||
} catch (error) { |
||||
} |
||||
throw new Error(message); |
||||
} |
||||
|
||||
resolve({ username, userId: await resp.json(), created: true }); |
||||
} catch (err) { |
||||
spinner.remove(); |
||||
error.textContent = err.message ?? err.statusText ?? err ?? "An unknown error occurred."; |
||||
input.disabled = select.disabled = input.readonly = select.readonly = false; |
||||
return; |
||||
} |
||||
} else if (!select.value) { |
||||
error.textContent = "Please select an existing user."; |
||||
return; |
||||
} else { |
||||
resolve({ username: users[select.value], userId: select.value, created: false }); |
||||
} |
||||
}); |
||||
|
||||
if (user) { |
||||
const name = localStorage["Comfy.userName"]; |
||||
if (name) { |
||||
input.value = name; |
||||
} |
||||
} |
||||
if (input.value) { |
||||
// Focus the input, do this separately as sometimes browsers like to fill in the value
|
||||
input.focus(); |
||||
} |
||||
|
||||
const userIds = Object.keys(users ?? {}); |
||||
if (userIds.length) { |
||||
for (const u of userIds) { |
||||
$el("option", { textContent: users[u], value: u, parent: select }); |
||||
} |
||||
select.style.color = "var(--descrip-text)"; |
||||
|
||||
if (select.value) { |
||||
// Focus the select, do this separately as sometimes browsers like to fill in the value
|
||||
select.focus(); |
||||
} |
||||
} else { |
||||
userSelection.classList.add("no-users"); |
||||
input.focus(); |
||||
} |
||||
}).then((r) => { |
||||
userSelection.remove(); |
||||
return r; |
||||
}); |
||||
} |
||||
} |
Loading…
Reference in new issue