Redmine/inc/templateengine.class.php
Gemini 7ac1f8ac44 feat(2.0): Fase 1 — motor de templates de payload (design + engine + fallback 1.x)
- docs/DESIGN-2.0.md: decisões fechadas (tags PT, editor linha-a-linha,
  escopo global, dry-run com preview) e arquitetura Motor x Template
- PluginRedmineTemplateengine: render mustache-like ({{ var | filtro }}),
  regra de tipos (tag inteira = tipo nativo), lookup indexado
  (map.status[chamado.status]), filtros (truncar, sem_html, minusculo,
  identificador, padrao), prune de nulls, seeds 1.x-equivalentes e
  descoberta de custom fields obrigatórios
- tabela glpi_plugin_redmine_templates (operation único, is_active)
- wiring com precedência de template + fallback no render em
  create_issue, log_time e update_status
- validado: 12/12 unit + E2E (template ativo cria issue/tempo; template
  quebrado cai no motor 1.x sem perder lançamento)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:25:32 -03:00

321 lines
13 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;
}
/**
* 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;
}
// ------------------------------------------------------------------
// Contexto
// ------------------------------------------------------------------
/**
* 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) {
$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'],
];
}
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,
];
}
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;
}
}