From 7ac1f8ac44fc441c0a8a777bb4ff45305cb346ea Mon Sep 17 00:00:00 2001 From: Gemini Date: Thu, 2 Jul 2026 15:25:32 -0300 Subject: [PATCH] =?UTF-8?q?feat(2.0):=20Fase=201=20=E2=80=94=20motor=20de?= =?UTF-8?q?=20templates=20de=20payload=20(design=20+=20engine=20+=20fallba?= =?UTF-8?q?ck=201.x)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 10 ++ docs/DESIGN-2.0.md | 100 +++++++++++ hook.php | 159 ++++++++++++----- inc/redmineapi.class.php | 28 +++ inc/templateengine.class.php | 321 +++++++++++++++++++++++++++++++++++ setup.php | 2 +- 6 files changed, 574 insertions(+), 46 deletions(-) create mode 100644 docs/DESIGN-2.0.md create mode 100644 inc/templateengine.class.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9379f30..a9d5486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ Todas as mudanças relevantes deste plugin são documentadas aqui. O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/) e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/). +## [2.0.0-dev] — em desenvolvimento (branch main; estável = stable/1.x) + +### Adicionado +- **Motor de templates de payload** (Fase 1, invisível na UI — ver `docs/DESIGN-2.0.md`): + templates JSON com tags `{{ var | filtro }}` por operação (`create_issue`, `log_time`, + `update_status`, `create_project`), tabela `glpi_plugin_redmine_templates`, seeds + equivalentes ao comportamento 1.x e descoberta de custom fields obrigatórios. + Sem template ativo, o plugin se comporta exatamente como o 1.x; erro de render + também cai no 1.x (nunca perde lançamento). + ## [1.6.0] ### Adicionado diff --git a/docs/DESIGN-2.0.md b/docs/DESIGN-2.0.md new file mode 100644 index 0000000..288c8a2 --- /dev/null +++ b/docs/DESIGN-2.0.md @@ -0,0 +1,100 @@ +# DESIGN 2.0 — Templates de Payload (motor de mapeamento dinâmico) + +> Objetivo: transformar o plugin — hoje moldado ao ambiente Mindtek — em **produto**: +> os payloads enviados ao Redmine passam a ser **templates editáveis** pelo admin, +> com variáveis do GLPI e campos descobertos do Redmine de destino. +> Linha estável (clientes atuais): branch `stable/1.x`. Evolução: `main` (2.0). + +## Decisões fechadas (2026-07-02) + +| Decisão | Escolha | +|---|---| +| Idioma das tags | **PT-BR** (`{{ chamado.titulo }}`) — público Mindplace | +| UX do editor | **Linha-a-linha** (campo → valor/tag → obrigatório) com toggle "ver JSON" | +| Escopo do template | **Global por operação** na 2.0; override por vínculo na 2.1 | +| Dry-run | **Preview de render sempre**; teste real opt-in contra alvo escolhido | +| Sintaxe | Mustache-like (`{{ var \| filtro }}`), **sem Twig completo** (segurança) | + +## Fronteira Motor × Template + +**Motor (fixo, não editável):** auth, impersonation + auto-membership + fallback admin, +idempotência (1 issue/chamado, tempo 1×/task), recuperação de issue órfã, gates +(Feito + duração>0), normalização de identifier, logs, self-healing de usuários. + +**Template (editável):** o corpo JSON de cada operação — quais campos vão e com +quais valores (tags, valores fixos, custom fields do ambiente). + +O admin mapeia **dados**, nunca **lógica**. + +## Operações e seeds + +4 operações fixas: `create_project`, `create_issue`, `log_time`, `update_status`. +O botão **"Gerar template"** produz o seed = comportamento atual do motor 1.x ++ custom fields obrigatórios descobertos via `/custom_fields.json` +(`customized_type` project/issue/time_entry conforme a operação). + +Seed `create_issue` (exemplo): +```json +{ "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 }}" +} } +``` + +## Sintaxe das tags + +- `{{ namespace.campo }}` — resolução por caminho no contexto. +- `{{ mapa[chave.dinamica] }}` — lookup indexado (ex.: `map.status[chamado.status]`). +- Filtros em pipeline: `{{ var | filtro:arg | filtro2 }}`. + - `truncar:N`, `sem_html`, `minusculo`, `identificador`, `padrao:'texto fixo'`. + +**Regra de tipos:** tag ocupando o **valor inteiro** da string → rende com o **tipo +nativo** da variável (número/array/bool, sem aspas no JSON final). Tag **embutida** +em texto → interpolação de string. Valores `null`/arrays vazios pós-render são +**removidos** do payload (igual ao motor 1.x, que só inclui campos definidos). + +## Contexto (paleta de variáveis) + +| Namespace | Campos | Origem | +|---|---|---| +| `chamado.*` | id, titulo, descricao, status, entidade | Ticket | +| `tarefa.*` | id, horas, segundos, descricao, comentario*, data | TicketTask | +| `vinculo.*` | projeto_redmine, issue_redmine, categoria_padrao | tabelas do plugin | +| `config.*` | tracker_padrao, prioridade_padrao, atividade_padrao, observadores | formconfig | +| `map.status` | mapa status GLPI→Redmine | formconfig | +| `form.*` | campos do form de criar projeto (inclui `cf_`) | aba Redmine | + +\* `tarefa.comentario` = descrição sem HTML **ou** o fallback "Tempo lançado via +GLPI (chamado #N)" — o fallback é responsabilidade do motor (contexto), não do template. + +## Armazenamento e retrocompatibilidade + +Tabela `glpi_plugin_redmine_templates` (`operation` único, `template` MEDIUMTEXT, +`is_active`). **Sem template ativo → motor 1.x intocado.** Template ativo com erro +de **render** → log + motor 1.x (nunca perde lançamento). Template ativo válido → +payload do template é o enviado (sem retry pela via legada em erro de API — respeita +a escolha do admin e evita duplicação). + +## Fases + +1. **Motor (esta fase):** engine de render + tabela + seeds + wiring com fallback nas + operações do hook (`create_issue`, `log_time`, `update_status`). Invisível na UI. +2. **UI:** editor linha-a-linha + paleta + "Gerar template" + wiring do `create_project` + (contexto `form.*`). +3. **Validação:** preview de render + dry-run opcional com tradução de 422. +4. **2.1:** override por vínculo, custom fields de issue/time_entry na paleta, + perfis múltiplos (um GLPI → vários Redmines). + +## Referências + +- Caso de estudo: ambiente Mindtek (KB-PLUGIN-037) — nativo vs "o que o Redmine exigiu" + (custom fields 4/5 de projeto, activity/comments obrigatórios no time entry, trackers + por projeto). +- Redmine **não tem** introspecção de schema: descoberta = esqueleto fixo por operação + + `/custom_fields.json` + dry-run com tradução de erros 422. diff --git a/hook.php b/hook.php index 9a8671c..b7fbfb4 100644 --- a/hook.php +++ b/hook.php @@ -105,6 +105,20 @@ function plugin_redmine_install() { $migration->addPostQuery($query); } + // Payload templates (motor 2.0 — ver docs/DESIGN-2.0.md). Sem template + // ativo, o plugin usa o comportamento 1.x. + if (!$DB->tableExists("glpi_plugin_redmine_templates")) { + $query = "CREATE TABLE `glpi_plugin_redmine_templates` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `operation` varchar(50) NOT NULL, + `template` MEDIUMTEXT DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `operation` (`operation`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"; + $migration->addPostQuery($query); + } + // Cache of Redmine users (populated by "Sincronizar Metadados"). if (!$DB->tableExists("glpi_plugin_redmine_users")) { $query = "CREATE TABLE `glpi_plugin_redmine_users` ( @@ -173,7 +187,8 @@ function plugin_redmine_uninstall() { "glpi_plugin_redmine_tasks", "glpi_plugin_redmine_users", "glpi_plugin_redmine_usermap", - "glpi_plugin_redmine_entities" + "glpi_plugin_redmine_entities", + "glpi_plugin_redmine_templates" ]; foreach ($tables as $table) { @@ -216,6 +231,21 @@ function plugin_redmine_ticket_update(Ticket $ticket) { } $issueId = (int) $issueMap->current()['redmine_issue_id']; + // Motor 2.0: template ativo tem precedência; erro de render cai no 1.x. + $tpl = PluginRedmineTemplateengine::getActive('update_status'); + if ($tpl) { + try { + $ctx = PluginRedmineTemplateengine::buildContext($ticket, null, null, null, $issueId); + $payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx); + if (!empty($payload['issue'])) { + PluginRedmineRedmineapi::updateIssue($issueId, $payload['issue']); + } + return; + } catch (\Throwable $e) { + Toolbox::logInFile('redmine', "Template update_status falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n"); + } + } + $mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']); if ($mappedStatus) { PluginRedmineRedmineapi::updateIssue($issueId, ['status_id' => $mappedStatus]); @@ -380,38 +410,57 @@ function _plugin_redmine_process_tickettask(TicketTask $task) { } if (!$issueId) { - // Build the issue from the ticket + the configured defaults. - $cfg = PluginRedmineConfig::getConfigRow(); - $extra = []; - if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; } - if (!empty($cfg['default_priority_id'])) { $extra['priority_id'] = (int) $cfg['default_priority_id']; } - - // Category is per link (project or entity). - if ($linkRow && !empty($linkRow['default_category_id'])) { - $extra['category_id'] = (int) $linkRow['default_category_id']; - } - - // Issue status mirrors the GLPI ticket status (mapped). - $mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']); - if ($mappedStatus) { $extra['status_id'] = $mappedStatus; } - - // Default watchers come from the config (Redmine user ids). - $watchers = PluginRedmineConfig::getDefaultWatcherIds(); - if (!empty($watchers)) { $extra['watcher_user_ids'] = $watchers; } - // Author = the GLPI assigned technician, via impersonation. $techId = _plugin_redmine_assigned_tech($ticketId); $switchUser = _plugin_redmine_impersonation_login($techId, $redmineProjectId); - // Subject = ticket title; description stripped of HTML. - $description = strip_tags(html_entity_decode((string) $ticket->fields['content'])); - $issueId = PluginRedmineRedmineapi::createIssue( - $redmineProjectId, - $ticket->fields['name'], - $description, - $extra, - $switchUser - ); + // Motor 2.0: template ativo tem precedência. Erro de RENDER cai no 1.x; + // template válido é a palavra final (sem retry pela via legada). + $tpl = PluginRedmineTemplateengine::getActive('create_issue'); + if ($tpl) { + try { + $ctx = PluginRedmineTemplateengine::buildContext($ticket, $task, $linkRow, $redmineProjectId); + $payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx); + if (!empty($payload['issue'])) { + $issueId = PluginRedmineRedmineapi::createIssueFromPayload($payload['issue'], $switchUser); + } + } catch (\Throwable $e) { + Toolbox::logInFile('redmine', "Template create_issue falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n"); + $tpl = null; // render quebrado: deixa o 1.x assumir + } + } + + if (!$tpl) { + // Motor 1.x: issue a partir do chamado + defaults configurados. + $cfg = PluginRedmineConfig::getConfigRow(); + $extra = []; + if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; } + if (!empty($cfg['default_priority_id'])) { $extra['priority_id'] = (int) $cfg['default_priority_id']; } + + // Category is per link (project or entity). + if ($linkRow && !empty($linkRow['default_category_id'])) { + $extra['category_id'] = (int) $linkRow['default_category_id']; + } + + // Issue status mirrors the GLPI ticket status (mapped). + $mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']); + if ($mappedStatus) { $extra['status_id'] = $mappedStatus; } + + // Default watchers come from the config (Redmine user ids). + $watchers = PluginRedmineConfig::getDefaultWatcherIds(); + if (!empty($watchers)) { $extra['watcher_user_ids'] = $watchers; } + + // Subject = ticket title; description stripped of HTML. + $description = strip_tags(html_entity_decode((string) $ticket->fields['content'])); + $issueId = PluginRedmineRedmineapi::createIssue( + $redmineProjectId, + $ticket->fields['name'], + $description, + $extra, + $switchUser + ); + } + if ($issueId) { $DB->insert('glpi_plugin_redmine_tickets', [ 'tickets_id' => $ticketId, @@ -425,26 +474,46 @@ function _plugin_redmine_process_tickettask(TicketTask $task) { return; } - $cfg = PluginRedmineConfig::getConfigRow(); - $hours = round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2); - - // Comments are mandatory on time entries in some Redmine setups — never send empty. - $comments = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? '')))); - if ($comments === '') { - $comments = "Tempo lançado via GLPI (chamado #{$ticketId})"; - } - // Time entry author = the GLPI user who logged the task, via impersonation. $taskUserId = (int) ($task->fields['users_id'] ?? 0); $timeSwitchUser = _plugin_redmine_impersonation_login($taskUserId, $redmineProjectId); - $timeEntryId = PluginRedmineRedmineapi::logTime( - $issueId, - $hours, - $comments, - !empty($cfg['default_activity_id']) ? (int) $cfg['default_activity_id'] : null, - $timeSwitchUser - ); + $timeEntryId = null; + + // Motor 2.0: template ativo tem precedência; erro de render cai no 1.x. + $tpl = PluginRedmineTemplateengine::getActive('log_time'); + if ($tpl) { + try { + $ctx = PluginRedmineTemplateengine::buildContext($ticket, $task, $linkRow, $redmineProjectId, $issueId); + $payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx); + if (!empty($payload['time_entry'])) { + $timeEntryId = PluginRedmineRedmineapi::logTimeFromPayload($payload['time_entry'], $timeSwitchUser); + } + } catch (\Throwable $e) { + Toolbox::logInFile('redmine', "Template log_time falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n"); + $tpl = null; + } + } + + if (!$tpl) { + // Motor 1.x + $cfg = PluginRedmineConfig::getConfigRow(); + $hours = round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2); + + // Comments are mandatory on time entries in some Redmine setups — never send empty. + $comments = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? '')))); + if ($comments === '') { + $comments = "Tempo lançado via GLPI (chamado #{$ticketId})"; + } + + $timeEntryId = PluginRedmineRedmineapi::logTime( + $issueId, + $hours, + $comments, + !empty($cfg['default_activity_id']) ? (int) $cfg['default_activity_id'] : null, + $timeSwitchUser + ); + } if ($timeEntryId) { $DB->insert('glpi_plugin_redmine_tasks', [ diff --git a/inc/redmineapi.class.php b/inc/redmineapi.class.php index 886fa29..fde1118 100644 --- a/inc/redmineapi.class.php +++ b/inc/redmineapi.class.php @@ -150,6 +150,34 @@ class PluginRedmineRedmineapi { return false; } + /** + * Create an Issue from a fully-built payload (template engine 2.0). + * @param array $issue corpo do objeto issue já renderizado + * @return int|false + */ + public static function createIssueFromPayload(array $issue, $switchUser = null) { + $result = self::post('/issues.json', ['issue' => $issue], $switchUser); + if (!$result && !empty($switchUser)) { + Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao criar issue (template); usando admin.\n"); + $result = self::post('/issues.json', ['issue' => $issue]); + } + return ($result && isset($result['issue']['id'])) ? $result['issue']['id'] : false; + } + + /** + * Log time from a fully-built payload (template engine 2.0). + * @param array $entry corpo do time_entry já renderizado + * @return int|false + */ + public static function logTimeFromPayload(array $entry, $switchUser = null) { + $result = self::post('/time_entries.json', ['time_entry' => $entry], $switchUser); + if (!$result && !empty($switchUser)) { + Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao lançar tempo (template); usando admin.\n"); + $result = self::post('/time_entries.json', ['time_entry' => $entry]); + } + return ($result && isset($result['time_entry']['id'])) ? $result['time_entry']['id'] : false; + } + /** * Log time to an Issue * @param int $issueId diff --git a/inc/templateengine.class.php b/inc/templateengine.class.php new file mode 100644 index 0000000..3ad53ab --- /dev/null +++ b/inc/templateengine.class.php @@ -0,0 +1,321 @@ + 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; + } +} diff --git a/setup.php b/setup.php index 1e1311b..b887a85 100644 --- a/setup.php +++ b/setup.php @@ -1,6 +1,6 @@