diff --git a/CMakeLists.txt b/CMakeLists.txt index 05cafd09..54e3666d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1472,6 +1472,7 @@ add_executable(wowee_editor tools/editor/cli_audit_tree.cpp tools/editor/cli_magic_fix.cpp tools/editor/cli_bulk_validate.cpp + tools/editor/cli_bulk_json.cpp tools/editor/cli_macros_catalog.cpp tools/editor/cli_char_features_catalog.cpp tools/editor/cli_pvp_catalog.cpp diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 84c7a089..447120ba 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -138,6 +138,7 @@ const char* const kArgRequired[] = { "--bulk-rename-by-magic", "--touch-tree", "--tree-summary-md", "--catalog-grep", "--diff-headers", "--audit-tree", "--magic-fix", "--bulk-validate", + "--bulk-export-json", "--bulk-import-json", "--gen-animations", "--gen-animations-combat", "--gen-animations-movement", "--info-wani", "--validate-wani", "--export-wani-json", "--import-wani-json", diff --git a/tools/editor/cli_bulk_json.cpp b/tools/editor/cli_bulk_json.cpp new file mode 100644 index 00000000..5f5f6ef1 --- /dev/null +++ b/tools/editor/cli_bulk_json.cpp @@ -0,0 +1,286 @@ +#include "cli_bulk_json.hpp" +#include "cli_arg_parse.hpp" +#include "cli_format_table.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#endif + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +namespace fs = std::filesystem; + +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 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; +} + +// Derive --export-X-json or --import-X-json from the +// format table's --info-X flag. Both share the magic +// suffix (e.g. --info-wsrg -> --export-wsrg-json). +std::string deriveJsonFlag(const char* infoFlag, + const char* verb /* "export" or "import" */) { + if (!infoFlag) return {}; + std::string s = infoFlag; + const std::string prefix = "--info-"; + if (s.size() < prefix.size() || + s.compare(0, prefix.size(), prefix) != 0) { + return {}; + } + return std::string("--") + verb + "-" + + s.substr(prefix.size()) + "-json"; +} + +int runSubprocessExitCode(const std::string& cmd) { + int rc = std::system(cmd.c_str()); +#ifdef _WIN32 + return rc; +#else + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + return 1; +#endif +} + +struct JobResult { + fs::path path; + const FormatMagicEntry* fmt = nullptr; + int exitCode = 0; + bool skipped = false; +}; + +int handleExport(int& i, int argc, char** argv) { + std::string dir = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + if (!fs::exists(dir) || !fs::is_directory(dir)) { + std::fprintf(stderr, + "bulk-export-json: not a directory: %s\n", dir.c_str()); + return 1; + } + std::string self = argv[0]; + std::vector rows; + for (const auto& entry : fs::recursive_directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + // Skip files that are themselves .json sidecars — + // export only operates on binary .w* sources. + if (entry.path().extension() == ".json") continue; + char magic[4]; + if (!peekMagic(entry.path(), magic)) continue; + const FormatMagicEntry* fmt = findFormatByMagic(magic); + if (!fmt) continue; + JobResult r; + r.path = entry.path(); + r.fmt = fmt; + std::string flag = deriveJsonFlag(fmt->infoFlag, "export"); + if (flag.empty()) { + r.skipped = true; + rows.push_back(std::move(r)); + continue; + } + // strip the .wXXX extension so the per-format + // exporter sees the base path it expects. + std::string base = entry.path().string(); + std::string ext = entry.path().extension().string(); + if (!ext.empty() && base.size() > ext.size()) { + base = base.substr(0, base.size() - ext.size()); + } + std::string cmd = shellQuote(self) + " " + flag + " " + + shellQuote(base) + " >/dev/null 2>&1"; + r.exitCode = runSubprocessExitCode(cmd); + rows.push_back(std::move(r)); + } + size_t total = rows.size(); + size_t okCount = 0, failCount = 0, skipCount = 0; + for (const auto& r : rows) { + if (r.skipped) ++skipCount; + else if (r.exitCode == 0) ++okCount; + else ++failCount; + } + bool ok = (failCount == 0); + if (jsonOut) { + nlohmann::json j; + j["dir"] = dir; + j["mode"] = "export"; + j["total"] = total; + j["exported"] = okCount; + j["failed"] = failCount; + j["skipped"] = skipCount; + j["allOk"] = ok; + nlohmann::json failArr = nlohmann::json::array(); + for (const auto& r : rows) { + if (r.skipped || r.exitCode == 0) continue; + failArr.push_back({ + {"path", fs::relative(r.path, dir).string()}, + {"format", std::string(r.fmt->extension)}, + {"exitCode", r.exitCode}, + }); + } + j["failures"] = failArr; + std::printf("%s\n", j.dump(2).c_str()); + return ok ? 0 : 1; + } + std::printf("bulk-export-json: %s\n", dir.c_str()); + std::printf(" total recognized : %zu\n", total); + std::printf(" exported : %zu\n", okCount); + std::printf(" failed : %zu\n", failCount); + std::printf(" skipped (no exp) : %zu\n", skipCount); + if (ok) { + std::printf(" OK — every catalog with an exporter wrote a .json sidecar\n"); + return 0; + } + std::printf("\n failures:\n"); + for (const auto& r : rows) { + if (r.skipped || r.exitCode == 0) continue; + std::printf(" %s [%s, exit %d]\n", + fs::relative(r.path, dir).string().c_str(), + r.fmt->extension, r.exitCode); + } + return 1; +} + +// For import: walk *.json files, look at the inner shape +// to figure out the format, then call the per-format +// importer. Easier approach: derive the format from the +// sidecar's *.wXXX.json filename pattern (which the +// exporters all produce). +int handleImport(int& i, int argc, char** argv) { + std::string dir = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + if (!fs::exists(dir) || !fs::is_directory(dir)) { + std::fprintf(stderr, + "bulk-import-json: not a directory: %s\n", dir.c_str()); + return 1; + } + std::string self = argv[0]; + std::vector rows; + for (const auto& entry : fs::recursive_directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + const fs::path& p = entry.path(); + // We only care about .wXXX.json sidecars where the + // .wXXX matches a known format. Peek the filename + // suffix. + std::string fname = p.filename().string(); + if (fname.size() < 6) continue; + // Tail must be ".json" + if (fname.compare(fname.size() - 5, 5, ".json") != 0) continue; + // Strip ".json" then look at the resulting extension + std::string stem = fname.substr(0, fname.size() - 5); + size_t dot = stem.rfind('.'); + if (dot == std::string::npos) continue; + std::string ext = stem.substr(dot); + // Match the .wXXX extension against the format + // table. Iterate (no per-extension index lookup + // helper exposed yet). + const FormatMagicEntry* fmt = nullptr; + for (const FormatMagicEntry* f = formatTableBegin(); + f != formatTableEnd(); ++f) { + if (ext == f->extension) { fmt = f; break; } + } + if (!fmt) continue; + JobResult r; + r.path = p; + r.fmt = fmt; + std::string flag = deriveJsonFlag(fmt->infoFlag, "import"); + if (flag.empty()) { + r.skipped = true; + rows.push_back(std::move(r)); + continue; + } + std::string cmd = shellQuote(self) + " " + flag + " " + + shellQuote(p.string()) + " >/dev/null 2>&1"; + r.exitCode = runSubprocessExitCode(cmd); + rows.push_back(std::move(r)); + } + size_t total = rows.size(); + size_t okCount = 0, failCount = 0, skipCount = 0; + for (const auto& r : rows) { + if (r.skipped) ++skipCount; + else if (r.exitCode == 0) ++okCount; + else ++failCount; + } + bool ok = (failCount == 0); + if (jsonOut) { + nlohmann::json j; + j["dir"] = dir; + j["mode"] = "import"; + j["total"] = total; + j["imported"] = okCount; + j["failed"] = failCount; + j["skipped"] = skipCount; + j["allOk"] = ok; + nlohmann::json failArr = nlohmann::json::array(); + for (const auto& r : rows) { + if (r.skipped || r.exitCode == 0) continue; + failArr.push_back({ + {"path", fs::relative(r.path, dir).string()}, + {"format", std::string(r.fmt->extension)}, + {"exitCode", r.exitCode}, + }); + } + j["failures"] = failArr; + std::printf("%s\n", j.dump(2).c_str()); + return ok ? 0 : 1; + } + std::printf("bulk-import-json: %s\n", dir.c_str()); + std::printf(" total sidecars : %zu\n", total); + std::printf(" imported : %zu\n", okCount); + std::printf(" failed : %zu\n", failCount); + std::printf(" skipped (no imp) : %zu\n", skipCount); + if (ok) { + std::printf(" OK — every .json sidecar was imported back to binary\n"); + return 0; + } + std::printf("\n failures:\n"); + for (const auto& r : rows) { + if (r.skipped || r.exitCode == 0) continue; + std::printf(" %s [%s, exit %d]\n", + fs::relative(r.path, dir).string().c_str(), + r.fmt->extension, r.exitCode); + } + return 1; +} + +} // namespace + +bool handleBulkJson(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--bulk-export-json") == 0 && i + 1 < argc) { + outRc = handleExport(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--bulk-import-json") == 0 && i + 1 < argc) { + outRc = handleImport(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_bulk_json.hpp b/tools/editor/cli_bulk_json.hpp new file mode 100644 index 00000000..6a8ccffd --- /dev/null +++ b/tools/editor/cli_bulk_json.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleBulkJson(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 94a5bd3a..84b4b347 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -97,6 +97,7 @@ #include "cli_audit_tree.hpp" #include "cli_magic_fix.hpp" #include "cli_bulk_validate.hpp" +#include "cli_bulk_json.hpp" #include "cli_macros_catalog.hpp" #include "cli_char_features_catalog.hpp" #include "cli_pvp_catalog.hpp" @@ -257,6 +258,7 @@ constexpr DispatchFn kDispatchTable[] = { handleAuditTree, handleMagicFix, handleBulkValidate, + handleBulkJson, handleMacrosCatalog, handleCharFeaturesCatalog, handlePVPCatalog, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 9e8140b8..4133a4cd 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -1371,6 +1371,10 @@ void printUsage(const char* argv0) { std::printf(" Auto-rename files whose extension doesn't match their magic to the canonical extension. Default is dry-run; pass --apply to commit. Skips collisions where the target already exists. Natural follow-up to --audit-tree\n"); std::printf(" --bulk-validate [--json]\n"); std::printf(" Recursively run each format's --validate-X validator across every recognized .w* file. Reports per-file pass/fail counts; lists failure paths. Asset formats with no validator are skipped. Exit 1 if any failure\n"); + std::printf(" --bulk-export-json [--json]\n"); + std::printf(" Recursively export every recognized .w* file to its JSON sidecar via the per-format --export-X-json flag. Useful for git-friendly diffs of binary catalogs. Exit 1 if any failure\n"); + std::printf(" --bulk-import-json [--json]\n"); + std::printf(" Recursively import every .wXXX.json sidecar back to its binary .w* form via the per-format --import-X-json flag. Inverse of --bulk-export-json. Exit 1 if any failure\n"); std::printf(" --gen-animations [name]\n"); std::printf(" Emit .wani starter: 5 essential animations (Stand / Walk / Run / Death / AttackUnarmed) with fallback chains\n"); std::printf(" --gen-animations-combat [name]\n");