diff --git a/CMakeLists.txt b/CMakeLists.txt index b58b0083..512d92f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1555,6 +1555,7 @@ add_executable(wowee_editor tools/editor/cli_creature_resists_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp + tools/editor/cli_catalog_by_name.cpp tools/editor/cli_quest_objective.cpp tools/editor/cli_quest_reward.cpp tools/editor/cli_clone.cpp diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 04e1976d..bf12c380 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -137,7 +137,7 @@ const char* const kArgRequired[] = { "--info-magic", "--summary-dir", "--rename-by-magic", "--bulk-rename-by-magic", "--touch-tree", "--tree-summary-md", "--catalog-grep", "--diff-headers", "--audit-tree", - "--catalog-pluck", "--catalog-find", + "--catalog-pluck", "--catalog-find", "--catalog-by-name", "--magic-fix", "--bulk-validate", "--bulk-export-json", "--bulk-import-json", "--diff-tree", "--orphan-jsons", "--list-by-magic", diff --git a/tools/editor/cli_catalog_by_name.cpp b/tools/editor/cli_catalog_by_name.cpp new file mode 100644 index 00000000..672e3d17 --- /dev/null +++ b/tools/editor/cli_catalog_by_name.cpp @@ -0,0 +1,281 @@ +#include "cli_catalog_by_name.hpp" +#include "cli_arg_parse.hpp" +#include "cli_format_table.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; +} + +std::string toLower(std::string s) { + for (char& c : s) { + c = static_cast( + std::tolower(static_cast(c))); + } + return s; +} + +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; +} + +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; +} + +// Find the first numeric *Id field in an entry to use as +// the displayed id for a hit. Same alphabetical-iteration +// caveat as cli_catalog_pluck — we iterate alphabetically +// (nlohmann::json default storage), so we have a small +// foreign-key filter to skip obvious external refs. +// For catalog-by-name this is purely cosmetic (the search +// itself is by name), so the filter doesn't need to be +// as comprehensive as catalog-pluck. +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", + }; + for (const char* ref : kExternals) { + if (k == ref) return true; + } + return false; +} + +uint64_t findEntryDisplayId(const nlohmann::json& entry) { + if (!entry.is_object()) return 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 it.value().get(); + } + } + 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 it.value().get(); + } + } + return 0; +} + +struct Hit { + fs::path path; + std::string magic; + uint64_t id; + std::string entryName; +}; + +int handleByName(int& i, int argc, char** argv) { + if (i + 2 >= argc) { + std::fprintf(stderr, + "catalog-by-name: usage: --catalog-by-name " + " [--magic ] " + "[--ignore-case] [--json]\n"); + return 1; + } + std::string dir = argv[++i]; + std::string pattern = argv[++i]; + bool jsonOut = false; + bool ignoreCase = false; + std::string magicFilter; + // Parse trailing flags in any order. + while (i + 1 < argc) { + if (std::strcmp(argv[i + 1], "--json") == 0) { + ++i; jsonOut = true; + } else if (std::strcmp(argv[i + 1], "--ignore-case") == 0) { + ++i; ignoreCase = 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-by-name: not a directory: %s\n", dir.c_str()); + return 1; + } + + std::string lcPattern = ignoreCase ? toLower(pattern) : pattern; + std::vector hits; + size_t scanned = 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-by-name: cannot open directory '%s': %s\n", + dir.c_str(), 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) continue; + if (!magicFilter.empty()) { + std::string m(magic, 4); + if (m != magicFilter) continue; + } + ++scanned; + 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; + for (const auto& entry : doc["entries"]) { + if (!entry.is_object()) continue; + if (!entry.contains("name") || + !entry["name"].is_string()) continue; + std::string entryName = + entry["name"].get(); + std::string haystack = ignoreCase + ? toLower(entryName) : entryName; + if (haystack.find(lcPattern) == std::string::npos) + continue; + Hit h; + h.path = dirent.path(); + h.magic = std::string(magic, 4); + h.id = findEntryDisplayId(entry); + h.entryName = entryName; + hits.push_back(h); + } + } + + if (jsonOut) { + nlohmann::json out; + out["directory"] = dir; + out["pattern"] = pattern; + out["ignoreCase"] = ignoreCase; + if (!magicFilter.empty()) out["magicFilter"] = magicFilter; + out["scanned"] = scanned; + out["hits"] = nlohmann::json::array(); + for (const auto& h : hits) { + out["hits"].push_back({ + {"file", h.path.string()}, + {"magic", h.magic}, + {"id", h.id}, + {"name", h.entryName}, + }); + } + std::printf("%s\n", out.dump(2).c_str()); + return hits.empty() ? 1 : 0; + } + + std::printf("catalog-by-name: searched %zu catalog files " + "in '%s' for name~='%s'%s", + scanned, dir.c_str(), pattern.c_str(), + ignoreCase ? " (case-insensitive)" : ""); + if (!magicFilter.empty()) { + std::printf(" (magic=%s)", magicFilter.c_str()); + } + std::printf("\n"); + if (hits.empty()) { + std::printf(" no hits — no entry name matched the " + "pattern in any catalog under this tree\n"); + return 1; + } + std::printf(" hits (%zu):\n", hits.size()); + for (const auto& h : hits) { + std::printf(" [%s] %s id=%llu \"%s\"\n", + h.magic.c_str(), h.path.string().c_str(), + static_cast(h.id), + h.entryName.c_str()); + } + return 0; +} + +} // namespace + +bool handleCatalogByName(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--catalog-by-name") == 0 && + i + 2 < argc) { + outRc = handleByName(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_catalog_by_name.hpp b/tools/editor/cli_catalog_by_name.hpp new file mode 100644 index 00000000..08c0823d --- /dev/null +++ b/tools/editor/cli_catalog_by_name.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleCatalogByName(int& i, int argc, char** argv, int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index 908f8872..6c970853 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -153,6 +153,7 @@ #include "cli_creature_resists_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" +#include "cli_catalog_by_name.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" #include "cli_clone.hpp" @@ -347,6 +348,7 @@ constexpr DispatchFn kDispatchTable[] = { handleCreatureResistsCatalog, handleCatalogPluck, handleCatalogFind, + handleCatalogByName, handleQuestObjective, handleQuestReward, handleClone, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 41549b1f..e7146bf3 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2255,6 +2255,8 @@ void printUsage(const char* argv0) { std::printf(" Extract one entry by id from any registered catalog format. Auto-detects magic, dispatches to the per-format --info-* handler internally, then prints just the matching entry. Primary-key field is auto-detected (first *Id field, or first numeric)\n"); std::printf(" --catalog-find [--magic ] [--json]\n"); std::printf(" Search every catalog file under for an entry with the given id (recursive walk). Prints all hits as [WXXX] file:fieldName=id name. Use --magic to limit search to one format family when the same id is a primary key in multiple\n"); + std::printf(" --catalog-by-name [--magic ] [--ignore-case] [--json]\n"); + std::printf(" Search every catalog file under for entries whose name contains the substring. Complements --catalog-find (id-based) and --catalog-grep (catalog-header label only). --ignore-case for fuzzy substring matching\n"); std::printf(" --gen-weather-temperate [zoneName]\n"); std::printf(" Emit .wow weather schedule: clear-dominant + occasional rain + fog (forest / grassland)\n"); std::printf(" --gen-weather-arctic [zoneName]\n");