mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-11 19:43:52 +00:00
New utility scans every catalog file under a directory tree and reports the primary-key id range, gap count, first gap, and recommended next id (smallest gap if any, else max+1). Useful when adding new entries without conflicts: instead of opening the file in --info to read the current max id, run --catalog-id-range and pick the recNext value. Optional --magic <WXXX> filter narrows to one format family. Output is sorted by file path for deterministic shell-pipeline behavior. Skips files whose format has no --info-* surface (asset formats like .wom/.wob/.whm). Permission-denied subdirs handled gracefully via skip_permission_denied. Reuses the same primary-key heuristic + foreign-key filter as --catalog-pluck and --catalog-find. CLI flag count 1198 -> 1199.
301 lines
9.3 KiB
C++
301 lines
9.3 KiB
C++
#include "cli_catalog_id_range.hpp"
|
|
#include "cli_arg_parse.hpp"
|
|
#include "cli_format_table.hpp"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <set>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace wowee {
|
|
namespace editor {
|
|
namespace cli {
|
|
|
|
namespace {
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
std::string shellQuote(const std::string& s) {
|
|
std::string out;
|
|
out.reserve(s.size() + 2);
|
|
out.push_back('\'');
|
|
for (char c : s) {
|
|
if (c == '\'') out += "'\"'\"'";
|
|
else out.push_back(c);
|
|
}
|
|
out.push_back('\'');
|
|
return out;
|
|
}
|
|
|
|
bool peekMagic(const fs::path& path, char magic[4]) {
|
|
std::ifstream is(path, std::ios::binary);
|
|
if (!is) return false;
|
|
if (!is.read(magic, 4) || is.gcount() != 4) return false;
|
|
return true;
|
|
}
|
|
|
|
// Same external-ref filter pattern as cli_catalog_pluck
|
|
// + cli_catalog_find. Picks the first non-foreign-key
|
|
// *Id field for the displayed range.
|
|
bool isExternalRefField(const std::string& k) {
|
|
static const char* kExternals[] = {
|
|
"mapId", "areaId", "spellId", "itemId", "npcId",
|
|
"creatureId", "factionId", "guildId", "soundId",
|
|
"movieId", "displayId", "modelId", "iconId",
|
|
"creatorPlayerId", "emblemId", "animationId",
|
|
"previousRankId", "nextRankId", "difficultyId",
|
|
"instanceId", "raceId", "classId",
|
|
"skillLineId", "questId", "talentId",
|
|
"achievementId", "criteriaId", "lootId",
|
|
};
|
|
for (const char* ref : kExternals) {
|
|
if (k == ref) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Extract the primary-key value from one entry. Returns
|
|
// {false, 0} if no usable numeric field found.
|
|
std::pair<bool, uint64_t>
|
|
findEntryPrimaryKey(const nlohmann::json& entry) {
|
|
if (!entry.is_object()) return {false, 0};
|
|
for (auto it = entry.begin(); it != entry.end(); ++it) {
|
|
const std::string& k = it.key();
|
|
if (k.size() >= 2 &&
|
|
k.compare(k.size() - 2, 2, "Id") == 0 &&
|
|
it.value().is_number_integer() &&
|
|
!isExternalRefField(k)) {
|
|
return {true, it.value().get<uint64_t>()};
|
|
}
|
|
}
|
|
for (auto it = entry.begin(); it != entry.end(); ++it) {
|
|
const std::string& k = it.key();
|
|
if (k.size() >= 2 &&
|
|
k.compare(k.size() - 2, 2, "Id") == 0 &&
|
|
it.value().is_number_integer()) {
|
|
return {true, it.value().get<uint64_t>()};
|
|
}
|
|
}
|
|
return {false, 0};
|
|
}
|
|
|
|
std::string runAndCapture(const std::string& cmd, int& outRc) {
|
|
std::string buf;
|
|
FILE* pipe = popen(cmd.c_str(), "r");
|
|
if (!pipe) {
|
|
outRc = 127;
|
|
return buf;
|
|
}
|
|
char chunk[4096];
|
|
while (std::fgets(chunk, sizeof(chunk), pipe) != nullptr) {
|
|
buf += chunk;
|
|
}
|
|
int rc = pclose(pipe);
|
|
#ifdef WEXITSTATUS
|
|
outRc = (rc != -1) ? WEXITSTATUS(rc) : rc;
|
|
#else
|
|
outRc = rc;
|
|
#endif
|
|
return buf;
|
|
}
|
|
|
|
struct FileSummary {
|
|
fs::path path;
|
|
std::string magic;
|
|
size_t entryCount = 0;
|
|
uint64_t minId = 0;
|
|
uint64_t maxId = 0;
|
|
size_t gapCount = 0; // missing IDs in
|
|
// [minId, maxId] range
|
|
uint64_t firstGap = 0; // smallest unused id in
|
|
// the range, 0 if none
|
|
uint64_t recommendedNextId = 1;
|
|
};
|
|
|
|
int handleIdRange(int& i, int argc, char** argv) {
|
|
if (i + 1 >= argc) {
|
|
std::fprintf(stderr,
|
|
"catalog-id-range: usage: --catalog-id-range "
|
|
"<directory> [--magic <WXXX>] [--json]\n");
|
|
return 1;
|
|
}
|
|
std::string dir = argv[++i];
|
|
bool jsonOut = false;
|
|
std::string magicFilter;
|
|
while (i + 1 < argc) {
|
|
if (std::strcmp(argv[i + 1], "--json") == 0) {
|
|
++i; jsonOut = true;
|
|
} else if (std::strcmp(argv[i + 1], "--magic") == 0 &&
|
|
i + 2 < argc) {
|
|
++i;
|
|
magicFilter = argv[++i];
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if (!fs::exists(dir) || !fs::is_directory(dir)) {
|
|
std::fprintf(stderr,
|
|
"catalog-id-range: not a directory: %s\n",
|
|
dir.c_str());
|
|
return 1;
|
|
}
|
|
|
|
std::vector<FileSummary> summaries;
|
|
size_t skipped = 0;
|
|
|
|
std::error_code walkEc;
|
|
fs::recursive_directory_iterator it(
|
|
dir, fs::directory_options::skip_permission_denied,
|
|
walkEc);
|
|
fs::recursive_directory_iterator end;
|
|
if (walkEc) {
|
|
std::fprintf(stderr,
|
|
"catalog-id-range: cannot open directory: %s\n",
|
|
walkEc.message().c_str());
|
|
return 1;
|
|
}
|
|
for (; it != end; it.increment(walkEc)) {
|
|
if (walkEc) { walkEc.clear(); continue; }
|
|
const auto& dirent = *it;
|
|
if (!dirent.is_regular_file(walkEc)) {
|
|
walkEc.clear(); continue;
|
|
}
|
|
char magic[4]{};
|
|
if (!peekMagic(dirent.path(), magic)) continue;
|
|
const FormatMagicEntry* fmt = findFormatByMagic(magic);
|
|
if (!fmt || !fmt->infoFlag) {
|
|
++skipped; continue;
|
|
}
|
|
if (!magicFilter.empty()) {
|
|
std::string m(magic, 4);
|
|
if (m != magicFilter) continue;
|
|
}
|
|
std::string base = dirent.path().string();
|
|
if (fmt->extension && *fmt->extension) {
|
|
size_t extLen = std::strlen(fmt->extension);
|
|
if (base.size() >= extLen &&
|
|
base.compare(base.size() - extLen, extLen,
|
|
fmt->extension) == 0) {
|
|
base.resize(base.size() - extLen);
|
|
}
|
|
}
|
|
std::string cmd = shellQuote(argv[0]) + " " +
|
|
fmt->infoFlag + " " +
|
|
shellQuote(base) + " --json 2>/dev/null";
|
|
int rc = 0;
|
|
std::string out = runAndCapture(cmd, rc);
|
|
if (rc != 0 || out.empty()) continue;
|
|
nlohmann::json doc;
|
|
try { doc = nlohmann::json::parse(out); }
|
|
catch (...) { continue; }
|
|
if (!doc.contains("entries") ||
|
|
!doc["entries"].is_array()) continue;
|
|
|
|
FileSummary s;
|
|
s.path = dirent.path();
|
|
s.magic = std::string(magic, 4);
|
|
s.entryCount = doc["entries"].size();
|
|
|
|
std::set<uint64_t> ids;
|
|
for (const auto& entry : doc["entries"]) {
|
|
auto [ok, key] = findEntryPrimaryKey(entry);
|
|
if (ok) ids.insert(key);
|
|
}
|
|
if (!ids.empty()) {
|
|
s.minId = *ids.begin();
|
|
s.maxId = *ids.rbegin();
|
|
// Count gaps in the [min, max] range.
|
|
for (uint64_t k = s.minId + 1; k < s.maxId; ++k) {
|
|
if (ids.find(k) == ids.end()) {
|
|
if (s.firstGap == 0) s.firstGap = k;
|
|
++s.gapCount;
|
|
}
|
|
}
|
|
// Recommendation: smallest gap in range if any,
|
|
// else max+1.
|
|
s.recommendedNextId = (s.firstGap != 0)
|
|
? s.firstGap : (s.maxId + 1);
|
|
} else {
|
|
s.recommendedNextId = 1;
|
|
}
|
|
summaries.push_back(s);
|
|
}
|
|
|
|
// Sort by path for deterministic output.
|
|
std::sort(summaries.begin(), summaries.end(),
|
|
[](const FileSummary& a, const FileSummary& b) {
|
|
return a.path < b.path;
|
|
});
|
|
|
|
if (jsonOut) {
|
|
nlohmann::json out;
|
|
out["directory"] = dir;
|
|
if (!magicFilter.empty()) out["magicFilter"] = magicFilter;
|
|
out["scanned"] = summaries.size();
|
|
out["skippedNoFlag"] = skipped;
|
|
out["files"] = nlohmann::json::array();
|
|
for (const auto& s : summaries) {
|
|
out["files"].push_back({
|
|
{"path", s.path.string()},
|
|
{"magic", s.magic},
|
|
{"entryCount", s.entryCount},
|
|
{"minId", s.minId},
|
|
{"maxId", s.maxId},
|
|
{"gapCount", s.gapCount},
|
|
{"firstGap", s.firstGap},
|
|
{"recommendedNextId", s.recommendedNextId},
|
|
});
|
|
}
|
|
std::printf("%s\n", out.dump(2).c_str());
|
|
return summaries.empty() ? 1 : 0;
|
|
}
|
|
|
|
std::printf("catalog-id-range: %zu catalog files in '%s'",
|
|
summaries.size(), dir.c_str());
|
|
if (!magicFilter.empty()) {
|
|
std::printf(" (magic=%s)", magicFilter.c_str());
|
|
}
|
|
std::printf("\n");
|
|
if (skipped > 0) {
|
|
std::printf(" (skipped %zu files: no --info-* "
|
|
"surface)\n", skipped);
|
|
}
|
|
if (summaries.empty()) {
|
|
std::printf(" no catalog files found\n");
|
|
return 1;
|
|
}
|
|
std::printf(" magic count minId maxId gaps firstGap recNext file\n");
|
|
for (const auto& s : summaries) {
|
|
std::printf(" [%s] %4zu %7llu %7llu %4zu %7llu %7llu %s\n",
|
|
s.magic.c_str(), s.entryCount,
|
|
(unsigned long long)s.minId,
|
|
(unsigned long long)s.maxId,
|
|
s.gapCount,
|
|
(unsigned long long)s.firstGap,
|
|
(unsigned long long)s.recommendedNextId,
|
|
s.path.string().c_str());
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool handleCatalogIdRange(int& i, int argc, char** argv, int& outRc) {
|
|
if (std::strcmp(argv[i], "--catalog-id-range") == 0 &&
|
|
i + 1 < argc) {
|
|
outRc = handleIdRange(i, argc, argv); return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
} // namespace cli
|
|
} // namespace editor
|
|
} // namespace wowee
|