mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-10 02:53:51 +00:00
refactor(editor): extract add-* append handlers into cli_add.cpp
Moves the three add-* coordinate-based append handlers
(--add-object, --add-creature, --add-item) out of main.cpp
into a new cli_add.{hpp,cpp} module. All three append a
single entry to a zone's JSON file with optional positional
args after the required ones. --add-item handles raw
nlohmann::json (no dedicated editor class) and auto-assigns
the smallest unused id when the user passes 0 / nothing.
main.cpp shrinks by 198 lines (5,986 to 5,788).
This commit is contained in:
parent
83a6992ae0
commit
7af7534dc6
4 changed files with 274 additions and 203 deletions
249
tools/editor/cli_add.cpp
Normal file
249
tools/editor/cli_add.cpp
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
#include "cli_add.hpp"
|
||||
|
||||
#include "object_placer.hpp"
|
||||
#include "npc_spawner.hpp"
|
||||
#include <glm/glm.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace wowee {
|
||||
namespace editor {
|
||||
namespace cli {
|
||||
|
||||
namespace {
|
||||
|
||||
int handleAddObject(int& i, int argc, char** argv) {
|
||||
// Append a single object placement to a zone's objects.json.
|
||||
// Args: <zoneDir> <m2|wmo> <gamePath> <x> <y> <z> [scale]
|
||||
std::string zoneDir = argv[++i];
|
||||
std::string typeStr = argv[++i];
|
||||
std::string gamePath = argv[++i];
|
||||
namespace fs = std::filesystem;
|
||||
if (!fs::exists(zoneDir)) {
|
||||
std::fprintf(stderr, "add-object: zone '%s' does not exist\n",
|
||||
zoneDir.c_str());
|
||||
return 1;
|
||||
}
|
||||
wowee::editor::PlaceableType ptype;
|
||||
if (typeStr == "m2") ptype = wowee::editor::PlaceableType::M2;
|
||||
else if (typeStr == "wmo") ptype = wowee::editor::PlaceableType::WMO;
|
||||
else {
|
||||
std::fprintf(stderr, "add-object: type must be 'm2' or 'wmo'\n");
|
||||
return 1;
|
||||
}
|
||||
glm::vec3 pos;
|
||||
try {
|
||||
pos.x = std::stof(argv[++i]);
|
||||
pos.y = std::stof(argv[++i]);
|
||||
pos.z = std::stof(argv[++i]);
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "add-object: bad coordinate (%s)\n", e.what());
|
||||
return 1;
|
||||
}
|
||||
wowee::editor::ObjectPlacer placer;
|
||||
std::string path = zoneDir + "/objects.json";
|
||||
if (fs::exists(path)) placer.loadFromFile(path);
|
||||
placer.setActivePath(gamePath, ptype);
|
||||
placer.placeObject(pos);
|
||||
// Optional scale after coordinates.
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try {
|
||||
float scale = std::stof(argv[++i]);
|
||||
if (std::isfinite(scale) && scale > 0.0f) {
|
||||
// Set scale on the just-placed object (last in list).
|
||||
placer.getObjects().back().scale = scale;
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
if (!placer.saveToFile(path)) {
|
||||
std::fprintf(stderr, "add-object: failed to write %s\n", path.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("Added %s '%s' to %s (now %zu total)\n",
|
||||
typeStr.c_str(), gamePath.c_str(), path.c_str(),
|
||||
placer.getObjects().size());
|
||||
return 0;
|
||||
}
|
||||
|
||||
int handleAddCreature(int& i, int argc, char** argv) {
|
||||
// Append a single creature spawn to a zone's creatures.json.
|
||||
// Args: <zoneDir> <name> <x> <y> <z> [displayId] [level]
|
||||
// Useful for batch-populating zones via shell script without
|
||||
// launching the GUI placement tool.
|
||||
std::string zoneDir = argv[++i];
|
||||
std::string name = argv[++i];
|
||||
namespace fs = std::filesystem;
|
||||
if (!fs::exists(zoneDir)) {
|
||||
std::fprintf(stderr, "add-creature: zone '%s' does not exist\n",
|
||||
zoneDir.c_str());
|
||||
return 1;
|
||||
}
|
||||
wowee::editor::CreatureSpawn s;
|
||||
s.name = name;
|
||||
try {
|
||||
s.position.x = std::stof(argv[++i]);
|
||||
s.position.y = std::stof(argv[++i]);
|
||||
s.position.z = std::stof(argv[++i]);
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "add-creature: bad coordinate (%s)\n", e.what());
|
||||
return 1;
|
||||
}
|
||||
// Optional displayId (positional, after coordinates).
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try {
|
||||
s.displayId = static_cast<uint32_t>(std::stoul(argv[++i]));
|
||||
} catch (...) { /* leave 0 → SQL exporter substitutes 11707 */ }
|
||||
}
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try {
|
||||
s.level = static_cast<uint32_t>(std::stoul(argv[++i]));
|
||||
} catch (...) { /* leave default 1 */ }
|
||||
}
|
||||
// Load existing spawns (if any), append, save.
|
||||
wowee::editor::NpcSpawner spawner;
|
||||
std::string path = zoneDir + "/creatures.json";
|
||||
if (fs::exists(path)) spawner.loadFromFile(path);
|
||||
spawner.placeCreature(s);
|
||||
if (!spawner.saveToFile(path)) {
|
||||
std::fprintf(stderr, "add-creature: failed to write %s\n", path.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("Added creature '%s' to %s (now %zu total)\n",
|
||||
name.c_str(), path.c_str(), spawner.spawnCount());
|
||||
return 0;
|
||||
}
|
||||
|
||||
int handleAddItem(int& i, int argc, char** argv) {
|
||||
// Append one item entry to <zoneDir>/items.json. Inline
|
||||
// JSON without a dedicated editor class — items.json is
|
||||
// a simple {"items": [...]} array of records, and the
|
||||
// schema is small enough that we don't need NpcSpawner-
|
||||
// style infrastructure yet.
|
||||
//
|
||||
// Schema per item:
|
||||
// id (uint32) — Item.dbc primary key (auto-increments
|
||||
// from 1 if omitted)
|
||||
// name (string)
|
||||
// quality (uint8) — 0..6 (poor..artifact, default 1)
|
||||
// displayId (uint32) — ItemDisplayInfo index (default 0)
|
||||
// itemLevel (uint32) — default 1
|
||||
// stackable (uint32) — max stack size (default 1)
|
||||
std::string zoneDir = argv[++i];
|
||||
std::string name = argv[++i];
|
||||
namespace fs = std::filesystem;
|
||||
if (!fs::exists(zoneDir)) {
|
||||
std::fprintf(stderr,
|
||||
"add-item: zone '%s' does not exist\n", zoneDir.c_str());
|
||||
return 1;
|
||||
}
|
||||
uint32_t id = 0, displayId = 0, itemLevel = 1;
|
||||
uint32_t quality = 1;
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try { id = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||||
catch (...) {}
|
||||
}
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try { quality = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||||
catch (...) {}
|
||||
if (quality > 6) quality = 1;
|
||||
}
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try { displayId = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||||
catch (...) {}
|
||||
}
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try { itemLevel = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||||
catch (...) {}
|
||||
}
|
||||
std::string path = zoneDir + "/items.json";
|
||||
nlohmann::json doc = nlohmann::json::object({{"items",
|
||||
nlohmann::json::array()}});
|
||||
if (fs::exists(path)) {
|
||||
std::ifstream in(path);
|
||||
try { in >> doc; } catch (...) {
|
||||
std::fprintf(stderr,
|
||||
"add-item: %s exists but is not valid JSON\n",
|
||||
path.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (!doc.contains("items") || !doc["items"].is_array()) {
|
||||
doc["items"] = nlohmann::json::array();
|
||||
}
|
||||
}
|
||||
// Auto-assign id if user passed 0 / nothing — pick the
|
||||
// smallest unused positive integer so the items.json
|
||||
// numbering stays contiguous.
|
||||
if (id == 0) {
|
||||
std::set<uint32_t> used;
|
||||
for (const auto& it : doc["items"]) {
|
||||
if (it.contains("id") && it["id"].is_number_unsigned()) {
|
||||
used.insert(it["id"].get<uint32_t>());
|
||||
}
|
||||
}
|
||||
id = 1;
|
||||
while (used.count(id)) ++id;
|
||||
}
|
||||
// Reject duplicate id so the user notices a collision.
|
||||
for (const auto& it : doc["items"]) {
|
||||
if (it.contains("id") && it["id"].is_number_unsigned() &&
|
||||
it["id"].get<uint32_t>() == id) {
|
||||
std::fprintf(stderr,
|
||||
"add-item: id %u already in use in %s\n",
|
||||
id, path.c_str());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
nlohmann::json item = {
|
||||
{"id", id},
|
||||
{"name", name},
|
||||
{"quality", quality},
|
||||
{"displayId", displayId},
|
||||
{"itemLevel", itemLevel},
|
||||
{"stackable", 1},
|
||||
};
|
||||
doc["items"].push_back(item);
|
||||
std::ofstream out(path);
|
||||
if (!out) {
|
||||
std::fprintf(stderr,
|
||||
"add-item: failed to write %s\n", path.c_str());
|
||||
return 1;
|
||||
}
|
||||
out << doc.dump(2);
|
||||
out.close();
|
||||
static const char* qualityNames[] = {
|
||||
"poor", "common", "uncommon", "rare", "epic",
|
||||
"legendary", "artifact"
|
||||
};
|
||||
std::printf("Added item '%s' (id=%u, quality=%s, ilvl=%u) to %s (now %zu total)\n",
|
||||
name.c_str(), id,
|
||||
qualityNames[quality], itemLevel,
|
||||
path.c_str(), doc["items"].size());
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool handleAdd(int& i, int argc, char** argv, int& outRc) {
|
||||
if (std::strcmp(argv[i], "--add-object") == 0 && i + 5 < argc) {
|
||||
outRc = handleAddObject(i, argc, argv); return true;
|
||||
}
|
||||
if (std::strcmp(argv[i], "--add-creature") == 0 && i + 4 < argc) {
|
||||
outRc = handleAddCreature(i, argc, argv); return true;
|
||||
}
|
||||
if (std::strcmp(argv[i], "--add-item") == 0 && i + 2 < argc) {
|
||||
outRc = handleAddItem(i, argc, argv); return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace cli
|
||||
} // namespace editor
|
||||
} // namespace wowee
|
||||
20
tools/editor/cli_add.hpp
Normal file
20
tools/editor/cli_add.hpp
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#pragma once
|
||||
|
||||
namespace wowee {
|
||||
namespace editor {
|
||||
namespace cli {
|
||||
|
||||
// Dispatch the add-* append-by-coords handlers — append a
|
||||
// single creature spawn / object placement / item record to a
|
||||
// zone's JSON files. Useful for batch-populating zones via
|
||||
// shell scripts without launching the GUI placement tool.
|
||||
// --add-object <zone> <m2|wmo> <gamePath> <x> <y> <z> [scale]
|
||||
// --add-creature <zone> <name> <x> <y> <z> [displayId] [level]
|
||||
// --add-item <zone> <name> [id] [quality] [displayId] [itemLevel]
|
||||
//
|
||||
// Returns true if matched; outRc holds the exit code.
|
||||
bool handleAdd(int& i, int argc, char** argv, int& outRc);
|
||||
|
||||
} // namespace cli
|
||||
} // namespace editor
|
||||
} // namespace wowee
|
||||
|
|
@ -41,6 +41,7 @@
|
|||
#include "cli_quest_reward.hpp"
|
||||
#include "cli_clone.hpp"
|
||||
#include "cli_remove.hpp"
|
||||
#include "cli_add.hpp"
|
||||
#include "content_pack.hpp"
|
||||
#include "npc_spawner.hpp"
|
||||
#include "object_placer.hpp"
|
||||
|
|
@ -500,6 +501,9 @@ int main(int argc, char* argv[]) {
|
|||
if (wowee::editor::cli::handleRemove(i, argc, argv, outRc)) {
|
||||
return outRc;
|
||||
}
|
||||
if (wowee::editor::cli::handleAdd(i, argc, argv, outRc)) {
|
||||
return outRc;
|
||||
}
|
||||
}
|
||||
if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
|
||||
dataPath = argv[++i];
|
||||
|
|
@ -1428,209 +1432,6 @@ int main(int argc, char* argv[]) {
|
|||
outPath.c_str(),
|
||||
col.triangles.size(), col.walkableCount(), col.steepCount());
|
||||
return 0;
|
||||
} else if (std::strcmp(argv[i], "--add-object") == 0 && i + 5 < argc) {
|
||||
// Append a single object placement to a zone's objects.json.
|
||||
// Args: <zoneDir> <m2|wmo> <gamePath> <x> <y> <z> [scale]
|
||||
std::string zoneDir = argv[++i];
|
||||
std::string typeStr = argv[++i];
|
||||
std::string gamePath = argv[++i];
|
||||
namespace fs = std::filesystem;
|
||||
if (!fs::exists(zoneDir)) {
|
||||
std::fprintf(stderr, "add-object: zone '%s' does not exist\n",
|
||||
zoneDir.c_str());
|
||||
return 1;
|
||||
}
|
||||
wowee::editor::PlaceableType ptype;
|
||||
if (typeStr == "m2") ptype = wowee::editor::PlaceableType::M2;
|
||||
else if (typeStr == "wmo") ptype = wowee::editor::PlaceableType::WMO;
|
||||
else {
|
||||
std::fprintf(stderr, "add-object: type must be 'm2' or 'wmo'\n");
|
||||
return 1;
|
||||
}
|
||||
glm::vec3 pos;
|
||||
try {
|
||||
pos.x = std::stof(argv[++i]);
|
||||
pos.y = std::stof(argv[++i]);
|
||||
pos.z = std::stof(argv[++i]);
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "add-object: bad coordinate (%s)\n", e.what());
|
||||
return 1;
|
||||
}
|
||||
wowee::editor::ObjectPlacer placer;
|
||||
std::string path = zoneDir + "/objects.json";
|
||||
if (fs::exists(path)) placer.loadFromFile(path);
|
||||
placer.setActivePath(gamePath, ptype);
|
||||
placer.placeObject(pos);
|
||||
// Optional scale after coordinates.
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try {
|
||||
float scale = std::stof(argv[++i]);
|
||||
if (std::isfinite(scale) && scale > 0.0f) {
|
||||
// Set scale on the just-placed object (last in list).
|
||||
placer.getObjects().back().scale = scale;
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
if (!placer.saveToFile(path)) {
|
||||
std::fprintf(stderr, "add-object: failed to write %s\n", path.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("Added %s '%s' to %s (now %zu total)\n",
|
||||
typeStr.c_str(), gamePath.c_str(), path.c_str(),
|
||||
placer.getObjects().size());
|
||||
return 0;
|
||||
} else if (std::strcmp(argv[i], "--add-creature") == 0 && i + 4 < argc) {
|
||||
// Append a single creature spawn to a zone's creatures.json.
|
||||
// Args: <zoneDir> <name> <x> <y> <z> [displayId] [level]
|
||||
// Useful for batch-populating zones via shell script without
|
||||
// launching the GUI placement tool.
|
||||
std::string zoneDir = argv[++i];
|
||||
std::string name = argv[++i];
|
||||
namespace fs = std::filesystem;
|
||||
if (!fs::exists(zoneDir)) {
|
||||
std::fprintf(stderr, "add-creature: zone '%s' does not exist\n",
|
||||
zoneDir.c_str());
|
||||
return 1;
|
||||
}
|
||||
wowee::editor::CreatureSpawn s;
|
||||
s.name = name;
|
||||
try {
|
||||
s.position.x = std::stof(argv[++i]);
|
||||
s.position.y = std::stof(argv[++i]);
|
||||
s.position.z = std::stof(argv[++i]);
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "add-creature: bad coordinate (%s)\n", e.what());
|
||||
return 1;
|
||||
}
|
||||
// Optional displayId (positional, after coordinates).
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try {
|
||||
s.displayId = static_cast<uint32_t>(std::stoul(argv[++i]));
|
||||
} catch (...) { /* leave 0 → SQL exporter substitutes 11707 */ }
|
||||
}
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try {
|
||||
s.level = static_cast<uint32_t>(std::stoul(argv[++i]));
|
||||
} catch (...) { /* leave default 1 */ }
|
||||
}
|
||||
// Load existing spawns (if any), append, save.
|
||||
wowee::editor::NpcSpawner spawner;
|
||||
std::string path = zoneDir + "/creatures.json";
|
||||
if (fs::exists(path)) spawner.loadFromFile(path);
|
||||
spawner.placeCreature(s);
|
||||
if (!spawner.saveToFile(path)) {
|
||||
std::fprintf(stderr, "add-creature: failed to write %s\n", path.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("Added creature '%s' to %s (now %zu total)\n",
|
||||
name.c_str(), path.c_str(), spawner.spawnCount());
|
||||
return 0;
|
||||
} else if (std::strcmp(argv[i], "--add-item") == 0 && i + 2 < argc) {
|
||||
// Append one item entry to <zoneDir>/items.json. Inline
|
||||
// JSON without a dedicated editor class — items.json is
|
||||
// a simple {"items": [...]} array of records, and the
|
||||
// schema is small enough that we don't need NpcSpawner-
|
||||
// style infrastructure yet.
|
||||
//
|
||||
// Schema per item:
|
||||
// id (uint32) — Item.dbc primary key (auto-increments
|
||||
// from 1 if omitted)
|
||||
// name (string)
|
||||
// quality (uint8) — 0..6 (poor..artifact, default 1)
|
||||
// displayId (uint32) — ItemDisplayInfo index (default 0)
|
||||
// itemLevel (uint32) — default 1
|
||||
// stackable (uint32) — max stack size (default 1)
|
||||
std::string zoneDir = argv[++i];
|
||||
std::string name = argv[++i];
|
||||
namespace fs = std::filesystem;
|
||||
if (!fs::exists(zoneDir)) {
|
||||
std::fprintf(stderr,
|
||||
"add-item: zone '%s' does not exist\n", zoneDir.c_str());
|
||||
return 1;
|
||||
}
|
||||
uint32_t id = 0, displayId = 0, itemLevel = 1;
|
||||
uint32_t quality = 1;
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try { id = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||||
catch (...) {}
|
||||
}
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try { quality = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||||
catch (...) {}
|
||||
if (quality > 6) quality = 1;
|
||||
}
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try { displayId = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||||
catch (...) {}
|
||||
}
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||||
try { itemLevel = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||||
catch (...) {}
|
||||
}
|
||||
std::string path = zoneDir + "/items.json";
|
||||
nlohmann::json doc = nlohmann::json::object({{"items",
|
||||
nlohmann::json::array()}});
|
||||
if (fs::exists(path)) {
|
||||
std::ifstream in(path);
|
||||
try { in >> doc; } catch (...) {
|
||||
std::fprintf(stderr,
|
||||
"add-item: %s exists but is not valid JSON\n",
|
||||
path.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (!doc.contains("items") || !doc["items"].is_array()) {
|
||||
doc["items"] = nlohmann::json::array();
|
||||
}
|
||||
}
|
||||
// Auto-assign id if user passed 0 / nothing — pick the
|
||||
// smallest unused positive integer so the items.json
|
||||
// numbering stays contiguous.
|
||||
if (id == 0) {
|
||||
std::set<uint32_t> used;
|
||||
for (const auto& it : doc["items"]) {
|
||||
if (it.contains("id") && it["id"].is_number_unsigned()) {
|
||||
used.insert(it["id"].get<uint32_t>());
|
||||
}
|
||||
}
|
||||
id = 1;
|
||||
while (used.count(id)) ++id;
|
||||
}
|
||||
// Reject duplicate id so the user notices a collision.
|
||||
for (const auto& it : doc["items"]) {
|
||||
if (it.contains("id") && it["id"].is_number_unsigned() &&
|
||||
it["id"].get<uint32_t>() == id) {
|
||||
std::fprintf(stderr,
|
||||
"add-item: id %u already in use in %s\n",
|
||||
id, path.c_str());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
nlohmann::json item = {
|
||||
{"id", id},
|
||||
{"name", name},
|
||||
{"quality", quality},
|
||||
{"displayId", displayId},
|
||||
{"itemLevel", itemLevel},
|
||||
{"stackable", 1},
|
||||
};
|
||||
doc["items"].push_back(item);
|
||||
std::ofstream out(path);
|
||||
if (!out) {
|
||||
std::fprintf(stderr,
|
||||
"add-item: failed to write %s\n", path.c_str());
|
||||
return 1;
|
||||
}
|
||||
out << doc.dump(2);
|
||||
out.close();
|
||||
static const char* qualityNames[] = {
|
||||
"poor", "common", "uncommon", "rare", "epic",
|
||||
"legendary", "artifact"
|
||||
};
|
||||
std::printf("Added item '%s' (id=%u, quality=%s, ilvl=%u) to %s (now %zu total)\n",
|
||||
name.c_str(), id,
|
||||
qualityNames[quality], itemLevel,
|
||||
path.c_str(), doc["items"].size());
|
||||
return 0;
|
||||
} else if (std::strcmp(argv[i], "--random-populate-zone") == 0 && i + 1 < argc) {
|
||||
// Randomly add creatures and/or objects to a zone for
|
||||
// playtest scenarios. Reads the zone manifest's tile
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue