mirror of
https://gitea.tendokyu.moe/Hay1tsme/artemis.git
synced 2026-02-04 14:47:29 +08:00
chuni: add stage import and frontend config
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Linked VERSE
|
||||
"""CHUNITHM X-VERSE
|
||||
|
||||
Revision ID: 8b57e9646449
|
||||
Revises: bdf710616ba4
|
||||
@@ -18,6 +18,10 @@ depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"chuni_profile_data",
|
||||
sa.Column("stageId", sa.Integer(), nullable=False, server_default="99999"),
|
||||
)
|
||||
op.create_table(
|
||||
"chuni_static_linked_verse",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
@@ -67,9 +71,20 @@ def upgrade():
|
||||
sa.UniqueConstraint("user", "linkedVerseId", name="chuni_item_linked_verse_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.add_column(
|
||||
"chuni_profile_data",
|
||||
sa.Column("stageId", sa.Integer(), nullable=False, server_default="99999"),
|
||||
op.create_table(
|
||||
"chuni_static_stage",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column("stageId", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255)),
|
||||
sa.Column("imagePath", sa.String(length=255)),
|
||||
sa.Column("isEnabled", sa.Boolean(), server_default="1"),
|
||||
sa.Column("defaultHave", sa.Boolean(), server_default="0"),
|
||||
sa.Column("opt", sa.BIGINT(), sa.ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
sa.UniqueConstraint(
|
||||
"version", "stageId", name="chuni_static_stage_uk"
|
||||
),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -79,4 +94,5 @@ def downgrade():
|
||||
op.drop_column("chuni_profile_data", "stageId")
|
||||
op.drop_table("chuni_item_linked_verse")
|
||||
op.drop_table("chuni_static_linked_verse")
|
||||
op.drop_table("chuni_static_stage")
|
||||
# ### end Alembic commands ###
|
||||
@@ -24,6 +24,7 @@ mods:
|
||||
nameplates: False
|
||||
trophies: False
|
||||
character_icons: False
|
||||
stages: False
|
||||
|
||||
version:
|
||||
11:
|
||||
|
||||
@@ -329,6 +329,8 @@ class ItemKind(IntEnum):
|
||||
"""This only applies to ULTIMA difficulties that are *not* unlocked by
|
||||
reaching S rank on EXPERT difficulty or above.
|
||||
"""
|
||||
|
||||
STAGE = 13
|
||||
|
||||
|
||||
class FavoriteItemKind(IntEnum):
|
||||
|
||||
@@ -108,6 +108,7 @@ class ChuniFrontend(FE_Base):
|
||||
Route("/avatar", self.render_GET_avatar, methods=['GET']),
|
||||
Route("/update.map-icon", self.update_map_icon, methods=['POST']),
|
||||
Route("/update.system-voice", self.update_system_voice, methods=['POST']),
|
||||
Route("/update.stage", self.update_stage, methods=['POST']),
|
||||
Route("/update.userbox", self.update_userbox, methods=['POST']),
|
||||
Route("/update.avatar", self.update_avatar, methods=['POST']),
|
||||
Route("/update.name", self.update_name, methods=['POST']),
|
||||
@@ -141,6 +142,7 @@ class ChuniFrontend(FE_Base):
|
||||
# version here - it'll just end up being empty sets and the jinja will ignore the variables anyway.
|
||||
map_icons, total_map_icons = await self.get_available_map_icons(version, profile)
|
||||
system_voices, total_system_voices = await self.get_available_system_voices(version, profile)
|
||||
stages, total_stages = await self.get_available_stages(version, profile)
|
||||
|
||||
resp = Response(template.render(
|
||||
title=f"{self.core_config.server.name} | {self.nav_name}",
|
||||
@@ -155,7 +157,9 @@ class ChuniFrontend(FE_Base):
|
||||
map_icons=map_icons,
|
||||
system_voices=system_voices,
|
||||
total_map_icons=total_map_icons,
|
||||
total_system_voices=total_system_voices
|
||||
total_system_voices=total_system_voices,
|
||||
stages=stages,
|
||||
total_stages=total_stages
|
||||
), media_type="text/html; charset=utf-8")
|
||||
|
||||
if usr_sesh.chunithm_version >= 0:
|
||||
@@ -404,6 +408,31 @@ class ChuniFrontend(FE_Base):
|
||||
|
||||
return (items, len(rows))
|
||||
|
||||
async def get_available_stages(self, version: int, profile: Row) -> Tuple[List[Dict], int]:
|
||||
if profile is None:
|
||||
return ([], 0)
|
||||
items = dict()
|
||||
rows = await self.data.static.get_stages(version)
|
||||
if rows is None:
|
||||
return (items, 0) # can only happen with old db
|
||||
|
||||
force_unlocked = self.game_cfg.mods.forced_item_unlocks("stages")
|
||||
|
||||
user_stages = []
|
||||
if not force_unlocked:
|
||||
user_stages = await self.data.item.get_items(profile.user, ItemKind.STAGE.value)
|
||||
user_stages = [icon["itemId"] for icon in user_stages] + [profile.stageId]
|
||||
|
||||
for row in rows:
|
||||
if force_unlocked or row["defaultHave"] or row["stageId"] in user_stages:
|
||||
item = dict()
|
||||
item["id"] = row["stageId"]
|
||||
item["name"] = row["name"]
|
||||
item["imagePath"] = path.splitext(row["imagePath"])[0] + ".webp"
|
||||
items[row["stageId"]] = item
|
||||
|
||||
return (items, len(rows))
|
||||
|
||||
async def get_available_nameplates(self, version: int, profile: Row) -> Tuple[List[Dict], int]:
|
||||
items = dict()
|
||||
rows = await self.data.static.get_nameplates(version)
|
||||
@@ -650,6 +679,22 @@ class ChuniFrontend(FE_Base):
|
||||
return RedirectResponse("/gate/?e=999", 303)
|
||||
|
||||
return RedirectResponse("/game/chuni/", 303)
|
||||
|
||||
async def update_stage(self, request: Request) -> bytes:
|
||||
usr_sesh = self.validate_session(request)
|
||||
if not usr_sesh:
|
||||
return RedirectResponse("/gate/", 303)
|
||||
|
||||
form_data = await request.form()
|
||||
new_system_voice: str = form_data.get("id")
|
||||
|
||||
if not new_system_voice:
|
||||
return RedirectResponse("/gate/?e=4", 303)
|
||||
|
||||
if not await self.data.profile.update_stage(usr_sesh.user_id, usr_sesh.chunithm_version, new_system_voice):
|
||||
return RedirectResponse("/gate/?e=999", 303)
|
||||
|
||||
return RedirectResponse("/game/chuni/", 303)
|
||||
|
||||
async def update_userbox(self, request: Request) -> bytes:
|
||||
usr_sesh = self.validate_session(request)
|
||||
|
||||
4
titles/chuni/img/stage/.gitignore
vendored
Normal file
4
titles/chuni/img/stage/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Ignore everything in this directory
|
||||
*
|
||||
# Except this file
|
||||
!.gitignore
|
||||
@@ -69,6 +69,7 @@ class ChuniReader(BaseReader):
|
||||
await self.read_system_voice(f"{dir}/systemVoice", this_opt_id)
|
||||
await self.read_unlock_challenge(f"{dir}/unlockChallenge")
|
||||
await self.read_linked_verse(f"{dir}/linkedVerse")
|
||||
await self.read_stage(f"{dir}/stage", this_opt_id)
|
||||
|
||||
async def read_login_bonus(self, root_dir: str, opt_id: Optional[int] = None) -> None:
|
||||
for root, dirs, files in walk(f"{root_dir}loginBonusPreset"):
|
||||
@@ -569,6 +570,33 @@ class ChuniReader(BaseReader):
|
||||
self.logger.info(f"Inserted Linked VERSE {id}")
|
||||
else:
|
||||
self.logger.warning(f"Failed to Linked VERSE {id}")
|
||||
|
||||
async def read_stage(self, stage_dir: str, opt_id: Optional[int] = None) -> None:
|
||||
for root, dirs, files in walk(stage_dir):
|
||||
for dir in dirs:
|
||||
if path.exists(f"{root}/{dir}/Stage.xml"):
|
||||
with open(f"{root}/{dir}/Stage.xml", "r", encoding='utf-8') as fp:
|
||||
strdata = fp.read()
|
||||
|
||||
xml_root = ET.fromstring(strdata)
|
||||
for name in xml_root.findall("name"):
|
||||
id = name.find("id").text
|
||||
name = name.find("str").text
|
||||
for image in xml_root.findall("image"):
|
||||
image_path = image.find("path").text
|
||||
self.copy_image(image_path, f"{root}/{dir}", "titles/chuni/img/stage/")
|
||||
default_have = xml_root.find("defaultHave").text == 'true'
|
||||
disable_flag = xml_root.find("disableFlag") # may not exist in older data
|
||||
is_enabled = True if (disable_flag is None or disable_flag.text == "false") else False
|
||||
|
||||
result = await self.data.static.put_stage(
|
||||
self.version, id, name, image_path, is_enabled, default_have, opt_id
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
self.logger.info(f"Inserted stage {id}")
|
||||
else:
|
||||
self.logger.warning(f"Failed to insert stage {id}")
|
||||
|
||||
def copy_image(self, filename: str, src_dir: str, dst_dir: str) -> None:
|
||||
# Convert the image to webp so we can easily display it in the frontend
|
||||
|
||||
@@ -477,6 +477,17 @@ class ChuniProfileData(BaseData):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def update_stage(self, user_id: int, version: int, new_stage: int) -> bool:
|
||||
sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values(
|
||||
stageId=new_stage
|
||||
)
|
||||
result = await self.execute(sql)
|
||||
|
||||
if result is None:
|
||||
self.logger.warning(f"Failed to set user {user_id} stage")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def update_userbox(self, user_id: int, version: int, new_nameplate: int, new_trophy: int, new_trophy_sub_1: int, new_trophy_sub_2: int, new_character: int) -> bool:
|
||||
sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values(
|
||||
nameplateId=new_nameplate,
|
||||
|
||||
@@ -40,7 +40,7 @@ events = Table(
|
||||
Column("name", String(255)),
|
||||
Column("startDate", TIMESTAMP, server_default=func.now()),
|
||||
Column("enabled", Boolean, server_default="1"),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "eventId", name="chuni_static_events_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -58,7 +58,7 @@ music = Table(
|
||||
Column("genre", String(255)),
|
||||
Column("jacketPath", String(255)),
|
||||
Column("worldsEndTag", String(7)),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "songId", "chartId", name="chuni_static_music_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -74,7 +74,7 @@ charge = Table(
|
||||
Column("consumeType", Integer),
|
||||
Column("sellingAppeal", Boolean),
|
||||
Column("enabled", Boolean, server_default="1"),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "chargeId", name="chuni_static_charge_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -92,7 +92,7 @@ avatar = Table(
|
||||
Column("isEnabled", Boolean, server_default="1"),
|
||||
Column("defaultHave", Boolean, server_default="0"),
|
||||
Column("sortName", String(255)),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "avatarAccessoryId", name="chuni_static_avatar_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -108,7 +108,7 @@ nameplate = Table(
|
||||
Column("isEnabled", Boolean, server_default="1"),
|
||||
Column("defaultHave", Boolean, server_default="0"),
|
||||
Column("sortName", String(255)),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "nameplateId", name="chuni_static_nameplate_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -128,7 +128,7 @@ character = Table(
|
||||
Column("imagePath3", String(255)),
|
||||
Column("isEnabled", Boolean, server_default="1"),
|
||||
Column("defaultHave", Boolean, server_default="0"),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "characterId", name="chuni_static_character_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -143,7 +143,7 @@ trophy = Table(
|
||||
Column("rareType", Integer),
|
||||
Column("isEnabled", Boolean, server_default="1"),
|
||||
Column("defaultHave", Boolean, server_default="0"),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "trophyId", name="chuni_static_trophy_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -159,7 +159,7 @@ map_icon = Table(
|
||||
Column("iconPath", String(255)),
|
||||
Column("isEnabled", Boolean, server_default="1"),
|
||||
Column("defaultHave", Boolean, server_default="0"),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "mapIconId", name="chuni_static_mapicon_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -175,7 +175,7 @@ system_voice = Table(
|
||||
Column("imagePath", String(255)),
|
||||
Column("isEnabled", Boolean, server_default="1"),
|
||||
Column("defaultHave", Boolean, server_default="0"),
|
||||
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "voiceId", name="chuni_static_systemvoice_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -197,7 +197,7 @@ gachas = Table(
|
||||
Column("endDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"),
|
||||
Column("noticeStartDate", TIMESTAMP, server_default="2018-01-01 00:00:00.0"),
|
||||
Column("noticeEndDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"),
|
||||
Column("opt", ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT,ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "gachaId", "gachaName", name="chuni_static_gachas_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -218,7 +218,7 @@ cards = Table(
|
||||
Column("combo", Integer, nullable=False),
|
||||
Column("chain", Integer, nullable=False),
|
||||
Column("skillName", String(255), nullable=False),
|
||||
Column("opt", ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
Column("opt", BIGINT, ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint("version", "cardId", name="chuni_static_cards_uk"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -304,13 +304,13 @@ unlock_challenge = Table(
|
||||
Column("courseId4", Integer),
|
||||
Column("courseId5", Integer),
|
||||
UniqueConstraint(
|
||||
"version", "unlockChallengeId", name="chuni_static_unlock_challenge_pk"
|
||||
"version", "unlockChallengeId", name="chuni_static_unlock_challenge_uk"
|
||||
),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
|
||||
linked_verse: Table = Table(
|
||||
linked_verse = Table(
|
||||
"chuni_static_linked_verse",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, nullable=False),
|
||||
@@ -325,7 +325,24 @@ linked_verse: Table = Table(
|
||||
Column("courseId4", Integer),
|
||||
Column("courseId5", Integer),
|
||||
UniqueConstraint(
|
||||
"version", "linkedVerseId", name="chuni_static_linked_verse_pk"
|
||||
"version", "linkedVerseId", name="chuni_static_linked_verse_uk"
|
||||
),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
stage = Table(
|
||||
"chuni_static_stage",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, nullable=False),
|
||||
Column("version", Integer, nullable=False),
|
||||
Column("stageId", Integer, nullable=False),
|
||||
Column("name", String(255)),
|
||||
Column("imagePath", String(255)),
|
||||
Column("isEnabled", Boolean, server_default="1"),
|
||||
Column("defaultHave", Boolean, server_default="0"),
|
||||
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
|
||||
UniqueConstraint(
|
||||
"version", "stageId", name="chuni_static_stage_uk"
|
||||
),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
@@ -1307,3 +1324,54 @@ class ChuniStaticData(BaseData):
|
||||
return None
|
||||
|
||||
return result.fetchall()
|
||||
|
||||
async def put_stage(
|
||||
self,
|
||||
version: int,
|
||||
stage_id: int,
|
||||
name: str,
|
||||
image_path: str,
|
||||
is_enabled: int,
|
||||
default_have: int,
|
||||
opt_id: int = None
|
||||
) -> Optional[int]:
|
||||
|
||||
sql = insert(stage).values(
|
||||
version=version,
|
||||
stageId=stage_id,
|
||||
name=name,
|
||||
imagePath=image_path,
|
||||
isEnabled=is_enabled,
|
||||
defaultHave=default_have,
|
||||
opt=coalesce(stage.c.opt, opt_id)
|
||||
)
|
||||
|
||||
conflict = sql.on_duplicate_key_update(
|
||||
name=name,
|
||||
imagePath=image_path,
|
||||
isEnabled=is_enabled,
|
||||
defaultHave=default_have,
|
||||
opt=coalesce(stage.c.opt, opt_id)
|
||||
)
|
||||
|
||||
result = await self.execute(conflict)
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
return result.lastrowid
|
||||
|
||||
async def get_stages(self, version: int) -> Optional[List[Dict]]:
|
||||
sql = stage.select(
|
||||
and_(
|
||||
stage.c.version == version,
|
||||
stage.c.isEnabled == True,
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.execute(sql)
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
return result.fetchall()
|
||||
|
||||
@@ -79,6 +79,12 @@
|
||||
<td><div id="system-voice-name">{{ system_voices[profile.voiceId]["name"] if system_voices|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}</div></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if cur_version >= 18 %} <!-- STAGE introduced in X-VERSE -->
|
||||
<tr>
|
||||
<td>Stage:</td>
|
||||
<td><div id="stage-name">{{ stages[profile.stageId]["name"] if stages|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}</div></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,6 +117,21 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if cur_version >= 18 %} <!-- STAGE introduced in X-VERSE -->
|
||||
<!-- STAGE SELECTION -->
|
||||
<div class="col-lg-8 m-auto mt-3 scrolling-lists">
|
||||
<div class="card bg-card rounded">
|
||||
<button class="collapsible">Stage: {{ stages|length }}/{{ total_stages }}</button>
|
||||
<div id="scrollable-stage" class="collapsible-content">
|
||||
{% for item in stages.values() %}
|
||||
<img id="stage-{{ item["id"] }}" style="padding: 8px 8px;" onclick="saveItem('stage', '{{ item["id"] }}', '{{ item["name"] }}')" src="img/stage/{{ item["imagePath"] }}" alt="{{ item["name"] }}">
|
||||
<span id="stage-br-{{ loop.index }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-lg-8 m-auto mt-3">
|
||||
<div class="card bg-card rounded">
|
||||
<table class="table-large table-rowdistinct">
|
||||
@@ -201,6 +222,10 @@ items = {
|
||||
"map-icon": ["{{ map_icons|length }}", "{{ profile.mapIconId }}"],
|
||||
"system-voice":["{{ system_voices|length }}", "{{ profile.voiceId }}"]
|
||||
};
|
||||
// STAGE introduced in X-VERSE
|
||||
if ({{cur_version}} >= 18) {
|
||||
items.stage = ["{{ stages|length }}", "{{ profile.stageId }}"]
|
||||
}
|
||||
types = Object.keys(items);
|
||||
|
||||
function changeItem(type, id, name) {
|
||||
|
||||
Reference in New Issue
Block a user