feat: catch-up de decisiones previas (Webpage→Url, anti-bot, UI 2-col, tests cross-platform)

Bloque de cambios revisados y validados con el usuario en sesiones
previas que no habian aterrizado en commits propios. Lista por tema:

* enrichers: web_search ahora usa lite.duckduckgo.com como endpoint
  primario (mas tolerante con bot detection desde IP residencial),
  con fallback al endpoint html. Detecta pagina captcha y emite
  error claro si ambos fallan. Anyade _DDGLiteParser para el formato
  lite + auto-pick de parser por contenido.

* enrichers: tipo Webpage unificado en Url (campos de cuerpo
  cacheado viven en metadata del Url). Manifests actualizados
  (applies_to: [Url]). fetch_webpage ya no convierte Url->Webpage.

* enrichers/manifest: campo `params` parseado a EnricherSpec.params
  (name, type, default_value, description). UI puede renderizar
  dialog de configuracion.

* jobs: fix de path conversion para Python embebido nativo Windows
  (no convertir a /mnt/c/... cuando el subproceso es Windows-native;
  solo cuando es bash o python via WSL).

* main.cpp: ventana ImGui (no modal) "Run enricher" con layout
  2-col (label izq, input der). Inserta job con JSON tipado. Layout
  clustering apretado: hijos del mismo anchor en un solo anillo
  alrededor del padre, sin desperdigar por anillos crecientes.

* views: inspector con layout 2-col via BeginTable (Identity,
  Schema fields, Extras). Description full-width debajo de su label.

* tests: portable conftest (auto-detecta REGISTRY_ROOT, PYTHON_BIN,
  ENRICHERS_DIR para WSL y Windows portable). _runner.py trampoline
  inyecta stub via sys.path porque embedded Python ignora PYTHONPATH.
  Tests bash-only (vendor_script, freeze, dispatcher bash, resolver
  Linux-binary) skipean en Windows. Tests existentes adaptados a
  Webpage->Url.

