feat(dashboard): UGC settings beside what they change on the UGC page, their own Settings category

The settings catalog gives the UGC server a category of its own (serving,
processing, models, storage, icons). The UGC page shows those sections next to
the server status, the purge tools and the icon presets, read from the catalog
and saved through the Settings page's own path; each links to its entry on the
Settings page, which now opens a #file/name link at that setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 15:51:18 -05:00
parent fbf923e043
commit d83cf071ba
6 changed files with 226 additions and 14 deletions

View File

@@ -4,6 +4,7 @@
#include <sqlite3.h>
#include "UgcAssemblies.h"
#include "UgcLookup.h"
#include "UgcLookupSql.h"
@@ -103,14 +104,17 @@ namespace {
Exec("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT);"
"CREATE TABLE charinfo (id BIGINT PRIMARY KEY, account_id INTEGER, name TEXT);"
"CREATE TABLE properties (id BIGINT PRIMARY KEY, owner_id BIGINT, name TEXT, zone_id INTEGER);"
"CREATE TABLE ugc (id INTEGER PRIMARY KEY, account_id INTEGER, character_id BIGINT, is_optimized INTEGER, filename TEXT, process_error TEXT DEFAULT '');"
"CREATE TABLE ugc_modular_build (ugc_id BIGINT PRIMARY KEY, character_id BIGINT, ldf_config TEXT, is_optimized INTEGER DEFAULT 0, process_error TEXT DEFAULT '');"
"CREATE TABLE ugc (id INTEGER PRIMARY KEY, account_id INTEGER, character_id BIGINT, is_optimized INTEGER, filename TEXT, process_error TEXT DEFAULT '', "
"process_attempts INTEGER DEFAULT 0, processed_at BIGINT DEFAULT 0, process_after BIGINT DEFAULT 0, bake_ao INTEGER DEFAULT 0, brick_count INTEGER DEFAULT 0, triangle_count INTEGER DEFAULT 0);"
"CREATE TABLE ugc_modular_build (ugc_id BIGINT PRIMARY KEY, character_id BIGINT, ldf_config TEXT, is_optimized INTEGER DEFAULT 0, process_error TEXT DEFAULT '', "
"process_attempts INTEGER DEFAULT 0, processed_at BIGINT DEFAULT 0);"
"CREATE TABLE properties_contents (id BIGINT PRIMARY KEY, property_id BIGINT, ugc_id BIGINT, lot INTEGER, model_name TEXT DEFAULT '', model_description TEXT DEFAULT '');"
"INSERT INTO accounts VALUES (1, 'builder'), (2, 'racer');"
"INSERT INTO charinfo VALUES (10, 1, 'Bricky'), (20, 2, 'Speedy');"
"INSERT INTO properties VALUES (500, 10, 'Castle Hill', 1150);"
"INSERT INTO ugc VALUES (1000, 1, 10, 1, 'tower.lxfml', ''), (1001, 1, 10, 0, 'boat.lxfml', '');"
"INSERT INTO ugc_modular_build VALUES (2000, 20, '1:8129+1:8130+1:9330', 1, '');"
"INSERT INTO ugc (id, account_id, character_id, is_optimized, filename, process_error, brick_count, triangle_count) VALUES "
"(1000, 1, 10, 1, 'tower.lxfml', '', 40, 900), (1001, 1, 10, 0, 'boat.lxfml', '', 0, 0);"
"INSERT INTO ugc_modular_build (ugc_id, character_id, ldf_config, is_optimized, process_error) VALUES (2000, 20, '1:8129+1:8130+1:9330', 1, '');"
"INSERT INTO properties_contents VALUES (7000, 500, 1000, 14, 'Big tower', 'the tallest');");
}
@@ -132,9 +136,125 @@ namespace {
sqlite3_finalize(stmt);
return ids;
}
// A list page's ids in order, and the count, as ListUgc runs them (all, or a search)
std::pair<std::vector<int64_t>, int64_t> List(const IUgcLookup::UgcListQuery& query, bool modular) {
const bool searching = UgcLookupSql::Searching(query.search);
const auto where = (searching ? UgcLookupSql::Where(query.search, modular) : std::string("WHERE 1=1 ")) + UgcLookupSql::ListFilter(query, modular);
const auto pattern = "%" + query.search.text + "%";
const auto run = [&](const std::string& sql, bool page) {
std::vector<int64_t> out;
sqlite3_stmt* stmt = nullptr;
EXPECT_EQ(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), SQLITE_OK) << sqlite3_errmsg(db) << "\n" << sql;
int i = 1;
if (searching) for (; i <= UgcLookupSql::TEXT_BINDS; i++) sqlite3_bind_text(stmt, i, pattern.c_str(), -1, SQLITE_TRANSIENT);
if (page) {
sqlite3_bind_int(stmt, i++, static_cast<int>(query.limit));
sqlite3_bind_int(stmt, i++, static_cast<int>(query.offset));
}
while (sqlite3_step(stmt) == SQLITE_ROW) out.push_back(sqlite3_column_int64(stmt, 0));
sqlite3_finalize(stmt);
return out;
};
const auto ids = run(UgcLookupSql::Select(modular) + where + UgcLookupSql::ListOrder(query, modular) + "LIMIT ? OFFSET ?;", true);
const auto count = run("SELECT COUNT(*) " + UgcLookupSql::From(modular) + where + ";", false);
return { ids, count.empty() ? -1 : count.front() };
}
};
}
TEST_F(UgcLookupSqlTests, ListsPagesWithCounts) {
IUgcLookup::UgcListQuery query;
auto [ids, total] = List(query, false);
EXPECT_EQ(ids, (std::vector<int64_t>{ 1001, 1000 })); // newest first
EXPECT_EQ(total, 2);
query.sort = IUgcLookup::eSort::BRICKS;
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1000, 1001 }));
query.sort = IUgcLookup::eSort::NAME; // boat before tower
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1001, 1000 }));
query.limit = 1;
query.offset = 1;
std::tie(ids, total) = List(query, false);
EXPECT_EQ(ids, (std::vector<int64_t>{ 1000 }));
EXPECT_EQ(total, 2); // the count is of every match, not the page
query = {};
query.state = IUgc::eProcessState::DONE;
EXPECT_EQ(List(query, false), (std::pair<std::vector<int64_t>, int64_t>{ { 1000 }, 1 }));
query.search = ParseQuery("boat"); // searched and in a state: none
EXPECT_EQ(List(query, false).second, 0);
query.state.reset();
EXPECT_EQ(List(query, false), (std::pair<std::vector<int64_t>, int64_t>{ { 1001 }, 1 }));
EXPECT_EQ(List({}, true), (std::pair<std::vector<int64_t>, int64_t>{ { 2000 }, 1 }));
}
TEST(UgcAssemblies, GroupsBuildsByTheirModules) {
using State = IUgc::eProcessState;
const auto build = [](LWOOBJID id, LWOOBJID owner, const std::string& ldf, State state) {
IUgcLookup::UgcEntry e;
e.kind = IUgcLookup::eUgcKind::MODULAR;
e.id = id;
e.characterId = owner;
e.detail = ldf;
e.state = state;
if (state == State::FAILED) e.error = "broken";
return e;
};
const std::map<uint32_t, UgcAssemblies::ModuleInfo> modules = {
{ 4713, { 3, "Classic Rocket Nose Cone" } }, { 4714, { 3, "Classic Rocket Cockpit" } }, { 4715, { 3, "Classic Rocket Engine" } },
{ 8129, { 6, "Racing Car Chassis" } }, { 8130, { 6, "Racing Car Engine" } },
};
const std::vector<IUgcLookup::UgcEntry> builds = {
build(10, 1, "1:4713+1:4714+1:4715", State::DONE),
build(11, 2, "1:4715+1:4714+1:4713", State::PENDING), // the same modules written differently
build(12, 2, "1:8129+1:8130", State::FAILED),
build(13, 3, "", State::PENDING), // no modules: left out
build(14, 1, "1:4713+1:4714+1:4715", State::FAILED),
};
auto list = UgcAssemblies::Group(builds, modules);
ASSERT_EQ(list.size(), 2u);
const auto rocket = std::find_if(list.begin(), list.end(), [](const auto& a) { return a.key == "4713-4714-4715"; });
ASSERT_NE(rocket, list.end());
EXPECT_EQ(rocket->builds, (std::vector<LWOOBJID>{ 14, 11, 10 }));
EXPECT_EQ(rocket->owners.size(), 2u);
EXPECT_EQ(rocket->buildType, 3);
EXPECT_EQ(rocket->state, State::DONE); // one build's icon is made: the combination's is
EXPECT_EQ(rocket->iconBuild, 10);
EXPECT_TRUE(rocket->error.empty());
const auto car = std::find_if(list.begin(), list.end(), [](const auto& a) { return a.key == "8129-8130"; });
EXPECT_EQ(car->state, State::FAILED);
EXPECT_EQ(car->error, "broken");
UgcAssemblies::Filter filter;
filter.buildType = 6;
EXPECT_FALSE(UgcAssemblies::Matches(*rocket, filter, modules));
EXPECT_TRUE(UgcAssemblies::Matches(*car, filter, modules));
filter = {};
filter.moduleText = "cockpit";
EXPECT_TRUE(UgcAssemblies::Matches(*rocket, filter, modules));
EXPECT_FALSE(UgcAssemblies::Matches(*car, filter, modules));
filter = {};
filter.moduleLot = 8130;
EXPECT_TRUE(UgcAssemblies::Matches(*car, filter, modules));
filter = {};
filter.builds = std::set<LWOOBJID>{ 11 }; // an owner's build
EXPECT_TRUE(UgcAssemblies::Matches(*rocket, filter, modules));
EXPECT_FALSE(UgcAssemblies::Matches(*car, filter, modules));
filter = {};
filter.state = State::FAILED;
EXPECT_FALSE(UgcAssemblies::Matches(*rocket, filter, modules));
UgcAssemblies::Sort(list, UgcAssemblies::eSort::REFERENCES, modules);
EXPECT_EQ(list.front().key, "4713-4714-4715");
UgcAssemblies::Sort(list, UgcAssemblies::eSort::NEWEST, modules);
EXPECT_EQ(list.front().key, "4713-4714-4715"); // build 14
UgcAssemblies::Sort(list, UgcAssemblies::eSort::NAME, modules);
EXPECT_EQ(list.front().key, "4713-4714-4715"); // Classic before Racing
UgcAssemblies::Sort(list, UgcAssemblies::eSort::OLDEST, modules);
EXPECT_EQ(list.front().key, "4713-4714-4715"); // build 10
EXPECT_EQ(UgcAssemblies::ParseSort("uses"), UgcAssemblies::eSort::REFERENCES);
EXPECT_FALSE(UgcAssemblies::ParseSort("nonsense"));
}
TEST_F(UgcLookupSqlTests, FindsByIds) {
EXPECT_EQ(Find("1000", false), (std::set<int64_t>{ 1000 })); // the blueprint id
EXPECT_EQ(Find("7000", false), (std::set<int64_t>{ 1000 })); // the placed model's object id