- ajax/template_test.php: action=preview (render com sampleContext) e action=dryrun (cria+apaga objeto de teste no Redmine, traduz erros 422 em dicas acionáveis; issue temporária para log_time/update_status) - RedmineApi::rawRequest (código+corpo crus) e TemplateEngine::sampleContext - UI: botão "Testar" + painel por card (preview sempre; teste real opt-in com seletor de projeto alvo), via fetch com X-Glpi-Csrf-Token - validado E2E por HTTP: preview, dry-run cria/deleta (204), render inválido capturado, tradução de 422; sem resíduo de teste no Redmine Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
563 lines
25 KiB
PHP
563 lines
25 KiB
PHP
<?php
|
|
|
|
if (!defined('GLPI_ROOT')) {
|
|
die("Sorry. You can't access this file directly");
|
|
}
|
|
|
|
/**
|
|
* Motor de templates de payload (2.0) — ver docs/DESIGN-2.0.md.
|
|
*
|
|
* Renderiza templates JSON com tags mustache-like ({{ var | filtro }}) sobre um
|
|
* contexto montado pelo motor. Sem template ativo, o plugin usa o motor 1.x.
|
|
*/
|
|
class PluginRedmineTemplateengine {
|
|
|
|
const OPERATIONS = ['create_project', 'create_issue', 'log_time', 'update_status'];
|
|
|
|
// ------------------------------------------------------------------
|
|
// Armazenamento
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Template ativo para a operação, ou null (=> motor 1.x).
|
|
* @return array|null linha da tabela
|
|
*/
|
|
public static function getActive($operation) {
|
|
global $DB;
|
|
if (!$DB->tableExists('glpi_plugin_redmine_templates')) {
|
|
return null;
|
|
}
|
|
$it = $DB->request([
|
|
'FROM' => 'glpi_plugin_redmine_templates',
|
|
'WHERE' => ['operation' => $operation, 'is_active' => 1]
|
|
]);
|
|
return count($it) > 0 ? $it->current() : null;
|
|
}
|
|
|
|
/**
|
|
* Linha do template da operação (ativa ou não), ou null.
|
|
*/
|
|
public static function getRow($operation) {
|
|
global $DB;
|
|
if (!$DB->tableExists('glpi_plugin_redmine_templates')) {
|
|
return null;
|
|
}
|
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_templates', 'WHERE' => ['operation' => $operation]]);
|
|
return count($it) > 0 ? $it->current() : null;
|
|
}
|
|
|
|
/**
|
|
* Grava (upsert) o template de uma operação.
|
|
*/
|
|
public static function save($operation, $templateJson, $active = false) {
|
|
global $DB;
|
|
$row = [
|
|
'template' => $templateJson,
|
|
'is_active' => $active ? 1 : 0,
|
|
];
|
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_templates', 'WHERE' => ['operation' => $operation]]);
|
|
if (count($it) > 0) {
|
|
$DB->update('glpi_plugin_redmine_templates', $row, ['operation' => $operation]);
|
|
} else {
|
|
$DB->insert('glpi_plugin_redmine_templates', $row + ['operation' => $operation]);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Seeds (comportamento 1.x expresso como template)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Template padrão da operação (seed do botão "Gerar template").
|
|
* @return array estrutura do payload (antes do json_encode)
|
|
*/
|
|
public static function defaultTemplate($operation) {
|
|
switch ($operation) {
|
|
case 'create_issue':
|
|
return ['issue' => [
|
|
'project_id' => '{{ vinculo.projeto_redmine }}',
|
|
'subject' => '{{ chamado.titulo | truncar:255 }}',
|
|
'description' => '{{ chamado.descricao | sem_html }}',
|
|
'tracker_id' => '{{ config.tracker_padrao }}',
|
|
'priority_id' => '{{ config.prioridade_padrao }}',
|
|
'status_id' => '{{ map.status[chamado.status] }}',
|
|
'category_id' => '{{ vinculo.categoria_padrao }}',
|
|
'watcher_user_ids' => '{{ config.observadores }}',
|
|
]];
|
|
case 'log_time':
|
|
return ['time_entry' => [
|
|
'issue_id' => '{{ vinculo.issue_redmine }}',
|
|
'hours' => '{{ tarefa.horas }}',
|
|
'comments' => '{{ tarefa.comentario }}',
|
|
'activity_id' => '{{ config.atividade_padrao }}',
|
|
]];
|
|
case 'update_status':
|
|
return ['issue' => [
|
|
'status_id' => '{{ map.status[chamado.status] }}',
|
|
]];
|
|
case 'create_project':
|
|
return ['project' => [
|
|
'name' => '{{ form.nome }}',
|
|
'identifier' => '{{ form.identificador | identificador }}',
|
|
'description' => '{{ form.descricao }}',
|
|
'is_public' => '{{ form.publico }}',
|
|
'parent_id' => '{{ form.subprojeto_de }}',
|
|
'tracker_ids' => '{{ form.trackers }}',
|
|
'enabled_module_names' => '{{ form.modulos }}',
|
|
]];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Enriquece um seed com os custom fields OBRIGATÓRIOS descobertos no cache
|
|
* de metadados (por tipo de objeto Redmine da operação).
|
|
*/
|
|
public static function mergeDiscoveredCustomFields($operation, array $template) {
|
|
$type_by_op = [
|
|
'create_project' => 'project',
|
|
'create_issue' => 'issue',
|
|
'log_time' => 'time_entry',
|
|
];
|
|
if (!isset($type_by_op[$operation])) {
|
|
return $template;
|
|
}
|
|
global $DB;
|
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
|
|
$config = count($it) > 0 ? $it->current() : [];
|
|
$metadata = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : [];
|
|
|
|
$root = array_key_first($template);
|
|
foreach (($metadata['project_custom_fields'] ?? []) as $f) {
|
|
// metadata_cache hoje guarda só os de projeto; issue/time_entry na 2.1
|
|
if (($f['customized_type'] ?? 'project') !== $type_by_op[$operation]) {
|
|
continue;
|
|
}
|
|
if (!empty($f['is_required'])) {
|
|
$template[$root]['custom_field_values'][(string) $f['id']] =
|
|
($operation === 'create_project') ? '{{ form.cf_' . (int) $f['id'] . ' }}' : '';
|
|
}
|
|
}
|
|
return $template;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Paleta (catálogo de variáveis para a UI)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Catálogo de variáveis disponíveis, com a tag pronta para copiar.
|
|
* @return array de ['group','tag','desc']
|
|
*/
|
|
public static function variableCatalog() {
|
|
$cat = [];
|
|
$add = function ($group, $var, $desc) use (&$cat) {
|
|
$cat[] = ['group' => $group, 'tag' => '{{ ' . $var . ' }}', 'desc' => $desc];
|
|
};
|
|
$add('chamado', 'chamado.id', 'ID do chamado');
|
|
$add('chamado', 'chamado.titulo | truncar:255', 'Título (truncado p/ o limite do Redmine)');
|
|
$add('chamado', 'chamado.descricao | sem_html', 'Descrição sem HTML');
|
|
$add('chamado', 'chamado.status', 'Status do chamado (número GLPI)');
|
|
$add('chamado', 'chamado.entidade', 'ID da entidade do chamado');
|
|
$add('tarefa', 'tarefa.horas', 'Duração em horas decimais (1h30 = 1.5)');
|
|
$add('tarefa', 'tarefa.segundos', 'Duração em segundos');
|
|
$add('tarefa', 'tarefa.descricao', 'Descrição da tarefa (sem HTML)');
|
|
$add('tarefa', 'tarefa.comentario', 'Descrição ou fallback ("Tempo lançado via GLPI...")');
|
|
$add('tarefa', 'tarefa.data', 'Data da tarefa (AAAA-MM-DD)');
|
|
$add('vinculo', 'vinculo.projeto_redmine', 'ID do projeto Redmine vinculado');
|
|
$add('vinculo', 'vinculo.issue_redmine', 'ID da issue Redmine do chamado');
|
|
$add('vinculo', 'vinculo.categoria_padrao', 'Categoria padrão do vínculo');
|
|
$add('config', 'config.tracker_padrao', 'Tracker padrão (formconfig)');
|
|
$add('config', 'config.prioridade_padrao', 'Prioridade padrão (formconfig)');
|
|
$add('config', 'config.atividade_padrao', 'Atividade padrão (formconfig)');
|
|
$add('config', 'config.observadores', 'Observadores padrão (array de ids)');
|
|
$add('map', 'map.status[chamado.status]', 'Status Redmine mapeado do status do chamado');
|
|
$add('chamado', 'chamado.tecnico', 'Nome do técnico atribuído (texto; a AUTORIA real é automática via impersonation)');
|
|
$add('tarefa', 'tarefa.autor', 'Nome de quem lançou a tarefa (texto; a AUTORIA real é automática via impersonation)');
|
|
$add('form (criar projeto)', 'form.nome', 'Nome digitado no form');
|
|
$add('form (criar projeto)', 'form.identificador | identificador', 'Identificador normalizado');
|
|
$add('form (criar projeto)', 'form.descricao', 'Descrição do form');
|
|
$add('form (criar projeto)', 'form.publico', 'Público (true/false)');
|
|
$add('form (criar projeto)', 'form.subprojeto_de', 'Projeto pai escolhido');
|
|
$add('form (criar projeto)', 'form.trackers', 'Trackers selecionados (array)');
|
|
$add('form (criar projeto)', 'form.modulos', 'Módulos selecionados (array)');
|
|
$add('form (criar projeto)', 'form.cf_<id>', 'Custom field do form (ex.: form.cf_4)');
|
|
return $cat;
|
|
}
|
|
|
|
/**
|
|
* Dicionário "PARA" da operação: os campos que o Redmine espera/aceita,
|
|
* com obrigatoriedade e valores possíveis do ambiente (via metadata cache).
|
|
* Alimenta a tabela "Campos que o Redmine espera" de cada card do editor.
|
|
* @return array de ['path','desc','required','values'=>[]]
|
|
*/
|
|
public static function fieldDictionary($operation) {
|
|
global $DB;
|
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
|
|
$config = count($it) > 0 ? $it->current() : [];
|
|
$meta = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : [];
|
|
|
|
$vals = function ($list) {
|
|
$out = [];
|
|
foreach (($list ?? []) as $e) {
|
|
if (isset($e['id'], $e['name'])) {
|
|
$out[] = $e['id'] . ' = ' . $e['name'];
|
|
}
|
|
}
|
|
return $out;
|
|
};
|
|
$trackers = $vals($meta['trackers'] ?? []);
|
|
$statuses = $vals($meta['statuses'] ?? []);
|
|
$priorities = $vals($meta['priorities'] ?? []);
|
|
$activities = $vals($meta['activities'] ?? []);
|
|
|
|
$D = [];
|
|
$add = function ($path, $desc, $req = false, $values = []) use (&$D) {
|
|
$D[] = ['path' => $path, 'desc' => $desc, 'required' => $req, 'values' => $values];
|
|
};
|
|
|
|
switch ($operation) {
|
|
case 'create_issue':
|
|
$add('issue.subject', 'Título da tarefa (máx. 255)', true);
|
|
$add('issue.description', 'Descrição');
|
|
$add('issue.tracker_id', 'Tipo de tarefa', false, $trackers);
|
|
$add('issue.status_id', 'Situação', false, $statuses);
|
|
$add('issue.priority_id', 'Prioridade', false, $priorities);
|
|
$add('issue.category_id', 'Categoria (os IDs variam por projeto Redmine)');
|
|
$add('issue.assigned_to_id', 'Atribuído para (ID de usuário Redmine)');
|
|
$add('issue.fixed_version_id', 'Versão/marco (por projeto)');
|
|
$add('issue.parent_issue_id', 'Tarefa pai (subtarefa)');
|
|
$add('issue.estimated_hours', 'Horas estimadas (decimal)');
|
|
$add('issue.start_date', 'Data de início (AAAA-MM-DD)');
|
|
$add('issue.due_date', 'Data limite (AAAA-MM-DD)');
|
|
$add('issue.is_private', 'Privada (true/false)');
|
|
$add('issue.watcher_user_ids', 'Observadores (array de IDs)');
|
|
break;
|
|
case 'log_time':
|
|
$add('time_entry.hours', 'Horas (decimal — use {{ tarefa.horas }})', true);
|
|
$add('time_entry.comments', 'Comentário (obrigatório em alguns ambientes)');
|
|
$add('time_entry.activity_id', 'Atividade (habilitada no projeto de destino!)', false, $activities);
|
|
$add('time_entry.spent_on', 'Data do lançamento (AAAA-MM-DD)');
|
|
break;
|
|
case 'update_status':
|
|
$add('issue.status_id', 'Situação', false, $statuses);
|
|
$add('issue.notes', 'Comentário adicionado à issue na atualização');
|
|
$add('issue.assigned_to_id', 'Atribuído para (ID de usuário Redmine)');
|
|
$add('issue.done_ratio', '% concluído (0-100)');
|
|
break;
|
|
case 'create_project':
|
|
$add('project.name', 'Nome do projeto', true);
|
|
$add('project.identifier', 'Identificador (minúsculas — use o filtro | identificador)', true);
|
|
$add('project.description', 'Descrição');
|
|
$add('project.is_public', 'Público (true/false)');
|
|
$add('project.parent_id', 'ID do projeto pai (subprojeto de)');
|
|
$add('project.inherit_members', 'Herdar membros do pai (true/false)');
|
|
$add('project.tracker_ids', 'Tipos de tarefa habilitados (array)', false, $trackers);
|
|
$add('project.enabled_module_names', 'Módulos (array: issue_tracking, time_tracking, wiki...)');
|
|
break;
|
|
}
|
|
|
|
// Custom fields do ambiente, por tipo de objeto da operação
|
|
$type_by_op = ['create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry'];
|
|
$root_by_op = ['create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry'];
|
|
if (isset($type_by_op[$operation])) {
|
|
$cfs = $meta['custom_fields'] ?? $meta['project_custom_fields'] ?? [];
|
|
foreach ($cfs as $f) {
|
|
if (($f['customized_type'] ?? '') !== $type_by_op[$operation]) {
|
|
continue;
|
|
}
|
|
$pv = array_column($f['possible_values'] ?? [], 'value');
|
|
$add(
|
|
$root_by_op[$operation] . '.custom_field_values.' . (int) $f['id'],
|
|
$f['name'] . ' (campo personalizado do seu Redmine)',
|
|
!empty($f['is_required']),
|
|
$pv
|
|
);
|
|
}
|
|
}
|
|
|
|
return $D;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Contexto
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Contexto para o create_project (aba Redmine de Projeto/Entidade):
|
|
* base (config/map) + namespace form.* a partir do POST do formulário.
|
|
*/
|
|
public static function buildFormContext(array $post) {
|
|
$ctx = self::buildContext();
|
|
$form = [
|
|
'nome' => (string) ($post['name'] ?? ''),
|
|
'identificador' => (string) ($post['identifier'] ?? ''),
|
|
'descricao' => (string) ($post['description'] ?? ''),
|
|
'publico' => !empty($post['is_public']),
|
|
'subprojeto_de' => !empty($post['parent_id']) ? (int) $post['parent_id'] : null,
|
|
'trackers' => !empty($post['tracker_ids']) && is_array($post['tracker_ids'])
|
|
? array_values(array_filter(array_map('intval', $post['tracker_ids']))) : null,
|
|
'modulos' => !empty($post['enabled_module_names']) && is_array($post['enabled_module_names'])
|
|
? array_values($post['enabled_module_names']) : null,
|
|
];
|
|
foreach (($post['custom_field_values'] ?? []) as $cfid => $v) {
|
|
if ($v !== '' && $v !== null && $v !== '0' && $v !== 0) {
|
|
$form['cf_' . (int) $cfid] = $v;
|
|
}
|
|
}
|
|
$ctx['form'] = $form;
|
|
return $ctx;
|
|
}
|
|
|
|
/**
|
|
* Monta o contexto de variáveis (a "paleta") para o render.
|
|
*/
|
|
public static function buildContext(?Ticket $ticket = null, ?TicketTask $task = null,
|
|
?array $linkRow = null, $redmineProjectId = null, $issueId = null) {
|
|
$cfg = PluginRedmineConfig::getConfigRow();
|
|
$statusMap = !empty($cfg['status_map']) ? (json_decode($cfg['status_map'], true) ?: []) : [];
|
|
|
|
$ctx = [
|
|
'config' => [
|
|
'tracker_padrao' => ((int) ($cfg['default_tracker_id'] ?? 0)) ?: null,
|
|
'prioridade_padrao' => ((int) ($cfg['default_priority_id'] ?? 0)) ?: null,
|
|
'atividade_padrao' => ((int) ($cfg['default_activity_id'] ?? 0)) ?: null,
|
|
'observadores' => PluginRedmineConfig::getDefaultWatcherIds() ?: null,
|
|
],
|
|
'map' => [
|
|
// ids como int para o JSON final sair numérico
|
|
'status' => array_map('intval', $statusMap),
|
|
],
|
|
'vinculo' => [
|
|
'projeto_redmine' => $redmineProjectId ? (int) $redmineProjectId : null,
|
|
'issue_redmine' => $issueId ? (int) $issueId : null,
|
|
'categoria_padrao' => ($linkRow && !empty($linkRow['default_category_id']))
|
|
? (int) $linkRow['default_category_id'] : null,
|
|
],
|
|
];
|
|
|
|
if ($ticket !== null) {
|
|
global $DB;
|
|
$tecnico = null;
|
|
$itT = $DB->request([
|
|
'SELECT' => 'users_id',
|
|
'FROM' => 'glpi_tickets_users',
|
|
'WHERE' => ['tickets_id' => (int) $ticket->getID(), 'type' => \CommonITILActor::ASSIGN, ['users_id' => ['>', 0]]],
|
|
'LIMIT' => 1,
|
|
]);
|
|
if (count($itT) > 0) {
|
|
$tecnico = getUserName($itT->current()['users_id']);
|
|
}
|
|
$ctx['chamado'] = [
|
|
'id' => (int) $ticket->getID(),
|
|
'titulo' => (string) $ticket->fields['name'],
|
|
'descricao' => (string) $ticket->fields['content'],
|
|
'status' => (int) $ticket->fields['status'],
|
|
'entidade' => (int) $ticket->fields['entities_id'],
|
|
'tecnico' => $tecnico,
|
|
];
|
|
}
|
|
|
|
if ($task !== null) {
|
|
$desc = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? ''))));
|
|
$ctx['tarefa'] = [
|
|
'id' => (int) $task->getID(),
|
|
'segundos' => (int) $task->fields['actiontime'],
|
|
'horas' => round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2),
|
|
'descricao' => $desc,
|
|
// fallback do comentário obrigatório é responsabilidade do MOTOR
|
|
'comentario' => ($desc !== '') ? $desc
|
|
: 'Tempo lançado via GLPI (chamado #' . (int) $task->fields['tickets_id'] . ')',
|
|
'data' => substr((string) ($task->fields['date'] ?? ''), 0, 10) ?: null,
|
|
'autor' => !empty($task->fields['users_id']) ? getUserName($task->fields['users_id']) : null,
|
|
];
|
|
}
|
|
|
|
return $ctx;
|
|
}
|
|
|
|
/**
|
|
* Contexto de EXEMPLO para o testador: config/mapa reais + chamado/tarefa
|
|
* fictícios + custom fields do form preenchidos com o 1º valor possível.
|
|
*/
|
|
public static function sampleContext($operation) {
|
|
global $DB;
|
|
$ctx = self::buildContext();
|
|
|
|
$link = $DB->request(['FROM' => 'glpi_plugin_redmine_projects', 'LIMIT' => 1]);
|
|
$rpid = count($link) > 0 ? (int) $link->current()['redmine_project_id'] : 1;
|
|
$ctx['vinculo']['projeto_redmine'] = $rpid;
|
|
$ctx['vinculo']['issue_redmine'] = 12345;
|
|
|
|
$ctx['chamado'] = [
|
|
'id' => 4321,
|
|
'titulo' => 'Exemplo: erro no servidor de e-mail',
|
|
'descricao' => '<p>Descrição de exemplo do chamado.</p>',
|
|
'status' => 2,
|
|
'entidade' => 0,
|
|
'tecnico' => 'Fulano Técnico (exemplo)',
|
|
];
|
|
$ctx['tarefa'] = [
|
|
'id' => 987,
|
|
'segundos' => 5400,
|
|
'horas' => 1.5,
|
|
'descricao' => 'Tarefa de exemplo concluída',
|
|
'comentario' => 'Tarefa de exemplo concluída',
|
|
'data' => date('Y-m-d'),
|
|
'autor' => 'Fulano Técnico (exemplo)',
|
|
];
|
|
|
|
$form = [
|
|
'nome' => 'Projeto Exemplo',
|
|
'identificador' => 'Projeto-Exemplo',
|
|
'descricao' => 'Descrição de exemplo',
|
|
'publico' => false,
|
|
'subprojeto_de' => null,
|
|
'trackers' => !empty($ctx['config']['tracker_padrao']) ? [$ctx['config']['tracker_padrao']] : null,
|
|
'modulos' => ['issue_tracking', 'time_tracking'],
|
|
];
|
|
// custom fields obrigatórios do form: usa o 1º valor possível (teste passável)
|
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
|
|
$config = count($it) > 0 ? $it->current() : [];
|
|
$meta = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : [];
|
|
foreach (($meta['project_custom_fields'] ?? []) as $f) {
|
|
$pv = array_column($f['possible_values'] ?? [], 'value');
|
|
$form['cf_' . (int) $f['id']] = $pv[0] ?? 'exemplo';
|
|
}
|
|
$ctx['form'] = $form;
|
|
|
|
return $ctx;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Render
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Renderiza um template JSON com o contexto. Lança RuntimeException em
|
|
* template inválido (o chamador cai no motor 1.x).
|
|
* @return array payload pronto (nulls e arrays vazios removidos)
|
|
*/
|
|
public static function render($templateJson, array $ctx) {
|
|
$tpl = json_decode((string) $templateJson, true);
|
|
if (!is_array($tpl)) {
|
|
throw new \RuntimeException('template não é JSON válido');
|
|
}
|
|
$out = self::walk($tpl, $ctx);
|
|
return self::prune($out);
|
|
}
|
|
|
|
private static function walk($node, array $ctx) {
|
|
if (is_array($node)) {
|
|
$out = [];
|
|
foreach ($node as $k => $v) {
|
|
$out[$k] = self::walk($v, $ctx);
|
|
}
|
|
return $out;
|
|
}
|
|
if (is_string($node)) {
|
|
return self::renderString($node, $ctx);
|
|
}
|
|
return $node;
|
|
}
|
|
|
|
private static function renderString($s, array $ctx) {
|
|
// Tag ocupando o valor inteiro => tipo nativo da variável
|
|
if (preg_match('/^\{\{\s*(.+?)\s*\}\}$/s', trim($s), $m) && trim($s) === $s) {
|
|
return self::evalExpr($m[1], $ctx);
|
|
}
|
|
// Tags embutidas => interpolação de string
|
|
return preg_replace_callback('/\{\{\s*(.+?)\s*\}\}/s', function ($m) use ($ctx) {
|
|
$v = self::evalExpr($m[1], $ctx);
|
|
if (is_array($v)) {
|
|
return json_encode($v, JSON_UNESCAPED_UNICODE);
|
|
}
|
|
if (is_bool($v)) {
|
|
return $v ? 'true' : 'false';
|
|
}
|
|
return (string) ($v ?? '');
|
|
}, $s);
|
|
}
|
|
|
|
private static function evalExpr($expr, array $ctx) {
|
|
// pipeline: var | filtro:arg | filtro2 ('|' entre aspas simples é preservado)
|
|
$parts = preg_split("/\|(?=(?:[^']*'[^']*')*[^']*$)/", $expr);
|
|
$value = self::resolveVar(trim(array_shift($parts)), $ctx);
|
|
foreach ($parts as $f) {
|
|
$value = self::applyFilter($value, trim($f));
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
private static function resolveVar($path, array $ctx) {
|
|
// lookup indexado: map.status[chamado.status]
|
|
if (preg_match('/^([a-z0-9_.]+)\[([a-z0-9_.]+)\]$/i', $path, $m)) {
|
|
$map = self::dig($ctx, $m[1]);
|
|
$key = self::dig($ctx, $m[2]);
|
|
if (!is_array($map) || $key === null) {
|
|
return null;
|
|
}
|
|
return $map[$key] ?? $map[(string) $key] ?? null;
|
|
}
|
|
return self::dig($ctx, $path);
|
|
}
|
|
|
|
private static function dig(array $ctx, $path) {
|
|
$cur = $ctx;
|
|
foreach (explode('.', $path) as $p) {
|
|
if (is_array($cur) && array_key_exists($p, $cur)) {
|
|
$cur = $cur[$p];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return $cur;
|
|
}
|
|
|
|
private static function applyFilter($v, $filter) {
|
|
$name = $filter;
|
|
$arg = null;
|
|
if (strpos($filter, ':') !== false) {
|
|
[$name, $arg] = explode(':', $filter, 2);
|
|
$name = trim($name);
|
|
$arg = trim($arg);
|
|
if (preg_match("/^'(.*)'$/s", $arg, $m)) {
|
|
$arg = $m[1];
|
|
}
|
|
}
|
|
switch ($name) {
|
|
case 'truncar':
|
|
return mb_substr((string) $v, 0, max(1, (int) $arg));
|
|
case 'sem_html':
|
|
return trim(strip_tags(html_entity_decode((string) $v)));
|
|
case 'minusculo':
|
|
return strtolower((string) $v);
|
|
case 'identificador':
|
|
return strtolower(preg_replace('/[^a-zA-Z0-9\-_]/', '-', (string) $v));
|
|
case 'padrao':
|
|
return ($v === null || $v === '' || $v === []) ? $arg : $v;
|
|
default:
|
|
// filtro desconhecido: no-op (não derruba o render)
|
|
return $v;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove nulls e arrays vazios (campos não definidos não vão no payload,
|
|
* igual ao motor 1.x). Strings vazias são preservadas.
|
|
*/
|
|
private static function prune($node) {
|
|
if (!is_array($node)) {
|
|
return $node;
|
|
}
|
|
$out = [];
|
|
foreach ($node as $k => $v) {
|
|
$v = self::prune($v);
|
|
if ($v === null || (is_array($v) && $v === [])) {
|
|
continue;
|
|
}
|
|
$out[$k] = $v;
|
|
}
|
|
return $out;
|
|
}
|
|
}
|