Resultado actual: 32 passed WSL, 21 passed + 11 skipped Windows.
This commit is contained in:
2026-05-03 14:41:28 +02:00
parent 4be5734ce5
commit 7a94160fd2
26 changed files with 973 additions and 241 deletions
+188 -127
View File
@@ -874,173 +874,234 @@ void views_inspector(AppState& app) {
bool any_change = false;
// ---- Identidad ----
// Layout label-izquierda / input-derecha via 2-col table. El label
// alineado al frame del input y el input estirado al ancho restante.
ImGui::TextUnformatted("Identity");
ImGui::Separator();
if (ImGui::InputText("name", app.insp_name_buf, sizeof(app.insp_name_buf)))
any_change = true;
// type combo
{
int cur = -1;
for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
if (app.insp_type_options[i] == app.insp_type_buf) { cur = (int)i; break; }
}
// Si el tipo no esta en el cache (raro), mostrar como tal y permitir
// introducirlo via input. Combo simple aqui.
if (ImGui::BeginCombo("type", app.insp_type_buf)) {
if (ImGui::BeginTable("##insp_id", 2,
ImGuiTableFlags_SizingStretchProp |
ImGuiTableFlags_NoBordersInBody)) {
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90.0f);
ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
// name
ImGui::TableNextRow(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("name");
ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN);
if (ImGui::InputText("##name", app.insp_name_buf,
sizeof(app.insp_name_buf)))
any_change = true;
// type combo
ImGui::TableNextRow(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("type");
ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN);
{
int cur = -1;
for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
bool is_sel = (int)i == cur;
if (ImGui::Selectable(app.insp_type_options[i].c_str(), is_sel)) {
copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf),
app.insp_type_options[i]);
any_change = true;
if (app.insp_type_options[i] == app.insp_type_buf) {
cur = (int)i; break;
}
if (is_sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
if (ImGui::BeginCombo("##type", app.insp_type_buf)) {
for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
bool is_sel = (int)i == cur;
if (ImGui::Selectable(app.insp_type_options[i].c_str(), is_sel)) {
copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf),
app.insp_type_options[i]);
any_change = true;
}
if (is_sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
}
// status combo
ImGui::TableNextRow(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("status");
ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN);
if (ImGui::Combo("##status", &app.insp_status_idx,
k_status_options, k_status_count))
any_change = true;
ImGui::EndTable();
}
// status combo
if (ImGui::Combo("status", &app.insp_status_idx,
k_status_options, k_status_count))
any_change = true;
// description multiline
// description — multiline va debajo de su label, ocupando todo el
// ancho. Con 60 px de alto entra ~3 lineas; el usuario hace scroll
// dentro del input para textos mas largos.
ImGui::Spacing();
ImGui::TextUnformatted("description");
if (app.insp_desc_buf.empty()) ensure_desc_buf(app.insp_desc_buf, 4096);
if (ImGui::InputTextMultiline("description",
if (ImGui::InputTextMultiline("##desc",
app.insp_desc_buf.data(),
app.insp_desc_buf.size(),
ImVec2(-FLT_MIN, 60.0f)))
any_change = true;
// ---- Schema fields + Extras ----
// Misma idea que Identity: 2-col table con label izquierda, input
// derecha. Para extras añadimos un boton trash inline; para URLs un
// boton Open. Ambos son SmallButton tras un input mas estrecho.
if (!app.insp_field_keys.empty()) {
ImGui::Spacing();
ImGui::TextUnformatted("Fields");
ImGui::Separator();
const EntitySpec* spec = find_entity_spec(app.parsed_types,
app.insp_type_buf);
for (size_t i = 0; i < app.insp_field_keys.size(); ++i) {
const std::string& key = app.insp_field_keys[i];
std::string& val = app.insp_field_values[i];
bool is_extra = app.insp_is_extra[i] != 0;
ImGui::PushID((int)i);
// Encuentra la FieldSpec si es del schema.
const FieldSpec* fs = nullptr;
if (!is_extra && spec) {
for (const auto& f : spec->fields) {
if (f.name == key) { fs = &f; break; }
}
}
if (ImGui::BeginTable("##insp_fields", 2,
ImGuiTableFlags_SizingStretchProp |
ImGuiTableFlags_NoBordersInBody)) {
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90.0f);
ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
FieldKind kind = fs ? fs->kind : FK_STRING;
std::string label = key;
if (fs && fs->required) label += " *";
if (is_extra) label = "[extra] " + key;
for (size_t i = 0; i < app.insp_field_keys.size(); ++i) {
const std::string& key = app.insp_field_keys[i];
std::string& val = app.insp_field_values[i];
bool is_extra = app.insp_is_extra[i] != 0;
ImGui::PushID((int)i);
char buf[1024];
size_t k = std::min(sizeof(buf) - 1, val.size());
std::memcpy(buf, val.data(), k);
buf[k] = 0;
bool changed = false;
switch (kind) {
case FK_BOOL: {
bool b = (val == "true" || val == "1");
if (ImGui::Checkbox(label.c_str(), &b)) {
val = b ? "true" : "false";
changed = true;
// Encuentra la FieldSpec si es del schema.
const FieldSpec* fs = nullptr;
if (!is_extra && spec) {
for (const auto& f : spec->fields) {
if (f.name == key) { fs = &f; break; }
}
break;
}
case FK_INT: {
int n = std::atoi(val.c_str());
if (ImGui::InputInt(label.c_str(), &n)) {
char nb[32]; std::snprintf(nb, sizeof(nb), "%d", n);
val = nb;
changed = true;
}
break;
FieldKind kind = fs ? fs->kind : FK_STRING;
// Label izquierdo. Marca `*` si es required, prefijo
// [extra] si es campo libre añadido por el usuario.
ImGui::TableNextRow(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
if (is_extra) {
ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(0.65f, 0.65f, 0.50f, 1.0f));
ImGui::Text("%s", key.c_str());
ImGui::PopStyleColor();
} else if (fs && fs->required) {
ImGui::Text("%s *", key.c_str());
} else {
ImGui::TextUnformatted(key.c_str());
}
case FK_FLOAT: {
double d = std::atof(val.c_str());
if (ImGui::InputDouble(label.c_str(), &d, 0.0, 0.0, "%.6g")) {
char nb[64]; std::snprintf(nb, sizeof(nb), "%.10g", d);
val = nb;
changed = true;
}
break;
}
case FK_ENUM: {
if (fs && !fs->enum_values.empty()) {
int cur = -1;
for (size_t e = 0; e < fs->enum_values.size(); ++e) {
if (fs->enum_values[e] == val) { cur = (int)e; break; }
// Input derecha. Reserva espacio para el trailing button
// cuando aplique (URL Open, extras trash).
ImGui::TableNextColumn();
bool needs_trail_btn = is_extra ||
(kind == FK_URL && !val.empty() &&
(val.rfind("http://", 0) == 0 ||
val.rfind("https://", 0) == 0));
ImGui::SetNextItemWidth(needs_trail_btn ? -32.0f : -FLT_MIN);
char buf[1024];
size_t k = std::min(sizeof(buf) - 1, val.size());
std::memcpy(buf, val.data(), k);
buf[k] = 0;
bool changed = false;
switch (kind) {
case FK_BOOL: {
bool b = (val == "true" || val == "1");
if (ImGui::Checkbox("##v", &b)) {
val = b ? "true" : "false";
changed = true;
}
if (ImGui::BeginCombo(label.c_str(), val.c_str())) {
break;
}
case FK_INT: {
int n = std::atoi(val.c_str());
if (ImGui::InputInt("##v", &n)) {
char nb[32]; std::snprintf(nb, sizeof(nb), "%d", n);
val = nb;
changed = true;
}
break;
}
case FK_FLOAT: {
double d = std::atof(val.c_str());
if (ImGui::InputDouble("##v", &d, 0.0, 0.0, "%.6g")) {
char nb[64]; std::snprintf(nb, sizeof(nb), "%.10g", d);
val = nb;
changed = true;
}
break;
}
case FK_ENUM: {
if (fs && !fs->enum_values.empty()) {
int cur = -1;
for (size_t e = 0; e < fs->enum_values.size(); ++e) {
bool is_sel = (int)e == cur;
if (ImGui::Selectable(fs->enum_values[e].c_str(), is_sel)) {
val = fs->enum_values[e];
changed = true;
}
if (is_sel) ImGui::SetItemDefaultFocus();
if (fs->enum_values[e] == val) { cur = (int)e; break; }
}
if (ImGui::BeginCombo("##v", val.c_str())) {
for (size_t e = 0; e < fs->enum_values.size(); ++e) {
bool is_sel = (int)e == cur;
if (ImGui::Selectable(fs->enum_values[e].c_str(), is_sel)) {
val = fs->enum_values[e];
changed = true;
}
if (is_sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
} else {
if (ImGui::InputText("##v", buf, sizeof(buf))) {
val = buf;
changed = true;
}
ImGui::EndCombo();
}
} else {
// Sin valores: tratar como string
if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) {
break;
}
case FK_URL:
if (ImGui::InputText("##v", buf, sizeof(buf))) {
val = buf;
changed = true;
}
}
break;
}
case FK_URL:
if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) {
val = buf;
changed = true;
}
if (!val.empty() &&
(val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)) {
ImGui::SameLine();
if (ImGui::SmallButton("Open##url")) {
if (!val.empty() &&
(val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)) {
ImGui::SameLine();
if (ImGui::SmallButton(TI_EXTERNAL_LINK "##url")) {
#if defined(_WIN32)
std::string cmd = "start \"\" \"" + val + "\"";
std::string cmd = "start \"\" \"" + val + "\"";
#else
std::string cmd = "xdg-open '" + val + "' >/dev/null 2>&1 &";
std::string cmd = "xdg-open '" + val + "' >/dev/null 2>&1 &";
#endif
int rc = std::system(cmd.c_str()); (void)rc;
int rc = std::system(cmd.c_str()); (void)rc;
}
}
}
break;
case FK_DATE:
case FK_STRING:
default:
if (ImGui::InputTextWithHint(label.c_str(),
kind == FK_DATE ? "YYYY-MM-DD" : "",
buf, sizeof(buf))) {
val = buf;
changed = true;
}
break;
}
if (is_extra) {
ImGui::SameLine();
if (ImGui::SmallButton(TI_TRASH "##rm")) {
app.insp_field_keys.erase(app.insp_field_keys.begin() + i);
app.insp_field_values.erase(app.insp_field_values.begin() + i);
app.insp_is_extra.erase(app.insp_is_extra.begin() + i);
ImGui::PopID();
any_change = true;
--i;
continue;
break;
case FK_DATE:
case FK_STRING:
default:
if (ImGui::InputTextWithHint("##v",
kind == FK_DATE ? "YYYY-MM-DD" : "",
buf, sizeof(buf))) {
val = buf;
changed = true;
}
break;
}
if (is_extra) {
ImGui::SameLine();
if (ImGui::SmallButton(TI_TRASH "##rm")) {
app.insp_field_keys.erase(app.insp_field_keys.begin() + i);
app.insp_field_values.erase(app.insp_field_values.begin() + i);
app.insp_is_extra.erase(app.insp_is_extra.begin() + i);
ImGui::PopID();
any_change = true;
--i;
continue;
}
}
if (changed) any_change = true;
ImGui::PopID();
}
if (changed) any_change = true;
ImGui::PopID();
ImGui::EndTable();
}
}